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) <noreply@anthropic.com>
This commit is contained in:
Angelo B. J. Luidens
2026-04-26 12:54:04 -04:00
parent e529651de1
commit c73b7e4aad
17 changed files with 1344 additions and 1 deletions

View File

@@ -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";

View File

@@ -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");

View File

@@ -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

View File

@@ -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": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1777222308055,
"tag": "0000_initial_schema",
"breakpoints": true
}
]
}

107
db/migrations/rehearse.ts Normal file
View File

@@ -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<string> => {
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<void> => {
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);
});