A .up/phase5a-webhooks.md => .up/phase5a-webhooks.md +57 -0
@@ 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.
A migrations/0003_webhooks.sql => migrations/0003_webhooks.sql +67 -0
@@ 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;
M schema.sql => schema.sql +66 -0
@@ 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
+);