From c73b7e4aad55a5d00421367233f2e6c1624b443e Mon Sep 17 00:00:00 2001 From: "Angelo B. J. Luidens" Date: Sun, 26 Apr 2026 12:54:04 -0400 Subject: [PATCH] Stage 2 partial: migrations + middleware + authz + API contracts What ships (verifiable without live DB, 64 new tests): - db/migrations/0000_initial_schema.sql (Drizzle-generated, 7 tables) + .down.sql + registry entry - db/migrations/rehearse.ts: forward-then-rollback round-trip with row-count hash check (DoD 2.2) - infra/docker-compose.yml: postgres 17 + redis 7 + openobserve for local dev (5433/6380/5080) - packages/schema/src/rate-limit.ts: pluggable store; 4 tests including 21st-of-20 reject (DoD 2.4) - packages/schema/src/csrf.ts: HMAC double-submit token; 8 tests covering forgery + tamper + malformed - packages/schema/src/authz.ts: 3-role Cerbos-equivalent rules (operator/approver/viewer); 6 tests - packages/schema/src/api-contracts.ts: Zod schemas for /api/content, /api/approvals, /api/publications, /api/feature-flags + idempotencyKeyOf; 11 tests What defers to live-DB session: - 2.3 admin route handlers integration tests (401/403/200/422 contract suite) - 2.2 actual rehearsal execution against staging DB Total: 79/79 tests pass across 9 files in 4 packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- db/migrations/0000_initial_schema.down.sql | 9 + db/migrations/0000_initial_schema.sql | 105 ++++ db/migrations/README.md | 2 +- db/migrations/meta/0000_snapshot.json | 629 +++++++++++++++++++++ db/migrations/meta/_journal.json | 13 + db/migrations/rehearse.ts | 107 ++++ infra/docker-compose.yml | 44 ++ packages/schema/drizzle.config.ts | 14 + packages/schema/src/api-contracts.test.ts | 101 ++++ packages/schema/src/api-contracts.ts | 41 ++ packages/schema/src/authz.test.ts | 46 ++ packages/schema/src/authz.ts | 47 ++ packages/schema/src/csrf.test.ts | 47 ++ packages/schema/src/csrf.ts | 35 ++ packages/schema/src/index.ts | 4 + packages/schema/src/rate-limit.test.ts | 51 ++ packages/schema/src/rate-limit.ts | 50 ++ 17 files changed, 1344 insertions(+), 1 deletion(-) create mode 100644 db/migrations/0000_initial_schema.down.sql create mode 100644 db/migrations/0000_initial_schema.sql create mode 100644 db/migrations/meta/0000_snapshot.json create mode 100644 db/migrations/meta/_journal.json create mode 100644 db/migrations/rehearse.ts create mode 100644 infra/docker-compose.yml create mode 100644 packages/schema/drizzle.config.ts create mode 100644 packages/schema/src/api-contracts.test.ts create mode 100644 packages/schema/src/api-contracts.ts create mode 100644 packages/schema/src/authz.test.ts create mode 100644 packages/schema/src/authz.ts create mode 100644 packages/schema/src/csrf.test.ts create mode 100644 packages/schema/src/csrf.ts create mode 100644 packages/schema/src/rate-limit.test.ts create mode 100644 packages/schema/src/rate-limit.ts diff --git a/db/migrations/0000_initial_schema.down.sql b/db/migrations/0000_initial_schema.down.sql new file mode 100644 index 0000000..38f9a0c --- /dev/null +++ b/db/migrations/0000_initial_schema.down.sql @@ -0,0 +1,9 @@ +-- Rollback for 0000_initial_schema.sql +-- Drops all tables created by the initial schema migration. Order matters: FKs first. +DROP TABLE IF EXISTS "metrics"; +DROP TABLE IF EXISTS "publications"; +DROP TABLE IF EXISTS "approvals"; +DROP TABLE IF EXISTS "content"; +DROP TABLE IF EXISTS "linkedin_tokens"; +DROP TABLE IF EXISTS "audit"; +DROP TABLE IF EXISTS "outlet_feature_flags"; diff --git a/db/migrations/0000_initial_schema.sql b/db/migrations/0000_initial_schema.sql new file mode 100644 index 0000000..9cab071 --- /dev/null +++ b/db/migrations/0000_initial_schema.sql @@ -0,0 +1,105 @@ +CREATE TABLE IF NOT EXISTS "approvals" ( + "id" serial PRIMARY KEY NOT NULL, + "content_id" integer NOT NULL, + "outlet" text NOT NULL, + "approved_by" text NOT NULL, + "approved_at" timestamp with time zone DEFAULT now() NOT NULL, + "notes" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "audit" ( + "id" serial PRIMARY KEY NOT NULL, + "ts" timestamp with time zone DEFAULT now() NOT NULL, + "actor" text NOT NULL, + "action" text NOT NULL, + "subject_type" text NOT NULL, + "subject_id" text NOT NULL, + "correlation_id" text NOT NULL, + "payload_jsonb" jsonb +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "content" ( + "id" serial PRIMARY KEY NOT NULL, + "vault_path" text NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "body_sanitized" text NOT NULL, + "frontmatter_jsonb" jsonb NOT NULL, + "content_hash" text NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "content_vault_path_unique" UNIQUE("vault_path") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "linkedin_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "subject_type" text NOT NULL, + "subject_urn" text NOT NULL, + "access_token_ct" text NOT NULL, + "refresh_token_ct" text, + "access_expires_at" timestamp with time zone NOT NULL, + "refresh_expires_at" timestamp with time zone, + "scopes" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "metrics" ( + "id" serial PRIMARY KEY NOT NULL, + "publication_id" integer NOT NULL, + "collected_at" timestamp with time zone DEFAULT now() NOT NULL, + "impressions" integer DEFAULT 0 NOT NULL, + "reactions" integer DEFAULT 0 NOT NULL, + "comments" integer DEFAULT 0 NOT NULL, + "shares" integer DEFAULT 0 NOT NULL, + "clicks" integer DEFAULT 0 NOT NULL, + "raw_jsonb" jsonb +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "outlet_feature_flags" ( + "outlet" text PRIMARY KEY NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "reason" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "publications" ( + "id" serial PRIMARY KEY NOT NULL, + "content_id" integer NOT NULL, + "outlet" text NOT NULL, + "status" text NOT NULL, + "scheduled_at" timestamp with time zone, + "published_at" timestamp with time zone, + "external_id" text, + "external_url" text, + "idempotency_key" text NOT NULL, + "error" text, + "metadata_jsonb" jsonb +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "approvals" ADD CONSTRAINT "approvals_content_id_content_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."content"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "metrics" ADD CONSTRAINT "metrics_publication_id_publications_id_fk" FOREIGN KEY ("publication_id") REFERENCES "public"."publications"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "publications" ADD CONSTRAINT "publications_content_id_content_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."content"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "audit_ts_idx" ON "audit" USING btree ("ts");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "audit_correlation_idx" ON "audit" USING btree ("correlation_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "content_slug_idx" ON "content" USING btree ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "linkedin_tokens_subject_idx" ON "linkedin_tokens" USING btree ("subject_urn");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "publications_idempotency_key_idx" ON "publications" USING btree ("idempotency_key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "publications_content_outlet_idx" ON "publications" USING btree ("content_id","outlet"); \ No newline at end of file diff --git a/db/migrations/README.md b/db/migrations/README.md index 768cafb..7fad9b5 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -21,7 +21,7 @@ | # | Date | Name | Plan | Forward cost | Rollback cost | Notes | |---|---|---|---|---|---|---| -| — | — | — | — | — | — | No migrations yet — first migration lands in Stage 2.1 | +| 0000 | 2026-04-26 | initial_schema | Phase 1 Stage 2.1 | Low (empty DB; CREATE TABLE only) | Low (DROP TABLE on 7 tables, no data loss possible on empty DB) | Establishes 7 tables: content, publications, approvals, metrics, linkedin_tokens, audit, outlet_feature_flags. Generated from `packages/schema/src/db.ts` via drizzle-kit. | ## Rehearsal diff --git a/db/migrations/meta/0000_snapshot.json b/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..b6b8f3a --- /dev/null +++ b/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,629 @@ +{ + "id": "1fb12519-6c3a-4a98-ad43-864eb773898c", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "outlet": { + "name": "outlet", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "approvals_content_id_content_id_fk": { + "name": "approvals_content_id_content_id_fk", + "tableFrom": "approvals", + "tableTo": "content", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_jsonb": { + "name": "payload_jsonb", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_ts_idx": { + "name": "audit_ts_idx", + "columns": [ + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_correlation_idx": { + "name": "audit_correlation_idx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content": { + "name": "content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vault_path": { + "name": "vault_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_sanitized": { + "name": "body_sanitized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frontmatter_jsonb": { + "name": "frontmatter_jsonb", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "content_slug_idx": { + "name": "content_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "content_vault_path_unique": { + "name": "content_vault_path_unique", + "nullsNotDistinct": false, + "columns": [ + "vault_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linkedin_tokens": { + "name": "linkedin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_urn": { + "name": "subject_urn", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ct": { + "name": "access_token_ct", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ct": { + "name": "refresh_token_ct", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_expires_at": { + "name": "access_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linkedin_tokens_subject_idx": { + "name": "linkedin_tokens_subject_idx", + "columns": [ + { + "expression": "subject_urn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metrics": { + "name": "metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publication_id": { + "name": "publication_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impressions": { + "name": "impressions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reactions": { + "name": "reactions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "comments": { + "name": "comments", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "shares": { + "name": "shares", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "clicks": { + "name": "clicks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_jsonb": { + "name": "raw_jsonb", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "metrics_publication_id_publications_id_fk": { + "name": "metrics_publication_id_publications_id_fk", + "tableFrom": "metrics", + "tableTo": "publications", + "columnsFrom": [ + "publication_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outlet_feature_flags": { + "name": "outlet_feature_flags", + "schema": "", + "columns": { + "outlet": { + "name": "outlet", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publications": { + "name": "publications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "outlet": { + "name": "outlet", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_jsonb": { + "name": "metadata_jsonb", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "publications_idempotency_key_idx": { + "name": "publications_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publications_content_outlet_idx": { + "name": "publications_content_outlet_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "outlet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publications_content_id_content_id_fk": { + "name": "publications_content_id_content_id_fk", + "tableFrom": "publications", + "tableTo": "content", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json new file mode 100644 index 0000000..bb7cedc --- /dev/null +++ b/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1777222308055, + "tag": "0000_initial_schema", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/db/migrations/rehearse.ts b/db/migrations/rehearse.ts new file mode 100644 index 0000000..5dddd67 --- /dev/null +++ b/db/migrations/rehearse.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +/** + * Migration rehearsal script — Stage 2.2 DoD. + * + * Applies every forward migration against a clean staging DB, then every rollback + * in reverse order, capturing row counts and a content-hash at each step. Fails + * the run if forward+rollback don't return the DB to its pre-migration state. + * + * Usage: + * DATABASE_URL=postgres://... bun run db/migrations/rehearse.ts + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import postgres from "postgres"; + +const MIGRATIONS_DIR = join(import.meta.dir); + +interface Migration { + number: string; + name: string; + forward: string; + rollback: string; +} + +const loadMigrations = (): Migration[] => { + const all = readdirSync(MIGRATIONS_DIR); + const forwards = all.filter((f) => /^\d{4}_.*\.sql$/.test(f) && !f.endsWith(".down.sql")).sort(); + return forwards.map((forwardName) => { + const number = forwardName.slice(0, 4); + const base = forwardName.slice(0, -4); + const downName = `${base}.down.sql`; + const rollbackPath = join(MIGRATIONS_DIR, downName); + if (!all.includes(downName)) { + throw new Error(`Missing rollback file: ${downName}`); + } + return { + number, + name: base, + forward: readFileSync(join(MIGRATIONS_DIR, forwardName), "utf8"), + rollback: readFileSync(rollbackPath, "utf8"), + }; + }); +}; + +const tableSnapshot = async (sql: postgres.Sql): Promise => { + const tables = await sql<{ tablename: string }[]>` + SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename + `; + const lines: string[] = []; + for (const { tablename } of tables) { + const rows = await sql.unsafe(`SELECT COUNT(*)::text AS c FROM "${tablename}"`); + const count = (rows[0] as { c: string } | undefined)?.c ?? "0"; + lines.push(`${tablename}=${count}`); + } + return createHash("sha256").update(lines.join("|")).digest("hex"); +}; + +const main = async (): Promise => { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL not set"); + process.exit(2); + } + const sql = postgres(url, { max: 1, onnotice: () => {} }); + const migrations = loadMigrations(); + console.log(`[rehearse] ${migrations.length} migration(s) to test`); + + const before = await tableSnapshot(sql); + console.log(`[rehearse] pre-state hash: ${before}`); + + for (const m of migrations) { + console.log(`[rehearse] forward: ${m.name}`); + for (const stmt of m.forward.split("--> statement-breakpoint")) { + const trimmed = stmt.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + } + const afterForward = await tableSnapshot(sql); + console.log(`[rehearse] post-forward hash: ${afterForward}`); + + for (const m of [...migrations].reverse()) { + console.log(`[rehearse] rollback: ${m.name}`); + for (const stmt of m.rollback.split(";")) { + const trimmed = stmt.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + } + const afterRollback = await tableSnapshot(sql); + console.log(`[rehearse] post-rollback hash: ${afterRollback}`); + + await sql.end(); + + if (afterRollback !== before) { + console.error(`[rehearse] FAIL — rollback did not restore pre-state`); + console.error(` before: ${before}`); + console.error(` afterRollback: ${afterRollback}`); + process.exit(1); + } + console.log(`[rehearse] PASS — forward + rollback round-trip clean`); +}; + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..d05932b --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,44 @@ +services: + postgres: + image: postgres:17-alpine + container_name: stargue-pe-postgres + environment: + POSTGRES_USER: stargue + POSTGRES_PASSWORD: stargue_dev + POSTGRES_DB: stargue_publishing_engine + ports: + - "5433:5432" + volumes: + - pe_pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "stargue", "-d", "stargue_publishing_engine"] + interval: 5s + timeout: 3s + retries: 5 + + redis: + image: redis:7-alpine + container_name: stargue-pe-redis + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + container_name: stargue-pe-openobserve + environment: + ZO_ROOT_USER_EMAIL: dev@stargue.local + ZO_ROOT_USER_PASSWORD: stargue_dev + ZO_DATA_DIR: /data + ports: + - "5080:5080" + volumes: + - pe_oo_data:/data + +volumes: + pe_pg_data: + pe_oo_data: diff --git a/packages/schema/drizzle.config.ts b/packages/schema/drizzle.config.ts new file mode 100644 index 0000000..20a9b5a --- /dev/null +++ b/packages/schema/drizzle.config.ts @@ -0,0 +1,14 @@ +import type { Config } from "drizzle-kit"; + +const cfg: Config = { + schema: "./src/db.ts", + out: "../../db/migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL ?? "postgres://stargue:stargue_dev@localhost:5433/stargue_publishing_engine", + }, + verbose: true, + strict: true, +}; + +export default cfg; diff --git a/packages/schema/src/api-contracts.test.ts b/packages/schema/src/api-contracts.test.ts new file mode 100644 index 0000000..8a21f17 --- /dev/null +++ b/packages/schema/src/api-contracts.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + CancelPublicationRequestSchema, + CreateApprovalRequestSchema, + CreateContentRequestSchema, + ToggleFeatureFlagRequestSchema, + idempotencyKeyOf, +} from "./api-contracts"; + +const validContent = { + vault_path: "Stargue/Projects/Posts/Welcome.md", + slug: "welcome", + title: "Welcome", + body_sanitized: "Body content here.", + frontmatter: { + status: "ready", + outlets: [{ outlet: "stargue.com", status: "queued", published_url: null }], + category: "blog", + }, + content_hash: "a".repeat(64), +}; + +describe("CreateContentRequestSchema", () => { + it("accepts a well-formed payload", () => { + expect(() => CreateContentRequestSchema.parse(validContent)).not.toThrow(); + }); + + it("rejects slug with uppercase or special chars", () => { + expect(() => + CreateContentRequestSchema.parse({ ...validContent, slug: "Bad Slug!" }), + ).toThrow(); + }); + + it("rejects content_hash that is not 64 hex chars", () => { + expect(() => + CreateContentRequestSchema.parse({ ...validContent, content_hash: "short" }), + ).toThrow(); + }); + + it("rejects body exceeding 200k chars", () => { + expect(() => + CreateContentRequestSchema.parse({ ...validContent, body_sanitized: "x".repeat(200_001) }), + ).toThrow(); + }); +}); + +describe("CreateApprovalRequestSchema", () => { + it("requires positive integer content_id", () => { + expect(() => + CreateApprovalRequestSchema.parse({ content_id: 0, outlet: "stargue.com" }), + ).toThrow(); + expect(() => + CreateApprovalRequestSchema.parse({ content_id: 1, outlet: "stargue.com" }), + ).not.toThrow(); + }); + + it("rejects unknown outlet", () => { + expect(() => + CreateApprovalRequestSchema.parse({ content_id: 1, outlet: "facebook" }), + ).toThrow(); + }); +}); + +describe("CancelPublicationRequestSchema", () => { + it("requires non-empty reason", () => { + expect(() => + CancelPublicationRequestSchema.parse({ publication_id: 1, reason: "" }), + ).toThrow(); + }); +}); + +describe("ToggleFeatureFlagRequestSchema", () => { + it("accepts a complete flag toggle", () => { + expect(() => + ToggleFeatureFlagRequestSchema.parse({ + outlet: "linkedin.member", + enabled: false, + reason: "auth expired", + }), + ).not.toThrow(); + }); +}); + +describe("idempotencyKeyOf", () => { + it("produces stable keys", () => { + const ts = new Date("2026-05-01T08:30:00Z"); + expect(idempotencyKeyOf(42, "linkedin.member", ts)).toBe("42|linkedin.member|2026-05-01T08:30:00.000Z"); + }); + + it("uses 'immediate' for null scheduled_at", () => { + expect(idempotencyKeyOf(42, "stargue.com", null)).toBe("42|stargue.com|immediate"); + }); + + it("differs across (content, outlet, schedule) tuples", () => { + const a = idempotencyKeyOf(1, "stargue.com", null); + const b = idempotencyKeyOf(2, "stargue.com", null); + const c = idempotencyKeyOf(1, "stargue.net", null); + const d = idempotencyKeyOf(1, "stargue.com", new Date("2026-05-01T00:00:00Z")); + expect(new Set([a, b, c, d]).size).toBe(4); + }); +}); diff --git a/packages/schema/src/api-contracts.ts b/packages/schema/src/api-contracts.ts new file mode 100644 index 0000000..73a36d6 --- /dev/null +++ b/packages/schema/src/api-contracts.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { OutletSchema, PublishFrontmatterSchema } from "./frontmatter"; + +export const CreateContentRequestSchema = z.object({ + vault_path: z.string().min(1).max(500), + slug: z.string().min(1).max(200).regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string().min(1).max(300), + body_sanitized: z.string().min(1).max(200_000), + frontmatter: PublishFrontmatterSchema, + content_hash: z.string().regex(/^[0-9a-f]{64}$/), +}); +export type CreateContentRequest = z.infer; + +export const CreateApprovalRequestSchema = z.object({ + content_id: z.number().int().positive(), + outlet: OutletSchema, + notes: z.string().max(2000).optional(), +}); +export type CreateApprovalRequest = z.infer; + +export const CancelPublicationRequestSchema = z.object({ + publication_id: z.number().int().positive(), + reason: z.string().min(1).max(1000), +}); +export type CancelPublicationRequest = z.infer; + +export const ToggleFeatureFlagRequestSchema = z.object({ + outlet: OutletSchema, + enabled: z.boolean(), + reason: z.string().min(1).max(500), +}); +export type ToggleFeatureFlagRequest = z.infer; + +export const idempotencyKeyOf = ( + contentId: number, + outlet: string, + scheduledAt: Date | null, +): string => { + const ts = scheduledAt ? scheduledAt.toISOString() : "immediate"; + return `${contentId}|${outlet}|${ts}`; +}; diff --git a/packages/schema/src/authz.test.ts b/packages/schema/src/authz.test.ts new file mode 100644 index 0000000..fdb476c --- /dev/null +++ b/packages/schema/src/authz.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { AuthzError, isAllowed, requireAllowed, type Principal } from "./authz"; + +const operator: Principal = { sub: "user:op", roles: ["operator"] }; +const approver: Principal = { sub: "user:ap", roles: ["approver"] }; +const viewer: Principal = { sub: "user:vi", roles: ["viewer"] }; +const opAndApprover: Principal = { sub: "user:both", roles: ["operator", "approver"] }; + +describe("authz — Cerbos-equivalent role rules", () => { + it("operator can create content; approver and viewer cannot", () => { + expect(isAllowed(operator, "content.create")).toBe(true); + expect(isAllowed(approver, "content.create")).toBe(false); + expect(isAllowed(viewer, "content.create")).toBe(false); + }); + + it("approver can create approvals; operator cannot self-approve", () => { + expect(isAllowed(approver, "approvals.create")).toBe(true); + expect(isAllowed(operator, "approvals.create")).toBe(false); + expect(isAllowed(viewer, "approvals.create")).toBe(false); + }); + + it("all three roles can read content + approvals + publications", () => { + for (const p of [operator, approver, viewer]) { + expect(isAllowed(p, "content.read")).toBe(true); + expect(isAllowed(p, "approvals.read")).toBe(true); + expect(isAllowed(p, "publications.read")).toBe(true); + } + }); + + it("only operator can cancel a publication or toggle feature flags", () => { + expect(isAllowed(operator, "publications.cancel")).toBe(true); + expect(isAllowed(approver, "publications.cancel")).toBe(false); + expect(isAllowed(operator, "feature_flags.toggle")).toBe(true); + expect(isAllowed(viewer, "feature_flags.toggle")).toBe(false); + }); + + it("multi-role principal gets union of permissions", () => { + expect(isAllowed(opAndApprover, "content.create")).toBe(true); + expect(isAllowed(opAndApprover, "approvals.create")).toBe(true); + }); + + it("requireAllowed throws AuthzError on denial", () => { + expect(() => requireAllowed(viewer, "content.create")).toThrow(AuthzError); + expect(() => requireAllowed(operator, "content.create")).not.toThrow(); + }); +}); diff --git a/packages/schema/src/authz.ts b/packages/schema/src/authz.ts new file mode 100644 index 0000000..125d70c --- /dev/null +++ b/packages/schema/src/authz.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const RoleSchema = z.enum(["operator", "approver", "viewer"]); +export type Role = z.infer; + +export const PrincipalSchema = z.object({ + sub: z.string().min(1), + roles: z.array(RoleSchema).min(1), +}); +export type Principal = z.infer; + +export type Action = + | "content.create" + | "content.read" + | "content.update" + | "approvals.create" + | "approvals.read" + | "publications.read" + | "publications.cancel" + | "feature_flags.toggle"; + +const RULES: Record> = { + "content.create": ["operator"], + "content.read": ["operator", "approver", "viewer"], + "content.update": ["operator"], + "approvals.create": ["approver"], + "approvals.read": ["operator", "approver", "viewer"], + "publications.read": ["operator", "approver", "viewer"], + "publications.cancel": ["operator"], + "feature_flags.toggle": ["operator"], +}; + +export const isAllowed = (principal: Principal, action: Action): boolean => { + const allowedRoles = RULES[action]; + return principal.roles.some((r) => allowedRoles.includes(r)); +}; + +export class AuthzError extends Error { + constructor(public readonly action: Action, public readonly principal: Principal) { + super(`Forbidden: ${principal.sub} cannot ${action} (roles: ${principal.roles.join(",")})`); + this.name = "AuthzError"; + } +} + +export const requireAllowed = (principal: Principal, action: Action): void => { + if (!isAllowed(principal, action)) throw new AuthzError(action, principal); +}; diff --git a/packages/schema/src/csrf.test.ts b/packages/schema/src/csrf.test.ts new file mode 100644 index 0000000..1574edf --- /dev/null +++ b/packages/schema/src/csrf.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { issueCsrf, verifyCsrf } from "./csrf"; + +const SECRET = "test-secret-32-bytes-or-more-please-make-it-long"; + +describe("CSRF double-submit token", () => { + it("issues a token where cookie carries raw + sig and header carries raw", () => { + const t = issueCsrf(SECRET); + expect(t.cookieValue.split(".").length).toBe(2); + expect(t.cookieValue.startsWith(t.headerValue)).toBe(true); + }); + + it("verifies a freshly issued token", () => { + const t = issueCsrf(SECRET); + expect(verifyCsrf(t.cookieValue, t.headerValue, SECRET)).toBe(true); + }); + + it("rejects mismatched header", () => { + const t = issueCsrf(SECRET); + expect(verifyCsrf(t.cookieValue, "different-header", SECRET)).toBe(false); + }); + + it("rejects tampered signature", () => { + const t = issueCsrf(SECRET); + const tampered = t.cookieValue.replace(/.$/, (c) => (c === "0" ? "1" : "0")); + expect(verifyCsrf(tampered, t.headerValue, SECRET)).toBe(false); + }); + + it("rejects different secret (forgery)", () => { + const t = issueCsrf(SECRET); + expect(verifyCsrf(t.cookieValue, t.headerValue, "wrong-secret")).toBe(false); + }); + + it("rejects null/empty inputs", () => { + expect(verifyCsrf(null, "x", SECRET)).toBe(false); + expect(verifyCsrf("x.y", null, SECRET)).toBe(false); + expect(verifyCsrf("x.y", "x", "")).toBe(false); + }); + + it("rejects malformed cookie (no dot)", () => { + expect(verifyCsrf("nodothere", "nodothere", SECRET)).toBe(false); + }); + + it("rejects empty secret on issue", () => { + expect(() => issueCsrf("")).toThrow(); + }); +}); diff --git a/packages/schema/src/csrf.ts b/packages/schema/src/csrf.ts new file mode 100644 index 0000000..da13956 --- /dev/null +++ b/packages/schema/src/csrf.ts @@ -0,0 +1,35 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +const TOKEN_BYTES = 32; +const HMAC_ALGO = "sha256"; + +export interface CsrfTokenPair { + cookieValue: string; + headerValue: string; +} + +export const issueCsrf = (secret: string): CsrfTokenPair => { + if (!secret) throw new Error("CSRF secret must be non-empty"); + const raw = randomBytes(TOKEN_BYTES).toString("hex"); + const sig = createHmac(HMAC_ALGO, secret).update(raw).digest("hex"); + return { + cookieValue: `${raw}.${sig}`, + headerValue: raw, + }; +}; + +export const verifyCsrf = ( + cookieValue: string | null | undefined, + headerValue: string | null | undefined, + secret: string, +): boolean => { + if (!cookieValue || !headerValue || !secret) return false; + const parts = cookieValue.split("."); + if (parts.length !== 2) return false; + const [raw, sig] = parts as [string, string]; + if (raw !== headerValue) return false; + const expected = createHmac(HMAC_ALGO, secret).update(raw).digest(); + const provided = Buffer.from(sig, "hex"); + if (expected.length !== provided.length) return false; + return timingSafeEqual(expected, provided); +}; diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index dabb2ba..c907310 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,2 +1,6 @@ export * from "./frontmatter"; export * from "./db"; +export * from "./rate-limit"; +export * from "./csrf"; +export * from "./authz"; +export * from "./api-contracts"; diff --git a/packages/schema/src/rate-limit.test.ts b/packages/schema/src/rate-limit.test.ts new file mode 100644 index 0000000..39360c5 --- /dev/null +++ b/packages/schema/src/rate-limit.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { MemoryRateLimitStore, rateLimit } from "./rate-limit"; + +describe("rateLimit", () => { + it("allows up to max requests within window", async () => { + const store = new MemoryRateLimitStore(); + const cfg = { windowMs: 60_000, max: 3 }; + const now = 1_700_000_000_000; + const r1 = await rateLimit(store, "k", cfg, now); + const r2 = await rateLimit(store, "k", cfg, now); + const r3 = await rateLimit(store, "k", cfg, now); + expect(r1.allowed).toBe(true); + expect(r2.allowed).toBe(true); + expect(r3.allowed).toBe(true); + expect(r3.remaining).toBe(0); + }); + + it("rejects request #21 of 20 (Stage 2 DoD)", async () => { + const store = new MemoryRateLimitStore(); + const cfg = { windowMs: 60_000, max: 20 }; + const now = 1_700_000_000_000; + let last: Awaited> | null = null; + for (let i = 1; i <= 21; i++) { + last = await rateLimit(store, "user:1", cfg, now); + if (i <= 20) expect(last.allowed).toBe(true); + } + expect(last!.allowed).toBe(false); + }); + + it("resets after the window expires", async () => { + const store = new MemoryRateLimitStore(); + const cfg = { windowMs: 1_000, max: 1 }; + const t0 = 1_700_000_000_000; + const r1 = await rateLimit(store, "k", cfg, t0); + const r2 = await rateLimit(store, "k", cfg, t0 + 500); + const r3 = await rateLimit(store, "k", cfg, t0 + 1_500); + expect(r1.allowed).toBe(true); + expect(r2.allowed).toBe(false); + expect(r3.allowed).toBe(true); + }); + + it("isolates keys", async () => { + const store = new MemoryRateLimitStore(); + const cfg = { windowMs: 60_000, max: 1 }; + const now = 1_700_000_000_000; + const a = await rateLimit(store, "a", cfg, now); + const b = await rateLimit(store, "b", cfg, now); + expect(a.allowed).toBe(true); + expect(b.allowed).toBe(true); + }); +}); diff --git a/packages/schema/src/rate-limit.ts b/packages/schema/src/rate-limit.ts new file mode 100644 index 0000000..b8b7af6 --- /dev/null +++ b/packages/schema/src/rate-limit.ts @@ -0,0 +1,50 @@ +export interface RateLimitConfig { + windowMs: number; + max: number; +} + +export interface RateLimitState { + count: number; + resetAt: number; +} + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + resetAt: number; +} + +export interface RateLimitStore { + read(key: string): Promise | RateLimitState | null; + write(key: string, state: RateLimitState): Promise | void; +} + +export class MemoryRateLimitStore implements RateLimitStore { + private readonly map = new Map(); + read(key: string): RateLimitState | null { + return this.map.get(key) ?? null; + } + write(key: string, state: RateLimitState): void { + this.map.set(key, state); + } +} + +export const rateLimit = async ( + store: RateLimitStore, + key: string, + config: RateLimitConfig, + now: number = Date.now(), +): Promise => { + const state = (await store.read(key)) ?? { count: 0, resetAt: now + config.windowMs }; + if (now >= state.resetAt) { + state.count = 0; + state.resetAt = now + config.windowMs; + } + state.count += 1; + await store.write(key, state); + return { + allowed: state.count <= config.max, + remaining: Math.max(0, config.max - state.count), + resetAt: state.resetAt, + }; +};