~bigbes/sr-ht-spec

36976713874550ff48b51f954a59dd597c36fa87 — Eugene Blikh 25 days ago 7f779fe
feat(db): webhook + user tables (Phase 5a foundation)

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.
3 files changed, 190 insertions(+), 0 deletions(-)

A .up/phase5a-webhooks.md
A migrations/0003_webhooks.sql
M schema.sql
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
);