Artifact ff64353054bc6f3bb74c226a5e7463904789fbf5d4f6acae2d8734e7b03c155e:
- File
internal/oltp/webhook.go
— part of check-in
[e9547dcc49]
at
2026-08-24 17:16:30
on branch hooks-vocabulary
— Subscriptions select their events
A webhook took everything or nothing. It now names vocabulary patterns: '*', a bare noun for every action on it, or an exact kind. Checked at the moment it is set, because a webhook that silently delivers nothing reads as a broken forge. (user: dmestas, size: 6202) [annotate] [blame] [check-ins using] [more...]
// webhook.go stores webhook subscriptions — Principle 6's standard // outbound surface. A subscription is forge configuration, not DAG // state, so it belongs in the OLTP database beside accounts (0001 §8). package oltp import ( "crypto/rand" "encoding/hex" "errors" "fmt" "net/url" "strings" "time" ) const webhookSchema = ` CREATE TABLE IF NOT EXISTS webhook ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, url TEXT NOT NULL, secret TEXT NOT NULL, -- HMAC-SHA256 key; shown once, at creation active INTEGER NOT NULL DEFAULT 1, created TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS webhook_by_repo ON webhook (repo, active);` // webhookMigrations are additive columns for stores written by an // earlier schema. // // events: which of RFC 0005's vocabulary this endpoint wants, as // space-separated patterns. `*` is every event, which is what every // subscription written before this column got — the default preserves // exactly the behaviour those owners configured, which was "send me // everything" by not having been asked. var webhookMigrations = []string{ `ALTER TABLE webhook ADD COLUMN events TEXT NOT NULL DEFAULT '*'`, } // DefaultEvents is what a subscription selects when its owner does not // say. Everything, because a webhook exists to be told things. const DefaultEvents = "*" // Webhook is one subscription. Secret is only populated when the // subscription is created — listing never returns it, because a // signing key that can be read back is a signing key that leaks. type Webhook struct { ID int64 Repo string URL string Secret string Active bool // Events is the space-separated vocabulary patterns this endpoint // selects (RFC 0005). `*` is everything; `ticket` is every action on // tickets; `landing.accepted` is exactly that. Events string Created string } // AddWebhook registers an endpoint and mints its signing secret. The // secret is returned once and never retrievable afterwards. func (d *DB) AddWebhook(repo, endpoint, events string) (Webhook, error) { if repo == "" { return Webhook{}, errors.New("oltp: webhook needs a repository") } if strings.TrimSpace(events) == "" { events = DefaultEvents } // Only http(s): a webhook URL is fetched by the server, so any other // scheme is a request to make quarry speak a protocol on someone // else's behalf. u, err := url.Parse(endpoint) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return Webhook{}, fmt.Errorf("oltp: webhook url must be http or https, got %q", endpoint) } var buf [32]byte if _, err := rand.Read(buf[:]); err != nil { return Webhook{}, err } secret := hex.EncodeToString(buf[:]) created := time.Now().UTC().Format(time.RFC3339) res, err := d.db.Exec(`INSERT INTO webhook (repo, url, secret, active, events, created) VALUES (?, ?, ?, 1, ?, ?)`, repo, endpoint, secret, events, created) if err != nil { return Webhook{}, err } id, _ := res.LastInsertId() return Webhook{ID: id, Repo: repo, URL: endpoint, Secret: secret, Active: true, Events: events, Created: created}, nil } // Webhooks lists a repository's subscriptions. Secret is deliberately // left empty; ActiveWebhooks is the dispatcher's accessor. func (d *DB) Webhooks(repo string) ([]Webhook, error) { rows, err := d.db.Query(`SELECT id, repo, url, active, events, created FROM webhook WHERE repo = ? ORDER BY id`, repo) if err != nil { return nil, err } defer rows.Close() var out []Webhook for rows.Next() { var w Webhook var active int if err := rows.Scan(&w.ID, &w.Repo, &w.URL, &active, &w.Events, &w.Created); err != nil { return nil, err } w.Active = active != 0 out = append(out, w) } return out, rows.Err() } // ActiveWebhooks returns the subscriptions to deliver to, secrets // included — the dispatcher needs them to sign. func (d *DB) ActiveWebhooks(repo string) ([]Webhook, error) { rows, err := d.db.Query(`SELECT id, repo, url, secret, events, created FROM webhook WHERE repo = ? AND active = 1 ORDER BY id`, repo) if err != nil { return nil, err } defer rows.Close() var out []Webhook for rows.Next() { var w Webhook if err := rows.Scan(&w.ID, &w.Repo, &w.URL, &w.Secret, &w.Events, &w.Created); err != nil { return nil, err } w.Active = true out = append(out, w) } return out, rows.Err() } // WebhookByID returns one subscription with its secret, for redelivery. func (d *DB) WebhookByID(id int64) (Webhook, bool) { var w Webhook var active int err := d.db.QueryRow(`SELECT id, repo, url, secret, active, events, created FROM webhook WHERE id = ?`, id).Scan(&w.ID, &w.Repo, &w.URL, &w.Secret, &active, &w.Events, &w.Created) if err != nil { return Webhook{}, false } w.Active = active != 0 return w, true } // SetWebhookActive pauses or resumes a subscription. Pausing is kept // distinct from deleting so an endpoint that is down can be silenced // without losing its identity and secret. func (d *DB) SetWebhookActive(id int64, active bool) error { res, err := d.db.Exec(`UPDATE webhook SET active = ? WHERE id = ?`, boolInt(active), id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("oltp: no webhook %d", id) } return nil } // SetWebhookEvents changes which events an endpoint selects. Kept // separate from pausing: narrowing a subscription is a routine edit, // where pausing is a statement about the endpoint's health. func (d *DB) SetWebhookEvents(id int64, events string) error { if strings.TrimSpace(events) == "" { events = DefaultEvents } res, err := d.db.Exec(`UPDATE webhook SET events = ? WHERE id = ?`, events, id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("oltp: no webhook %d", id) } return nil } // DeleteWebhook removes a subscription. func (d *DB) DeleteWebhook(id int64) error { _, err := d.db.Exec(`DELETE FROM webhook WHERE id = ?`, id) return err } // redacted keeps a secret out of logs and errors if one is ever printed. func (w Webhook) String() string { return fmt.Sprintf("webhook %d %s -> %s (secret %s)", w.ID, w.Repo, w.URL, strings.Repeat("*", 8)) }