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:
9
db/migrations/0000_initial_schema.down.sql
Normal file
9
db/migrations/0000_initial_schema.down.sql
Normal 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";
|
||||
105
db/migrations/0000_initial_schema.sql
Normal file
105
db/migrations/0000_initial_schema.sql
Normal 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");
|
||||
@@ -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
|
||||
|
||||
|
||||
629
db/migrations/meta/0000_snapshot.json
Normal file
629
db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
db/migrations/meta/_journal.json
Normal file
13
db/migrations/meta/_journal.json
Normal 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
107
db/migrations/rehearse.ts
Normal 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);
|
||||
});
|
||||
44
infra/docker-compose.yml
Normal file
44
infra/docker-compose.yml
Normal file
@@ -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:
|
||||
14
packages/schema/drizzle.config.ts
Normal file
14
packages/schema/drizzle.config.ts
Normal file
@@ -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;
|
||||
101
packages/schema/src/api-contracts.test.ts
Normal file
101
packages/schema/src/api-contracts.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
41
packages/schema/src/api-contracts.ts
Normal file
41
packages/schema/src/api-contracts.ts
Normal file
@@ -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<typeof CreateContentRequestSchema>;
|
||||
|
||||
export const CreateApprovalRequestSchema = z.object({
|
||||
content_id: z.number().int().positive(),
|
||||
outlet: OutletSchema,
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
export type CreateApprovalRequest = z.infer<typeof CreateApprovalRequestSchema>;
|
||||
|
||||
export const CancelPublicationRequestSchema = z.object({
|
||||
publication_id: z.number().int().positive(),
|
||||
reason: z.string().min(1).max(1000),
|
||||
});
|
||||
export type CancelPublicationRequest = z.infer<typeof CancelPublicationRequestSchema>;
|
||||
|
||||
export const ToggleFeatureFlagRequestSchema = z.object({
|
||||
outlet: OutletSchema,
|
||||
enabled: z.boolean(),
|
||||
reason: z.string().min(1).max(500),
|
||||
});
|
||||
export type ToggleFeatureFlagRequest = z.infer<typeof ToggleFeatureFlagRequestSchema>;
|
||||
|
||||
export const idempotencyKeyOf = (
|
||||
contentId: number,
|
||||
outlet: string,
|
||||
scheduledAt: Date | null,
|
||||
): string => {
|
||||
const ts = scheduledAt ? scheduledAt.toISOString() : "immediate";
|
||||
return `${contentId}|${outlet}|${ts}`;
|
||||
};
|
||||
46
packages/schema/src/authz.test.ts
Normal file
46
packages/schema/src/authz.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
47
packages/schema/src/authz.ts
Normal file
47
packages/schema/src/authz.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const RoleSchema = z.enum(["operator", "approver", "viewer"]);
|
||||
export type Role = z.infer<typeof RoleSchema>;
|
||||
|
||||
export const PrincipalSchema = z.object({
|
||||
sub: z.string().min(1),
|
||||
roles: z.array(RoleSchema).min(1),
|
||||
});
|
||||
export type Principal = z.infer<typeof PrincipalSchema>;
|
||||
|
||||
export type Action =
|
||||
| "content.create"
|
||||
| "content.read"
|
||||
| "content.update"
|
||||
| "approvals.create"
|
||||
| "approvals.read"
|
||||
| "publications.read"
|
||||
| "publications.cancel"
|
||||
| "feature_flags.toggle";
|
||||
|
||||
const RULES: Record<Action, ReadonlyArray<Role>> = {
|
||||
"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);
|
||||
};
|
||||
47
packages/schema/src/csrf.test.ts
Normal file
47
packages/schema/src/csrf.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
35
packages/schema/src/csrf.ts
Normal file
35
packages/schema/src/csrf.ts
Normal file
@@ -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);
|
||||
};
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from "./frontmatter";
|
||||
export * from "./db";
|
||||
export * from "./rate-limit";
|
||||
export * from "./csrf";
|
||||
export * from "./authz";
|
||||
export * from "./api-contracts";
|
||||
|
||||
51
packages/schema/src/rate-limit.test.ts
Normal file
51
packages/schema/src/rate-limit.test.ts
Normal file
@@ -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<ReturnType<typeof rateLimit>> | 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);
|
||||
});
|
||||
});
|
||||
50
packages/schema/src/rate-limit.ts
Normal file
50
packages/schema/src/rate-limit.ts
Normal file
@@ -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> | RateLimitState | null;
|
||||
write(key: string, state: RateLimitState): Promise<void> | void;
|
||||
}
|
||||
|
||||
export class MemoryRateLimitStore implements RateLimitStore {
|
||||
private readonly map = new Map<string, RateLimitState>();
|
||||
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<RateLimitResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user