From 36976713874550ff48b51f954a59dd597c36fa87 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 24 Jul 2026 11:27:06 +0300 Subject: [PATCH] feat(db): webhook + user tables (Phase 5a foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB foundation for GraphQL-native webhooks, matching the core-go / pages.sr.ht convention: - "user" table — spec is single-owner, but the webhook subscription is user-scoped in core-go's model, so a user row is the owner's identity and the FK target. Columns mirror core-go auth.LookupUser so the table is ready if the service ever adopts core-go auth; today only id/username are used (owner seeded at startup). - webhook_event enum (PROPOSAL_OPENED/MERGED/REJECTED), auth_method enum. - gql_user_wh_sub / gql_user_wh_delivery — column names match core-go's webhooks engine exactly (it selects/inserts by these names). The events check uses cardinality() not array_length() so an empty event list is rejected at the DB (array_length returns NULL there, which a CHECK does not reject — a latent gap in the upstream convention). Webhook tables use bare `timestamp` (not spec's TIMESTAMPTZ) to match core-go's `NOW() at time zone 'utc'` inserts. Verified: schema.sql and the 0003 migration both apply and reverse cleanly; FK/check/cascade all behave. --- .up/phase5a-webhooks.md | 57 ++++++++++++++++++++++++++++++ migrations/0003_webhooks.sql | 67 ++++++++++++++++++++++++++++++++++++ schema.sql | 66 +++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 .up/phase5a-webhooks.md create mode 100644 migrations/0003_webhooks.sql diff --git a/.up/phase5a-webhooks.md b/.up/phase5a-webhooks.md new file mode 100644 index 0000000000000000000000000000000000000000..c1a24cfcd19ad9673775eea392ba150daeb43f6a --- /dev/null +++ b/.up/phase5a-webhooks.md @@ -0,0 +1,57 @@ +# Phase 5a — GraphQL-native webhooks (proposal lifecycle) + +**Mode:** hands-off +**Branch:** feat/phase5a-webhooks +**Beads:** spec-3m9 (epic), spec-8y4 (conventions), spec-45j (webhook feature) + +## Design + +Emit webhooks on proposal open/merge/reject, GraphQL-native, matching the +core-go webhook engine used by pages.sr.ht / lists.sr.ht. spec.sr.ht keeps its +own `authn` (owner/agent/anonymous + provenance) as the single auth policy — +core-go's auth model has no agent concept — and a thin adapter derives the +core-go `auth.AuthContext` the webhook engine consumes. Webhooks are +owner-scoped (`gql_user_wh_sub`, one user = the owner); events are +PROPOSAL_OPENED / PROPOSAL_MERGED / PROPOSAL_REJECTED; the agent identity that +triggered an event rides in the payload (the proposal), not the auth context. + +Reference: /Users/blikh/data/home/tmp/pages.sr.ht (same fork). + +## Plan (phases) + +1. **DB foundation** — `user` table + owner seed; `webhook_event` / `auth_method` + enums; `gql_user_wh_sub` + `gql_user_wh_delivery` tables. schema.sql + + migrations/0003. +2. **AuthContext bridge** — an in-spec adapter mapping `authn.Principal` → + core-go `auth.AuthContext` (owner→AUTH_COOKIE+UserID; agent→owner's UserID), + factored to lift into a future `sr-ht-ext` module. +3. **Server + context wiring** — install core-go database + webhooks context on + /query and the delivery worker; keep spec's surfaces/auth intact (no core-go + 401-by-default). +4. **Webhook SDL + models + resolvers** — WebhookEvent/Subscription/Delivery/ + Payload types, create/delete mutations, webhooks/webhook queries, the + `webhook` payload root field; gqlgen regen. +5. **Firing** — service event hook → webhooks.Schedule on open/merge/reject. + +## Conclusion + +### Hands-off decisions +- make: Mode hands-off (user invoked /up:handsoff). +- make: dedicated branch `feat/phase5a-webhooks`; worktree skipped — session is + configured for in-place work; isolation via the branch, master untouched. +- udesign: keep spec's `authn` as the single auth policy; derive a core-go + `auth.AuthContext` for the webhook engine only (core-go auth cannot model + agents/provenance without breaking Phases 3-4). +- uplan: adopt core-go's webhook ENGINE (queue/delivery/Ed25519 signing/ + GraphQL-native Exec) + the pages.sr.ht schema/SDL shape; do NOT rewrite the + existing read resolvers onto database.Model — only the webhook models need it. +- uplan: webhooks are owner-scoped (`gql_user_wh_sub`), matching pages.sr.ht's + user-scoped pattern; the single owner is the only user. + +### Deferred (needs user input) +- extensions-go / `sr-ht-ext` module — extracting spec's agent model into a + shared module (location, name, publish target, CI wiring). User said it's + "too early to judge"; building the bridge in-spec, factored for later + extraction. Revisit when a second consumer exists or after this ships. +- gqlgen regeneration runs `go generate ./graph` (pulls gqlgen at generate-time + and rewrites graph/api/generated.go). Will run during phase 4; flagged. diff --git a/migrations/0003_webhooks.sql b/migrations/0003_webhooks.sql new file mode 100644 index 0000000000000000000000000000000000000000..3181d41f5fa52fc7f15bb39deb8c85846c74671b --- /dev/null +++ b/migrations/0003_webhooks.sql @@ -0,0 +1,67 @@ +-- +brant Up + +-- Users. spec.sr.ht is single-owner, but the webhook subscription is +-- user-scoped in the core-go convention, so a user row is the owner's identity +-- and the FK target. Columns mirror what core-go's auth.LookupUser selects, so +-- the table is ready if the service ever adopts core-go auth wholesale; today +-- only id/username are used (the owner row is seeded at startup). +CREATE TYPE user_type AS ENUM ('PENDING','USER','ADMIN','SUSPENDED'); +CREATE TABLE "user" ( + id serial PRIMARY KEY, + created timestamp NOT NULL, + updated timestamp NOT NULL, + username varchar(256) NOT NULL UNIQUE, + email varchar(256) NOT NULL DEFAULT '', + user_type user_type NOT NULL DEFAULT 'USER', + url varchar, + location varchar, + bio varchar, + suspension_notice varchar +); + +-- Proposal lifecycle events a webhook may subscribe to. +CREATE TYPE webhook_event AS ENUM ('PROPOSAL_OPENED','PROPOSAL_MERGED','PROPOSAL_REJECTED'); + +-- The auth captured on a subscription at creation time (core-go webhooks/config.go +-- AuthConfig). Only OAUTH2 and INTERNAL are storable; the check enforces it. +CREATE TYPE auth_method AS ENUM ('OAUTH_LEGACY','OAUTH2','COOKIE','INTERNAL','WEBHOOK'); + +-- GraphQL-native user webhook subscription. The auth columns +-- (auth_method/token_hash/grants/client_id/expires/node_id) mirror core-go's +-- webhooks.WebhookSubscription exactly; the engine selects them by these names. +CREATE TABLE gql_user_wh_sub ( + id serial PRIMARY KEY, + created timestamp NOT NULL, + events webhook_event[] NOT NULL CHECK (cardinality(events) > 0), + url varchar NOT NULL, + query varchar NOT NULL, + auth_method auth_method NOT NULL CHECK (auth_method IN ('OAUTH2','INTERNAL')), + token_hash varchar(128) CHECK ((auth_method = 'OAUTH2') = (token_hash IS NOT NULL)), + grants varchar, + client_id uuid, + expires timestamp CHECK ((auth_method = 'OAUTH2') = (expires IS NOT NULL)), + node_id varchar CHECK ((auth_method = 'INTERNAL') = (node_id IS NOT NULL)), + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE +); +CREATE INDEX gql_user_wh_sub_token_hash_idx ON gql_user_wh_sub (token_hash); + +-- Delivery records. Columns match core-go's insert/update in webhooks/queue.go. +CREATE TABLE gql_user_wh_delivery ( + id serial PRIMARY KEY, + uuid uuid NOT NULL, + date timestamp NOT NULL, + event webhook_event NOT NULL, + subscription_id integer NOT NULL REFERENCES gql_user_wh_sub(id) ON DELETE CASCADE, + request_body varchar NOT NULL, + response_body varchar, + response_headers varchar, + response_status integer +); + +-- +brant Down +DROP TABLE gql_user_wh_delivery; +DROP TABLE gql_user_wh_sub; +DROP TYPE auth_method; +DROP TYPE webhook_event; +DROP TABLE "user"; +DROP TYPE user_type; diff --git a/schema.sql b/schema.sql index 39cb03326a2a487f9aa491a71385c27f2787be93..ff4c74bb578febd7db3f0145f6321ee503be720a 100644 --- a/schema.sql +++ b/schema.sql @@ -126,3 +126,69 @@ CREATE TABLE project_space ( -- The other direction: which projects contain this space. Not covered by the -- primary key, whose leading column is project_id. CREATE INDEX ix_project_space_space ON project_space (space_id); + +-- GraphQL-native webhooks (added in migrations/0003_webhooks.sql). +-- +-- These tables are written and read by core-go's webhook engine, which inserts +-- `NOW() at time zone 'utc'` — a `timestamp` WITHOUT time zone. So, unlike +-- spec's own tables above (which use TIMESTAMPTZ), everything below uses bare +-- `timestamp` to match pages.sr.ht and avoid timezone coercion. This is +-- deliberate; do not "fix" it to TIMESTAMPTZ. + +-- Users. spec.sr.ht is single-owner, but the webhook subscription is +-- user-scoped in the core-go convention, so a user row is the owner's identity +-- and the FK target. Columns mirror what core-go's auth.LookupUser selects, so +-- the table is ready if the service ever adopts core-go auth wholesale; today +-- only id/username are used (the owner row is seeded at startup). +CREATE TYPE user_type AS ENUM ('PENDING','USER','ADMIN','SUSPENDED'); +CREATE TABLE "user" ( + id serial PRIMARY KEY, + created timestamp NOT NULL, + updated timestamp NOT NULL, + username varchar(256) NOT NULL UNIQUE, + email varchar(256) NOT NULL DEFAULT '', + user_type user_type NOT NULL DEFAULT 'USER', + url varchar, + location varchar, + bio varchar, + suspension_notice varchar +); + +-- Proposal lifecycle events a webhook may subscribe to. +CREATE TYPE webhook_event AS ENUM ('PROPOSAL_OPENED','PROPOSAL_MERGED','PROPOSAL_REJECTED'); + +-- The auth captured on a subscription at creation time (core-go webhooks/config.go +-- AuthConfig). Only OAUTH2 and INTERNAL are storable; the check enforces it. +CREATE TYPE auth_method AS ENUM ('OAUTH_LEGACY','OAUTH2','COOKIE','INTERNAL','WEBHOOK'); + +-- GraphQL-native user webhook subscription. The auth columns +-- (auth_method/token_hash/grants/client_id/expires/node_id) mirror core-go's +-- webhooks.WebhookSubscription exactly; the engine selects them by these names. +CREATE TABLE gql_user_wh_sub ( + id serial PRIMARY KEY, + created timestamp NOT NULL, + events webhook_event[] NOT NULL CHECK (cardinality(events) > 0), + url varchar NOT NULL, + query varchar NOT NULL, + auth_method auth_method NOT NULL CHECK (auth_method IN ('OAUTH2','INTERNAL')), + token_hash varchar(128) CHECK ((auth_method = 'OAUTH2') = (token_hash IS NOT NULL)), + grants varchar, + client_id uuid, + expires timestamp CHECK ((auth_method = 'OAUTH2') = (expires IS NOT NULL)), + node_id varchar CHECK ((auth_method = 'INTERNAL') = (node_id IS NOT NULL)), + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE +); +CREATE INDEX gql_user_wh_sub_token_hash_idx ON gql_user_wh_sub (token_hash); + +-- Delivery records. Columns match core-go's insert/update in webhooks/queue.go. +CREATE TABLE gql_user_wh_delivery ( + id serial PRIMARY KEY, + uuid uuid NOT NULL, + date timestamp NOT NULL, + event webhook_event NOT NULL, + subscription_id integer NOT NULL REFERENCES gql_user_wh_sub(id) ON DELETE CASCADE, + request_body varchar NOT NULL, + response_body varchar, + response_headers varchar, + response_status integer +);