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,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;

View 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);
});
});

View 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}`;
};

View 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();
});
});

View 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);
};

View 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();
});
});

View 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);
};

View File

@@ -1,2 +1,6 @@
export * from "./frontmatter";
export * from "./db";
export * from "./rate-limit";
export * from "./csrf";
export * from "./authz";
export * from "./api-contracts";

View 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);
});
});

View 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,
};
};