🦄 Unicorn.Land ▸ docs/superpowers/plans/2026-07-05-oannes-codex-paywall.md
updated 2026-07-05

Oannes Codex Paywall Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship a working paywall for the Oannes Codex — Stripe Checkout/Billing Portal + Cloudflare Pages Functions gate four premium features (unlimited search, full corroboration network, AI Topic Dossiers, CSV/JSON export) behind a signed-cookie session, backed by Cloudflare KV.

Architecture: Cloudflare Pages Functions (oannescode-site/functions/) deployed by the existing wrangler pages deploy step in deploy-oannescode.yml. Cloudflare KV holds subscriber records and the premium dataset. All Stripe calls are hand-written REST wrappers over fetch (no stripe npm SDK — keeps everything mockable, matches the f: typeof fetch = fetch injection pattern already used throughout oannes-engine). Session state is a signed HttpOnly cookie (HMAC-SHA256 over email|exp), no login/password.

Tech Stack: TypeScript (Cloudflare Pages Functions, transpiled by Cloudflare at deploy time — no bundler needed), vitest for tests, Web Crypto API (crypto.subtle) for HMAC, Python 3 for the vault-side export/dossier scripts (matching the existing oannes/Oannes/_scripts/ codebase).

Global Constraints


Files: - Create: .github/workflows/provision-codex-kv.yml - Create: oannescode-site/wrangler.toml

Interfaces: - Produces: two KV namespace IDs (production + preview) wired into wrangler.toml under binding name SUBSCRIBERS; a COOKIE_SIGNING_SECRET Pages secret set on the oannescode project (consumed by Task 3 onward — every Function reads env.SUBSCRIBERS and env.COOKIE_SIGNING_SECRET).

# .github/workflows/provision-codex-kv.yml
name: Provision Codex KV (one-off)

on:
  workflow_dispatch:

jobs:
  provision:
    runs-on: ubuntu-latest
    steps:
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Create KV namespaces
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: |
          echo "--- production namespace ---"
          npx --yes wrangler@4 kv namespace create oannescode_subscribers
          echo "--- preview namespace ---"
          npx --yes wrangler@4 kv namespace create oannescode_subscribers --preview

      - name: Generate + set COOKIE_SIGNING_SECRET
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: |
          SECRET=$(openssl rand -hex 32)
          echo "$SECRET" | npx --yes wrangler@4 pages secret put COOKIE_SIGNING_SECRET --project-name=oannescode
          echo "COOKIE_SIGNING_SECRET set (value not printed)"

Run: gh workflow run provision-codex-kv.yml (after committing the workflow file — GitHub only recognizes workflow_dispatch workflows that already exist on the default branch, so this step’s workflow file must be committed and pushed before it can be triggered).

Then: gh run list --workflow=provision-codex-kv.yml --limit 1 to get the run ID, and gh run view <run-id> --log to read the two namespace IDs out of the “Create KV namespaces” step’s output (wrangler prints a line like id = "abcd1234..." for each — the first block is production, the second (--preview) is preview).

Expected: two distinct 32-char hex IDs printed, and a line confirming COOKIE_SIGNING_SECRET set.

# oannescode-site/wrangler.toml
name = "oannescode"
compatibility_date = "2026-07-05"

[[kv_namespaces]]
binding = "SUBSCRIBERS"
id = "<production-id-from-step-2>"
preview_id = "<preview-id-from-step-2>"
git add .github/workflows/provision-codex-kv.yml oannescode-site/wrangler.toml
git commit -m "chore(oannes): provision Codex KV namespace + cookie signing secret"

Note: this commit’s wrangler.toml only takes effect on the next deploy-oannescode.yml run (the KV binding is read by Cloudflare at Pages Function invocation time, sourced from this file at deploy). Nothing else in this plan depends on that deploy happening yet — all remaining tasks are testable locally.


Task 2: Scaffold a testable TypeScript project for oannescode-site

Files: - Create: oannescode-site/package.json - Create: oannescode-site/tsconfig.json - Create: oannescode-site/vitest.config.ts - Create: oannescode-site/functions/_lib/smoke.ts - Test: oannescode-site/tests/smoke.test.ts

Interfaces: - Produces: npm test runs vitest from oannescode-site/; @cloudflare/workers-types gives Function handlers proper typing for later tasks.

{
  "name": "oannescode-site",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20260701.0",
    "typescript": "^5.6.0",
    "vitest": "^2.1.0"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "lib": ["ES2022"],
    "types": ["@cloudflare/workers-types"],
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "noEmit": true
  },
  "include": ["functions", "tests"]
}
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
  },
});
// oannescode-site/functions/_lib/smoke.ts
export function add(a: number, b: number): number {
  return a + b;
}
// oannescode-site/tests/smoke.test.ts
import { describe, it, expect } from "vitest";
import { add } from "../functions/_lib/smoke.js";

describe("smoke", () => {
  it("harness runs", () => {
    expect(add(2, 3)).toBe(5);
  });
});

Run: cd oannescode-site && npm install && npm test Expected: 1 test passes.

git add oannescode-site/package.json oannescode-site/package-lock.json oannescode-site/tsconfig.json oannescode-site/vitest.config.ts oannescode-site/functions/_lib/smoke.ts oannescode-site/tests/smoke.test.ts
git commit -m "chore(oannescode-site): scaffold TypeScript + vitest for Pages Functions"

Files: - Create: oannescode-site/functions/_lib/cookie.ts - Test: oannescode-site/tests/cookie.test.ts

Interfaces: - Produces: signSession(email: string, expSec: number, secret: string): Promise<string>, verifySession(cookieValue: string, secret: string, nowSec: number): Promise<{ email: string } | null> — consumed by every Function in Tasks 7-13 to read/write the session cookie.

// oannescode-site/tests/cookie.test.ts
import { describe, it, expect } from "vitest";
import { signSession, verifySession } from "../functions/_lib/cookie.js";

const SECRET = "test-secret-do-not-use-in-prod";

describe("signSession / verifySession", () => {
  it("round-trips a valid session", async () => {
    const cookie = await signSession("jane@example.com", 2_000_000_000, SECRET);
    const result = await verifySession(cookie, SECRET, 1_000_000_000);
    expect(result).toEqual({ email: "jane@example.com" });
  });

  it("rejects an expired session", async () => {
    const cookie = await signSession("jane@example.com", 1_000_000_000, SECRET);
    const result = await verifySession(cookie, SECRET, 2_000_000_000);
    expect(result).toBeNull();
  });

  it("rejects a tampered email", async () => {
    const cookie = await signSession("jane@example.com", 2_000_000_000, SECRET);
    const tampered = cookie.replace("jane@example.com", "attacker@example.com");
    expect(await verifySession(tampered, SECRET, 1_000_000_000)).toBeNull();
  });

  it("rejects a signature signed with the wrong secret", async () => {
    const cookie = await signSession("jane@example.com", 2_000_000_000, SECRET);
    expect(await verifySession(cookie, "wrong-secret", 1_000_000_000)).toBeNull();
  });

  it("rejects malformed cookie values", async () => {
    expect(await verifySession("not-a-real-cookie", SECRET, 1_000_000_000)).toBeNull();
    expect(await verifySession("", SECRET, 1_000_000_000)).toBeNull();
  });

  it("rejects an email containing the field separator", async () => {
    await expect(signSession("weird|email@example.com", 2_000_000_000, SECRET)).rejects.toThrow();
  });
});

Run: cd oannescode-site && npm test -- cookie (from repo root: cd oannescode-site && npm test -- cookie) Expected: FAIL — Cannot find module '../functions/_lib/cookie.js'

// oannescode-site/functions/_lib/cookie.ts
const FIELD_SEP = "|";

async function hmacHex(message: string, secret: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
  return [...new Uint8Array(sigBuf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

/** Sign `email|exp` into a cookie value: `email|exp|hexHmac`. */
export async function signSession(email: string, expSec: number, secret: string): Promise<string> {
  if (email.includes(FIELD_SEP)) throw new Error("email must not contain the field separator");
  const payload = `${email}${FIELD_SEP}${expSec}`;
  const sig = await hmacHex(payload, secret);
  return `${payload}${FIELD_SEP}${sig}`;
}

/** Verify a cookie value produced by signSession. Returns null on any failure (expired, tampered, malformed, wrong secret). */
export async function verifySession(
  cookieValue: string,
  secret: string,
  nowSec: number,
): Promise<{ email: string } | null> {
  const parts = cookieValue.split(FIELD_SEP);
  if (parts.length !== 3) return null;
  const [email, expStr, sig] = parts;
  const exp = Number(expStr);
  if (!email || !Number.isFinite(exp)) return null;
  if (exp <= nowSec) return null;

  const expected = await hmacHex(`${email}${FIELD_SEP}${exp}`, secret);
  if (!timingSafeEqual(sig, expected)) return null;
  return { email };
}

Run: cd oannescode-site && npm test -- cookie Expected: 6 tests pass.

git add oannescode-site/functions/_lib/cookie.ts oannescode-site/tests/cookie.test.ts
git commit -m "feat(oannescode-site): HMAC-signed session cookie lib"

Task 4: Stripe REST client

Files: - Create: oannescode-site/functions/_lib/stripeClient.ts - Test: oannescode-site/tests/stripeClient.test.ts

Interfaces: - Produces: createCheckoutSession(secretKey, priceId, successUrl, cancelUrl, f?): Promise<{ id: string; url: string }>, retrieveCheckoutSession(secretKey, sessionId, f?): Promise<{ paymentStatus: string; customerId: string | null; subscriptionId: string | null; email: string | null }>, createBillingPortalSession(secretKey, customerId, returnUrl, f?): Promise<{ url: string }> — consumed by Tasks 7, 8, 11.

// oannescode-site/tests/stripeClient.test.ts
import { describe, it, expect, vi } from "vitest";
import {
  createCheckoutSession,
  retrieveCheckoutSession,
  createBillingPortalSession,
} from "../functions/_lib/stripeClient.js";

function fakeFetch(status: number, body: unknown) {
  return vi.fn(async () => ({
    ok: status >= 200 && status < 300,
    status,
    json: async () => body,
  })) as unknown as typeof fetch;
}

describe("createCheckoutSession", () => {
  it("posts form-encoded params and returns id/url", async () => {
    const f = fakeFetch(200, { id: "cs_test_123", url: "https://checkout.stripe.com/pay/cs_test_123" });
    const result = await createCheckoutSession("sk_test_x", "price_123", "https://a.com/ok", "https://a.com/cancel", f);
    expect(result).toEqual({ id: "cs_test_123", url: "https://checkout.stripe.com/pay/cs_test_123" });
    const [url, opts] = (f as ReturnType<typeof vi.fn>).mock.calls[0];
    expect(url).toBe("https://api.stripe.com/v1/checkout/sessions");
    expect(opts.headers.Authorization).toBe("Bearer sk_test_x");
    expect(opts.body).toContain("mode=subscription");
    expect(opts.body).toContain("line_items%5B0%5D%5Bprice%5D=price_123");
  });

  it("throws on a non-ok response", async () => {
    const f = fakeFetch(400, { error: { message: "bad request" } });
    await expect(createCheckoutSession("sk_test_x", "price_123", "u", "c", f)).rejects.toThrow(/400/);
  });
});

describe("retrieveCheckoutSession", () => {
  it("normalizes a Stripe session response", async () => {
    const f = fakeFetch(200, {
      payment_status: "paid",
      customer: "cus_123",
      subscription: "sub_123",
      customer_details: { email: "jane@example.com" },
    });
    const result = await retrieveCheckoutSession("sk_test_x", "cs_123", f);
    expect(result).toEqual({
      paymentStatus: "paid",
      customerId: "cus_123",
      subscriptionId: "sub_123",
      email: "jane@example.com",
    });
  });

  it("handles missing customer_details gracefully", async () => {
    const f = fakeFetch(200, { payment_status: "unpaid", customer: null, subscription: null });
    const result = await retrieveCheckoutSession("sk_test_x", "cs_123", f);
    expect(result.email).toBeNull();
  });
});

describe("createBillingPortalSession", () => {
  it("returns the portal url", async () => {
    const f = fakeFetch(200, { url: "https://billing.stripe.com/session/abc" });
    const result = await createBillingPortalSession("sk_test_x", "cus_123", "https://a.com/codex", f);
    expect(result).toEqual({ url: "https://billing.stripe.com/session/abc" });
  });
});

Run: cd oannescode-site && npm test -- stripeClient Expected: FAIL — module not found.

// oannescode-site/functions/_lib/stripeClient.ts
const STRIPE_API = "https://api.stripe.com/v1";

async function stripeRequest(
  path: string,
  secretKey: string,
  params: Record<string, string>,
  f: typeof fetch,
): Promise<Record<string, unknown>> {
  const res = await f(`${STRIPE_API}${path}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${secretKey}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams(params).toString(),
  });
  const body = (await res.json()) as Record<string, unknown>;
  if (!res.ok) {
    const message = (body.error as { message?: string } | undefined)?.message ?? "unknown error";
    throw new Error(`Stripe request failed: HTTP ${res.status} ${message}`);
  }
  return body;
}

export async function createCheckoutSession(
  secretKey: string,
  priceId: string,
  successUrl: string,
  cancelUrl: string,
  f: typeof fetch = fetch,
): Promise<{ id: string; url: string }> {
  const body = await stripeRequest(
    "/checkout/sessions",
    secretKey,
    {
      mode: "subscription",
      "line_items[0][price]": priceId,
      "line_items[0][quantity]": "1",
      success_url: successUrl,
      cancel_url: cancelUrl,
    },
    f,
  );
  return { id: String(body.id), url: String(body.url) };
}

export async function retrieveCheckoutSession(
  secretKey: string,
  sessionId: string,
  f: typeof fetch = fetch,
): Promise<{ paymentStatus: string; customerId: string | null; subscriptionId: string | null; email: string | null }> {
  const res = await f(`${STRIPE_API}/checkout/sessions/${sessionId}?expand[]=customer_details`, {
    headers: { Authorization: `Bearer ${secretKey}` },
  });
  const body = (await res.json()) as Record<string, unknown>;
  if (!res.ok) {
    const message = (body.error as { message?: string } | undefined)?.message ?? "unknown error";
    throw new Error(`Stripe request failed: HTTP ${res.status} ${message}`);
  }
  const customerDetails = body.customer_details as { email?: string } | null | undefined;
  return {
    paymentStatus: String(body.payment_status),
    customerId: (body.customer as string | null) ?? null,
    subscriptionId: (body.subscription as string | null) ?? null,
    email: customerDetails?.email ?? null,
  };
}

export async function createBillingPortalSession(
  secretKey: string,
  customerId: string,
  returnUrl: string,
  f: typeof fetch = fetch,
): Promise<{ url: string }> {
  const body = await stripeRequest(
    "/billing_portal/sessions",
    secretKey,
    { customer: customerId, return_url: returnUrl },
    f,
  );
  return { url: String(body.url) };
}

Run: cd oannescode-site && npm test -- stripeClient Expected: 5 tests pass.

git add oannescode-site/functions/_lib/stripeClient.ts oannescode-site/tests/stripeClient.test.ts
git commit -m "feat(oannescode-site): Stripe REST client (checkout, billing portal)"

Task 5: Stripe webhook signature verification

Files: - Create: oannescode-site/functions/_lib/webhookVerify.ts - Test: oannescode-site/tests/webhookVerify.test.ts

Interfaces: - Produces: verifyStripeSignature(rawBody: string, sigHeader: string, secret: string, nowSec: number, toleranceSec?: number): Promise<boolean> — consumed by Task 9.

// oannescode-site/tests/webhookVerify.test.ts
import { describe, it, expect } from "vitest";
import { verifyStripeSignature } from "../functions/_lib/webhookVerify.js";

const SECRET = "whsec_test_secret";

async function makeSigHeader(payload: string, timestamp: number, secret: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${payload}`));
  const hex = [...new Uint8Array(sigBuf)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return `t=${timestamp},v1=${hex}`;
}

describe("verifyStripeSignature", () => {
  it("accepts a validly signed, fresh payload", async () => {
    const payload = '{"type":"checkout.session.completed"}';
    const now = 1_800_000_000;
    const header = await makeSigHeader(payload, now, SECRET);
    expect(await verifyStripeSignature(payload, header, SECRET, now)).toBe(true);
  });

  it("rejects a tampered payload", async () => {
    const payload = '{"type":"checkout.session.completed"}';
    const now = 1_800_000_000;
    const header = await makeSigHeader(payload, now, SECRET);
    expect(await verifyStripeSignature(payload + "tampered", header, SECRET, now)).toBe(false);
  });

  it("rejects the wrong secret", async () => {
    const payload = '{"type":"x"}';
    const now = 1_800_000_000;
    const header = await makeSigHeader(payload, now, SECRET);
    expect(await verifyStripeSignature(payload, header, "whsec_wrong", now)).toBe(false);
  });

  it("rejects a timestamp outside the tolerance window (replay protection)", async () => {
    const payload = '{"type":"x"}';
    const signedAt = 1_800_000_000;
    const header = await makeSigHeader(payload, signedAt, SECRET);
    const farLater = signedAt + 10 * 60; // 10 minutes later, default tolerance is 5 minutes
    expect(await verifyStripeSignature(payload, header, SECRET, farLater)).toBe(false);
  });

  it("rejects a malformed signature header", async () => {
    expect(await verifyStripeSignature("{}", "not-a-real-header", SECRET, 1_800_000_000)).toBe(false);
    expect(await verifyStripeSignature("{}", "", SECRET, 1_800_000_000)).toBe(false);
  });
});

Run: cd oannescode-site && npm test -- webhookVerify Expected: FAIL — module not found.

// oannescode-site/functions/_lib/webhookVerify.ts
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

/** Verify a Stripe `Stripe-Signature` header per Stripe's documented scheme:
 * header = "t=<unix-ts>,v1=<hex-hmac-sha256 of `${ts}.${rawBody}`>". Rejects
 * payloads whose timestamp is more than `toleranceSec` away from `nowSec`
 * (replay protection). */
export async function verifyStripeSignature(
  rawBody: string,
  sigHeader: string,
  secret: string,
  nowSec: number,
  toleranceSec = 5 * 60,
): Promise<boolean> {
  const parts = Object.fromEntries(
    sigHeader
      .split(",")
      .map((p) => p.split("="))
      .filter((p): p is [string, string] => p.length === 2),
  );
  const timestamp = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(timestamp) || !v1) return false;
  if (Math.abs(nowSec - timestamp) > toleranceSec) return false;

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${rawBody}`));
  const expected = [...new Uint8Array(sigBuf)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return timingSafeEqual(v1, expected);
}

Run: cd oannescode-site && npm test -- webhookVerify Expected: 5 tests pass.

git add oannescode-site/functions/_lib/webhookVerify.ts oannescode-site/tests/webhookVerify.test.ts
git commit -m "feat(oannescode-site): Stripe webhook signature verification"

Task 6: KV subscriber-record helpers

Files: - Create: oannescode-site/functions/_lib/kv.ts - Test: oannescode-site/tests/kv.test.ts

Interfaces: - Produces: SubscriberRecord type { email: string; stripeCustomerId: string; subscriptionId: string; status: string; currentPeriodEnd: number }; getSubscriber(kv, email): Promise<SubscriberRecord | null>; putSubscriber(kv, record): Promise<void>; isActive(record: SubscriberRecord | null): boolean — consumed by Tasks 7-13. A minimal KVLike interface (get/put) is exported so tests can pass an in-memory fake instead of a real Cloudflare KVNamespace.

// oannescode-site/tests/kv.test.ts
import { describe, it, expect } from "vitest";
import { getSubscriber, putSubscriber, isActive, type KVLike, type SubscriberRecord } from "../functions/_lib/kv.js";

function fakeKV(): KVLike {
  const store = new Map<string, string>();
  return {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => {
      store.set(key, value);
    },
  };
}

describe("getSubscriber / putSubscriber", () => {
  it("returns null for an unknown email", async () => {
    const kv = fakeKV();
    expect(await getSubscriber(kv, "nobody@example.com")).toBeNull();
  });

  it("round-trips a subscriber record", async () => {
    const kv = fakeKV();
    const record: SubscriberRecord = {
      email: "jane@example.com",
      stripeCustomerId: "cus_123",
      subscriptionId: "sub_123",
      status: "active",
      currentPeriodEnd: 1_900_000_000,
    };
    await putSubscriber(kv, record);
    expect(await getSubscriber(kv, "jane@example.com")).toEqual(record);
  });

  it("keys are case-insensitive on email", async () => {
    const kv = fakeKV();
    const record: SubscriberRecord = {
      email: "Jane@Example.com",
      stripeCustomerId: "cus_123",
      subscriptionId: "sub_123",
      status: "active",
      currentPeriodEnd: 1_900_000_000,
    };
    await putSubscriber(kv, record);
    expect(await getSubscriber(kv, "jane@example.com")).not.toBeNull();
  });
});

describe("isActive", () => {
  it("is false for null", () => {
    expect(isActive(null)).toBe(false);
  });

  it("is true only for status active", () => {
    const base = { email: "a", stripeCustomerId: "c", subscriptionId: "s", currentPeriodEnd: 0 };
    expect(isActive({ ...base, status: "active" })).toBe(true);
    expect(isActive({ ...base, status: "canceled" })).toBe(false);
    expect(isActive({ ...base, status: "past_due" })).toBe(false);
  });
});

Run: cd oannescode-site && npm test -- kv Expected: FAIL — module not found.

// oannescode-site/functions/_lib/kv.ts
export interface KVLike {
  get(key: string): Promise<string | null>;
  put(key: string, value: string): Promise<void>;
}

export interface SubscriberRecord {
  email: string;
  stripeCustomerId: string;
  subscriptionId: string;
  status: string;
  currentPeriodEnd: number;
}

function subKey(email: string): string {
  return `sub:${email.toLowerCase()}`;
}

export async function getSubscriber(kv: KVLike, email: string): Promise<SubscriberRecord | null> {
  const raw = await kv.get(subKey(email));
  if (!raw) return null;
  return JSON.parse(raw) as SubscriberRecord;
}

export async function putSubscriber(kv: KVLike, record: SubscriberRecord): Promise<void> {
  await kv.put(subKey(record.email), JSON.stringify(record));
}

export function isActive(record: SubscriberRecord | null): boolean {
  return record?.status === "active";
}

Run: cd oannescode-site && npm test -- kv Expected: 5 tests pass.

git add oannescode-site/functions/_lib/kv.ts oannescode-site/tests/kv.test.ts
git commit -m "feat(oannescode-site): KV subscriber-record helpers"

Task 7: POST /api/checkout — start a subscription

Files: - Create: oannescode-site/functions/api/checkout.ts - Test: oannescode-site/tests/checkout.test.ts

Interfaces: - Consumes: createCheckoutSession (Task 4). - Produces: handleCheckout(env: { STRIPE_SECRET_KEY: string; STRIPE_PRICE_ID: string }, origin: string, f?: typeof fetch): Promise<{ status: number; body: unknown }> — the exported onRequestPost Pages Functions handler wraps this pure function; later tasks/tests call handleCheckout directly rather than constructing a full EventContext.

// oannescode-site/tests/checkout.test.ts
import { describe, it, expect, vi } from "vitest";
import { handleCheckout } from "../functions/api/checkout.js";

function fakeFetch(body: unknown) {
  return vi.fn(async () => ({ ok: true, status: 200, json: async () => body })) as unknown as typeof fetch;
}

describe("handleCheckout", () => {
  it("creates a checkout session pointed at the given origin and returns its url", async () => {
    const f = fakeFetch({ id: "cs_123", url: "https://checkout.stripe.com/pay/cs_123" });
    const result = await handleCheckout(
      { STRIPE_SECRET_KEY: "sk_test_x", STRIPE_PRICE_ID: "price_123" },
      "https://oannescode.com",
      f,
    );
    expect(result.status).toBe(200);
    expect(result.body).toEqual({ url: "https://checkout.stripe.com/pay/cs_123" });
    const [, opts] = (f as ReturnType<typeof vi.fn>).mock.calls[0];
    expect(opts.body).toContain(encodeURIComponent("https://oannescode.com/codex/"));
  });

  it("returns 500 with no leaked secret when Stripe errors", async () => {
    const f = vi.fn(async () => ({
      ok: false,
      status: 400,
      json: async () => ({ error: { message: "bad price id" } }),
    })) as unknown as typeof fetch;
    const result = await handleCheckout(
      { STRIPE_SECRET_KEY: "sk_test_x", STRIPE_PRICE_ID: "bad_price" },
      "https://oannescode.com",
      f,
    );
    expect(result.status).toBe(500);
    expect(JSON.stringify(result.body)).not.toContain("sk_test_x");
  });
});

Run: cd oannescode-site && npm test -- checkout Expected: FAIL — module not found.

// oannescode-site/functions/api/checkout.ts
import { createCheckoutSession } from "../_lib/stripeClient.js";

export interface CheckoutEnv {
  STRIPE_SECRET_KEY: string;
  STRIPE_PRICE_ID: string;
}

export async function handleCheckout(
  env: CheckoutEnv,
  origin: string,
  f: typeof fetch = fetch,
): Promise<{ status: number; body: unknown }> {
  try {
    const session = await createCheckoutSession(
      env.STRIPE_SECRET_KEY,
      env.STRIPE_PRICE_ID,
      `${origin}/codex/?session_id={CHECKOUT_SESSION_ID}`,
      `${origin}/codex/`,
      f,
    );
    return { status: 200, body: { url: session.url } };
  } catch {
    return { status: 500, body: { error: "checkout_failed" } };
  }
}

export const onRequestPost: PagesFunction<CheckoutEnv> = async (context) => {
  const origin = new URL(context.request.url).origin;
  const { status, body } = await handleCheckout(context.env, origin);
  return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
};

Run: cd oannescode-site && npm test -- checkout Expected: 2 tests pass.

git add oannescode-site/functions/api/checkout.ts oannescode-site/tests/checkout.test.ts
git commit -m "feat(oannescode-site): POST /api/checkout"

Files: - Create: oannescode-site/functions/api/checkout/verify.ts - Test: oannescode-site/tests/checkout-verify.test.ts

Interfaces: - Consumes: retrieveCheckoutSession (Task 4), putSubscriber/SubscriberRecord (Task 6), signSession (Task 3). - Produces: handleCheckoutVerify(env, kv, sessionId, nowSec, f?): Promise<{ status: number; cookie: string | null; body: unknown }>.

// oannescode-site/tests/checkout-verify.test.ts
import { describe, it, expect, vi } from "vitest";
import { handleCheckoutVerify } from "../functions/api/checkout/verify.js";
import { getSubscriber, type KVLike } from "../functions/_lib/kv.js";

function fakeKV(): KVLike {
  const store = new Map<string, string>();
  return {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => {
      store.set(key, value);
    },
  };
}

function fakeFetch(body: unknown) {
  return vi.fn(async () => ({ ok: true, status: 200, json: async () => body })) as unknown as typeof fetch;
}

const ENV = { STRIPE_SECRET_KEY: "sk_test_x", COOKIE_SIGNING_SECRET: "cookie-secret" };

describe("handleCheckoutVerify", () => {
  it("on a paid session: stores the subscriber and returns a signed cookie", async () => {
    const kv = fakeKV();
    const f = fakeFetch({
      payment_status: "paid",
      customer: "cus_1",
      subscription: "sub_1",
      customer_details: { email: "jane@example.com" },
    });
    const now = 1_800_000_000;
    const result = await handleCheckoutVerify(ENV, kv, "cs_123", now, f);
    expect(result.status).toBe(200);
    expect(result.cookie).toBeTruthy();
    const stored = await getSubscriber(kv, "jane@example.com");
    expect(stored).toMatchObject({ email: "jane@example.com", stripeCustomerId: "cus_1", status: "active" });
  });

  it("on an unpaid session: does not store anything or set a cookie", async () => {
    const kv = fakeKV();
    const f = fakeFetch({ payment_status: "unpaid", customer: "cus_1", subscription: null, customer_details: null });
    const result = await handleCheckoutVerify(ENV, kv, "cs_123", 1_800_000_000, f);
    expect(result.status).toBe(402);
    expect(result.cookie).toBeNull();
    expect(await getSubscriber(kv, "jane@example.com")).toBeNull();
  });

  it("fails closed when Stripe errors", async () => {
    const kv = fakeKV();
    const f = vi.fn(async () => ({ ok: false, status: 404, json: async () => ({ error: { message: "not found" } }) })) as unknown as typeof fetch;
    const result = await handleCheckoutVerify(ENV, kv, "cs_bad", 1_800_000_000, f);
    expect(result.status).toBe(500);
    expect(result.cookie).toBeNull();
  });
});

Run: cd oannescode-site && npm test -- checkout-verify Expected: FAIL — module not found.

// oannescode-site/functions/api/checkout/verify.ts
import { retrieveCheckoutSession } from "../../_lib/stripeClient.js";
import { putSubscriber, type KVLike } from "../../_lib/kv.js";
import { signSession } from "../../_lib/cookie.js";

export interface CheckoutVerifyEnv {
  STRIPE_SECRET_KEY: string;
  COOKIE_SIGNING_SECRET: string;
}

const SESSION_TTL_SEC = 30 * 24 * 60 * 60; // 30 days; the webhook keeps status fresh regardless

export async function handleCheckoutVerify(
  env: CheckoutVerifyEnv,
  kv: KVLike,
  sessionId: string,
  nowSec: number,
  f: typeof fetch = fetch,
): Promise<{ status: number; cookie: string | null; body: unknown }> {
  let session;
  try {
    session = await retrieveCheckoutSession(env.STRIPE_SECRET_KEY, sessionId, f);
  } catch {
    return { status: 500, cookie: null, body: { error: "verify_failed" } };
  }

  if (session.paymentStatus !== "paid" || !session.email || !session.customerId || !session.subscriptionId) {
    return { status: 402, cookie: null, body: { error: "not_paid" } };
  }

  await putSubscriber(kv, {
    email: session.email,
    stripeCustomerId: session.customerId,
    subscriptionId: session.subscriptionId,
    status: "active",
    currentPeriodEnd: nowSec + SESSION_TTL_SEC,
  });

  const cookie = await signSession(session.email, nowSec + SESSION_TTL_SEC, env.COOKIE_SIGNING_SECRET);
  return { status: 200, cookie, body: { ok: true } };
}

export const onRequestGet: PagesFunction<CheckoutVerifyEnv & { SUBSCRIBERS: KVNamespace }> = async (context) => {
  const sessionId = new URL(context.request.url).searchParams.get("session_id");
  if (!sessionId) return new Response(JSON.stringify({ error: "missing session_id" }), { status: 400 });

  const nowSec = Math.floor(Date.now() / 1000);
  const { status, cookie, body } = await handleCheckoutVerify(context.env, context.env.SUBSCRIBERS, sessionId, nowSec);
  const headers = new Headers({ "Content-Type": "application/json" });
  if (cookie) {
    headers.append(
      "Set-Cookie",
      `oannes_session=${cookie}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${30 * 24 * 60 * 60}`,
    );
  }
  return new Response(JSON.stringify(body), { status, headers });
};

Run: cd oannescode-site && npm test -- checkout-verify Expected: 3 tests pass.

git add "oannescode-site/functions/api/checkout/verify.ts" oannescode-site/tests/checkout-verify.test.ts
git commit -m "feat(oannescode-site): GET /api/checkout/verify"

Task 9: POST /api/stripe-webhook — keep subscription status in sync

Files: - Create: oannescode-site/functions/api/stripe-webhook.ts - Test: oannescode-site/tests/stripe-webhook.test.ts

Interfaces: - Consumes: verifyStripeSignature (Task 5), getSubscriber/putSubscriber (Task 6). - Produces: handleStripeWebhook(env, kv, rawBody, sigHeader, nowSec): Promise<{ status: number; body: unknown }>.

// oannescode-site/tests/stripe-webhook.test.ts
import { describe, it, expect } from "vitest";
import { handleStripeWebhook } from "../functions/api/stripe-webhook.js";
import { getSubscriber, putSubscriber, type KVLike } from "../functions/_lib/kv.js";

const SECRET = "whsec_test";
const NOW = 1_800_000_000;

function fakeKV(): KVLike {
  const store = new Map<string, string>();
  return {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => {
      store.set(key, value);
    },
  };
}

async function sign(payload: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${NOW}.${payload}`));
  const hex = [...new Uint8Array(sigBuf)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return `t=${NOW},v1=${hex}`;
}

const ENV = { STRIPE_WEBHOOK_SECRET: SECRET };

describe("handleStripeWebhook", () => {
  it("rejects a bad signature", async () => {
    const kv = fakeKV();
    const payload = JSON.stringify({ type: "customer.subscription.updated", data: { object: {} } });
    const result = await handleStripeWebhook(ENV, kv, payload, "t=1,v1=deadbeef", NOW);
    expect(result.status).toBe(400);
  });

  it("customer.subscription.updated syncs status + period end for an existing subscriber", async () => {
    const kv = fakeKV();
    await putSubscriber(kv, {
      email: "jane@example.com",
      stripeCustomerId: "cus_1",
      subscriptionId: "sub_1",
      status: "active",
      currentPeriodEnd: 0,
    });
    const payload = JSON.stringify({
      type: "customer.subscription.updated",
      data: { object: { id: "sub_1", customer: "cus_1", status: "past_due", current_period_end: 1_900_000_000 } },
    });
    const sig = await sign(payload);
    const result = await handleStripeWebhook(ENV, kv, payload, sig, NOW);
    expect(result.status).toBe(200);
    const updated = await getSubscriber(kv, "jane@example.com");
    expect(updated).toMatchObject({ status: "past_due", currentPeriodEnd: 1_900_000_000 });
  });

  it("customer.subscription.deleted revokes access", async () => {
    const kv = fakeKV();
    await putSubscriber(kv, {
      email: "jane@example.com",
      stripeCustomerId: "cus_1",
      subscriptionId: "sub_1",
      status: "active",
      currentPeriodEnd: 1_900_000_000,
    });
    const payload = JSON.stringify({
      type: "customer.subscription.deleted",
      data: { object: { id: "sub_1", customer: "cus_1", status: "canceled", current_period_end: 1_850_000_000 } },
    });
    const sig = await sign(payload);
    const result = await handleStripeWebhook(ENV, kv, payload, sig, NOW);
    expect(result.status).toBe(200);
    const updated = await getSubscriber(kv, "jane@example.com");
    expect(updated?.status).toBe("canceled");
  });

  it("ignores an event for a customer id we don't have a record for yet (no email to key by)", async () => {
    const kv = fakeKV();
    const payload = JSON.stringify({
      type: "customer.subscription.updated",
      data: { object: { id: "sub_unknown", customer: "cus_unknown", status: "active", current_period_end: 1_900_000_000 } },
    });
    const sig = await sign(payload);
    const result = await handleStripeWebhook(ENV, kv, payload, sig, NOW);
    // Nothing to key the KV record by (we index by email, and this event has no email) — must not throw.
    expect(result.status).toBe(200);
  });

  it("returns 200 for an event type it doesn't need to act on (still ack so Stripe stops retrying)", async () => {
    const kv = fakeKV();
    const payload = JSON.stringify({ type: "invoice.payment_failed", data: { object: {} } });
    const sig = await sign(payload);
    const result = await handleStripeWebhook(ENV, kv, payload, sig, NOW);
    expect(result.status).toBe(200);
  });
});

Note: the “ignores an event for a customer id we don’t have a record for yet” case reveals a real design point — subscription-lifecycle events carry customer/subscription ids, not email, and our KV is keyed by email (from Task 6). The implementation below resolves this by also writing a secondary cus:<customerId> -> email index at checkout-verify time (Task 8 needs a small addition) so the webhook can look up the email. Step 3 includes that addition.

Run: cd oannescode-site && npm test -- stripe-webhook Expected: FAIL — module not found.

First, extend oannescode-site/functions/_lib/kv.ts (append to the file):

// --- append to oannescode-site/functions/_lib/kv.ts ---

function customerIndexKey(stripeCustomerId: string): string {
  return `cus:${stripeCustomerId}`;
}

/** Secondary index so webhook events (keyed by Stripe customer id, not email) can find the subscriber record. */
export async function putCustomerIndex(kv: KVLike, stripeCustomerId: string, email: string): Promise<void> {
  await kv.put(customerIndexKey(stripeCustomerId), email.toLowerCase());
}

export async function getEmailForCustomer(kv: KVLike, stripeCustomerId: string): Promise<string | null> {
  return kv.get(customerIndexKey(stripeCustomerId));
}

Update putSubscriber in the same file to also maintain the index:

export async function putSubscriber(kv: KVLike, record: SubscriberRecord): Promise<void> {
  await kv.put(subKey(record.email), JSON.stringify(record));
  await putCustomerIndex(kv, record.stripeCustomerId, record.email);
}

Now the webhook handler:

// oannescode-site/functions/api/stripe-webhook.ts
import { verifyStripeSignature } from "../_lib/webhookVerify.js";
import { getSubscriber, putSubscriber, getEmailForCustomer, type KVLike } from "../_lib/kv.js";

export interface StripeWebhookEnv {
  STRIPE_WEBHOOK_SECRET: string;
}

interface SubscriptionEventObject {
  id: string;
  customer: string;
  status: string;
  current_period_end: number;
}

export async function handleStripeWebhook(
  env: StripeWebhookEnv,
  kv: KVLike,
  rawBody: string,
  sigHeader: string,
  nowSec: number,
): Promise<{ status: number; body: unknown }> {
  const valid = await verifyStripeSignature(rawBody, sigHeader, env.STRIPE_WEBHOOK_SECRET, nowSec);
  if (!valid) return { status: 400, body: { error: "invalid_signature" } };

  const event = JSON.parse(rawBody) as { type: string; data: { object: unknown } };

  if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
    const obj = event.data.object as SubscriptionEventObject;
    const email = await getEmailForCustomer(kv, obj.customer);
    if (!email) return { status: 200, body: { ok: true, note: "no subscriber record for this customer yet" } };

    const existing = await getSubscriber(kv, email);
    await putSubscriber(kv, {
      email,
      stripeCustomerId: obj.customer,
      subscriptionId: obj.id,
      status: obj.status,
      currentPeriodEnd: obj.current_period_end,
      ...(existing ? {} : {}),
    });
    return { status: 200, body: { ok: true } };
  }

  // Other event types (e.g. invoice.payment_failed) are acknowledged but need no action here —
  // Stripe follows payment failures with a customer.subscription.updated(status=past_due) event.
  return { status: 200, body: { ok: true, ignored: event.type } };
}

export const onRequestPost: PagesFunction<StripeWebhookEnv & { SUBSCRIBERS: KVNamespace }> = async (context) => {
  const rawBody = await context.request.text();
  const sigHeader = context.request.headers.get("Stripe-Signature") ?? "";
  const nowSec = Math.floor(Date.now() / 1000);
  const { status, body } = await handleStripeWebhook(context.env, context.env.SUBSCRIBERS, rawBody, sigHeader, nowSec);
  return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
};

Run: cd oannescode-site && npm test -- stripe-webhook kv Expected: all pass (5 webhook tests + updated kv tests still green).

git add oannescode-site/functions/_lib/kv.ts oannescode-site/functions/api/stripe-webhook.ts oannescode-site/tests/stripe-webhook.test.ts
git commit -m "feat(oannescode-site): POST /api/stripe-webhook + customer->email KV index"

Task 10: POST /api/billing-portal — self-serve cancel/manage

Files: - Create: oannescode-site/functions/api/billing-portal.ts - Test: oannescode-site/tests/billing-portal.test.ts

Interfaces: - Consumes: verifySession (Task 3), getSubscriber (Task 6), createBillingPortalSession (Task 4). - Produces: handleBillingPortal(env, kv, cookieHeader, origin, nowSec, f?): Promise<{ status: number; body: unknown }>.

// oannescode-site/tests/billing-portal.test.ts
import { describe, it, expect, vi } from "vitest";
import { handleBillingPortal } from "../functions/api/billing-portal.js";
import { putSubscriber, type KVLike } from "../functions/_lib/kv.js";
import { signSession } from "../functions/_lib/cookie.js";

function fakeKV(): KVLike {
  const store = new Map<string, string>();
  return {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => {
      store.set(key, value);
    },
  };
}

const ENV = { STRIPE_SECRET_KEY: "sk_test_x", COOKIE_SIGNING_SECRET: "cookie-secret" };
const NOW = 1_800_000_000;

describe("handleBillingPortal", () => {
  it("returns a portal url for a valid subscriber cookie", async () => {
    const kv = fakeKV();
    await putSubscriber(kv, {
      email: "jane@example.com",
      stripeCustomerId: "cus_1",
      subscriptionId: "sub_1",
      status: "active",
      currentPeriodEnd: NOW + 1000,
    });
    const cookie = await signSession("jane@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const f = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ url: "https://billing.stripe.com/x" }) })) as unknown as typeof fetch;

    const result = await handleBillingPortal(ENV, kv, `oannes_session=${cookie}`, "https://oannescode.com", NOW, f);
    expect(result.status).toBe(200);
    expect(result.body).toEqual({ url: "https://billing.stripe.com/x" });
  });

  it("returns 401 with no cookie", async () => {
    const kv = fakeKV();
    const result = await handleBillingPortal(ENV, kv, "", "https://oannescode.com", NOW);
    expect(result.status).toBe(401);
  });

  it("returns 401 for a cookie whose subscriber record no longer exists", async () => {
    const kv = fakeKV();
    const cookie = await signSession("ghost@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const result = await handleBillingPortal(ENV, kv, `oannes_session=${cookie}`, "https://oannescode.com", NOW);
    expect(result.status).toBe(401);
  });
});

Run: cd oannescode-site && npm test -- billing-portal Expected: FAIL — module not found.

// oannescode-site/functions/api/billing-portal.ts
import { verifySession } from "../_lib/cookie.js";
import { getSubscriber, type KVLike } from "../_lib/kv.js";
import { createBillingPortalSession } from "../_lib/stripeClient.js";

export interface BillingPortalEnv {
  STRIPE_SECRET_KEY: string;
  COOKIE_SIGNING_SECRET: string;
}

function parseCookie(cookieHeader: string, name: string): string | null {
  const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${name}=`));
  return match ? match.slice(name.length + 1) : null;
}

export async function handleBillingPortal(
  env: BillingPortalEnv,
  kv: KVLike,
  cookieHeader: string,
  origin: string,
  nowSec: number,
  f: typeof fetch = fetch,
): Promise<{ status: number; body: unknown }> {
  const sessionCookie = parseCookie(cookieHeader, "oannes_session");
  if (!sessionCookie) return { status: 401, body: { error: "not_authenticated" } };

  const session = await verifySession(sessionCookie, env.COOKIE_SIGNING_SECRET, nowSec);
  if (!session) return { status: 401, body: { error: "not_authenticated" } };

  const subscriber = await getSubscriber(kv, session.email);
  if (!subscriber) return { status: 401, body: { error: "not_authenticated" } };

  try {
    const portal = await createBillingPortalSession(env.STRIPE_SECRET_KEY, subscriber.stripeCustomerId, `${origin}/codex/`, f);
    return { status: 200, body: { url: portal.url } };
  } catch {
    return { status: 500, body: { error: "portal_failed" } };
  }
}

export const onRequestPost: PagesFunction<BillingPortalEnv & { SUBSCRIBERS: KVNamespace }> = async (context) => {
  const origin = new URL(context.request.url).origin;
  const nowSec = Math.floor(Date.now() / 1000);
  const { status, body } = await handleBillingPortal(
    context.env,
    context.env.SUBSCRIBERS,
    context.request.headers.get("Cookie") ?? "",
    origin,
    nowSec,
  );
  return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
};

Run: cd oannescode-site && npm test -- billing-portal Expected: 3 tests pass.

git add oannescode-site/functions/api/billing-portal.ts oannescode-site/tests/billing-portal.test.ts
git commit -m "feat(oannescode-site): POST /api/billing-portal"

Task 11: GET /api/premium — the gated data endpoint

Files: - Create: oannescode-site/functions/api/premium.ts - Test: oannescode-site/tests/premium.test.ts

Interfaces: - Consumes: verifySession (Task 3), getSubscriber/isActive (Task 6). - Produces: handlePremium(env, kv, cookieHeader, nowSec): Promise<{ status: number; body: unknown }>. The premium JSON payload itself is read from kv.get("premium:full") (written by Task 14’s export_codex.py --premium), so this handler stays a thin gate — it does not know or care about the claims data shape.

// oannescode-site/tests/premium.test.ts
import { describe, it, expect } from "vitest";
import { handlePremium } from "../functions/api/premium.js";
import { putSubscriber, type KVLike } from "../functions/_lib/kv.js";
import { signSession } from "../functions/_lib/cookie.js";

function fakeKV(initial: Record<string, string> = {}): KVLike {
  const store = new Map(Object.entries(initial));
  return {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => {
      store.set(key, value);
    },
  };
}

const ENV = { COOKIE_SIGNING_SECRET: "cookie-secret" };
const NOW = 1_800_000_000;

describe("handlePremium", () => {
  it("returns the premium payload for an active subscriber", async () => {
    const kv = fakeKV({ "premium:full": JSON.stringify({ claims: [{ id: "clm-1" }] }) });
    await putSubscriber(kv, {
      email: "jane@example.com", stripeCustomerId: "cus_1", subscriptionId: "sub_1",
      status: "active", currentPeriodEnd: NOW + 1000,
    });
    const cookie = await signSession("jane@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const result = await handlePremium(ENV, kv, `oannes_session=${cookie}`, NOW);
    expect(result.status).toBe(200);
    expect(result.body).toEqual({ claims: [{ id: "clm-1" }] });
  });

  it("returns 401 for no cookie", async () => {
    const kv = fakeKV();
    const result = await handlePremium(ENV, kv, "", NOW);
    expect(result.status).toBe(401);
  });

  it("returns 401 for a subscriber whose status is not active (e.g. canceled)", async () => {
    const kv = fakeKV({ "premium:full": JSON.stringify({ claims: [] }) });
    await putSubscriber(kv, {
      email: "jane@example.com", stripeCustomerId: "cus_1", subscriptionId: "sub_1",
      status: "canceled", currentPeriodEnd: NOW + 1000,
    });
    const cookie = await signSession("jane@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const result = await handlePremium(ENV, kv, `oannes_session=${cookie}`, NOW);
    expect(result.status).toBe(401);
  });

  it("returns 503 if the premium dataset hasn't been published to KV yet", async () => {
    const kv = fakeKV();
    await putSubscriber(kv, {
      email: "jane@example.com", stripeCustomerId: "cus_1", subscriptionId: "sub_1",
      status: "active", currentPeriodEnd: NOW + 1000,
    });
    const cookie = await signSession("jane@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const result = await handlePremium(ENV, kv, `oannes_session=${cookie}`, NOW);
    expect(result.status).toBe(503);
  });
});

Run: cd oannescode-site && npm test -- premium Expected: FAIL — module not found.

// oannescode-site/functions/api/premium.ts
import { verifySession } from "../_lib/cookie.js";
import { getSubscriber, isActive, type KVLike } from "../_lib/kv.js";

export interface PremiumEnv {
  COOKIE_SIGNING_SECRET: string;
}

function parseCookie(cookieHeader: string, name: string): string | null {
  const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${name}=`));
  return match ? match.slice(name.length + 1) : null;
}

export async function handlePremium(
  env: PremiumEnv,
  kv: KVLike,
  cookieHeader: string,
  nowSec: number,
): Promise<{ status: number; body: unknown }> {
  const sessionCookie = parseCookie(cookieHeader, "oannes_session");
  if (!sessionCookie) return { status: 401, body: { error: "not_authenticated" } };

  const session = await verifySession(sessionCookie, env.COOKIE_SIGNING_SECRET, nowSec);
  if (!session) return { status: 401, body: { error: "not_authenticated" } };

  const subscriber = await getSubscriber(kv, session.email);
  if (!isActive(subscriber)) return { status: 401, body: { error: "not_subscribed" } };

  const raw = await kv.get("premium:full");
  if (!raw) return { status: 503, body: { error: "premium_data_not_published" } };

  return { status: 200, body: JSON.parse(raw) };
}

export const onRequestGet: PagesFunction<PremiumEnv & { SUBSCRIBERS: KVNamespace }> = async (context) => {
  const nowSec = Math.floor(Date.now() / 1000);
  const { status, body } = await handlePremium(
    context.env,
    context.env.SUBSCRIBERS,
    context.request.headers.get("Cookie") ?? "",
    nowSec,
  );
  return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
};

Run: cd oannescode-site && npm test -- premium Expected: 4 tests pass.

git add oannescode-site/functions/api/premium.ts oannescode-site/tests/premium.test.ts
git commit -m "feat(oannescode-site): GET /api/premium (gated full dataset)"

Task 12: GET /api/export and GET /api/dossiers/:topic

Files: - Create: oannescode-site/functions/api/export.ts - Create: oannescode-site/functions/api/dossiers/[topic].ts - Test: oannescode-site/tests/export.test.ts - Test: oannescode-site/tests/dossiers.test.ts

Interfaces: - Consumes: same gate pattern as Task 11 (verifySession, getSubscriber/isActive). - Produces: handleExport(env, kv, cookieHeader, nowSec): Promise<{ status: number; body: unknown; contentType: string }>; handleDossier(env, kv, cookieHeader, nowSec, topic): Promise<{ status: number; body: unknown }>. Both read pre-generated content from KV ("premium:export", `premium:dossier:${topic}`) written by Tasks 14/15 — neither Function generates anything itself.

// oannescode-site/tests/export.test.ts
import { describe, it, expect } from "vitest";
import { handleExport } from "../functions/api/export.js";
import { putSubscriber, type KVLike } from "../functions/_lib/kv.js";
import { signSession } from "../functions/_lib/cookie.js";

function fakeKV(initial: Record<string, string> = {}): KVLike {
  const store = new Map(Object.entries(initial));
  return {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => { store.set(key, value); },
  };
}

const ENV = { COOKIE_SIGNING_SECRET: "cookie-secret" };
const NOW = 1_800_000_000;

describe("handleExport", () => {
  it("returns the CSV export for an active subscriber", async () => {
    const kv = fakeKV({ "premium:export": "id,text\nclm-1,hello" });
    await putSubscriber(kv, { email: "jane@example.com", stripeCustomerId: "cus_1", subscriptionId: "sub_1", status: "active", currentPeriodEnd: NOW + 1000 });
    const cookie = await signSession("jane@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const result = await handleExport(ENV, kv, `oannes_session=${cookie}`, NOW);
    expect(result.status).toBe(200);
    expect(result.body).toBe("id,text\nclm-1,hello");
    expect(result.contentType).toBe("text/csv");
  });

  it("returns 401 for no session", async () => {
    const kv = fakeKV();
    const result = await handleExport(ENV, kv, "", NOW);
    expect(result.status).toBe(401);
  });
});
// oannescode-site/tests/dossiers.test.ts
import { describe, it, expect } from "vitest";
import { handleDossier } from "../functions/api/dossiers/[topic].js";
import { putSubscriber, type KVLike } from "../functions/_lib/kv.js";
import { signSession } from "../functions/_lib/cookie.js";

function fakeKV(initial: Record<string, string> = {}): KVLike {
  const store = new Map(Object.entries(initial));
  return {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => { store.set(key, value); },
  };
}

const ENV = { COOKIE_SIGNING_SECRET: "cookie-secret" };
const NOW = 1_800_000_000;

describe("handleDossier", () => {
  it("returns the dossier for a known topic", async () => {
    const kv = fakeKV({ "premium:dossier:disclosure": JSON.stringify({ topic: "disclosure", synthesis: "..." }) });
    await putSubscriber(kv, { email: "jane@example.com", stripeCustomerId: "cus_1", subscriptionId: "sub_1", status: "active", currentPeriodEnd: NOW + 1000 });
    const cookie = await signSession("jane@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const result = await handleDossier(ENV, kv, `oannes_session=${cookie}`, NOW, "disclosure");
    expect(result.status).toBe(200);
    expect(result.body).toEqual({ topic: "disclosure", synthesis: "..." });
  });

  it("returns 404 for an unknown topic", async () => {
    const kv = fakeKV();
    await putSubscriber(kv, { email: "jane@example.com", stripeCustomerId: "cus_1", subscriptionId: "sub_1", status: "active", currentPeriodEnd: NOW + 1000 });
    const cookie = await signSession("jane@example.com", NOW + 1000, ENV.COOKIE_SIGNING_SECRET);
    const result = await handleDossier(ENV, kv, `oannes_session=${cookie}`, NOW, "nonexistent-topic");
    expect(result.status).toBe(404);
  });

  it("returns 401 before checking the topic at all", async () => {
    const kv = fakeKV();
    const result = await handleDossier(ENV, kv, "", NOW, "disclosure");
    expect(result.status).toBe(401);
  });
});

Run: cd oannescode-site && npm test -- export dossiers Expected: FAIL — modules not found.

// oannescode-site/functions/api/export.ts
import { verifySession } from "../_lib/cookie.js";
import { getSubscriber, isActive, type KVLike } from "../_lib/kv.js";

export interface ExportEnv {
  COOKIE_SIGNING_SECRET: string;
}

function parseCookie(cookieHeader: string, name: string): string | null {
  const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${name}=`));
  return match ? match.slice(name.length + 1) : null;
}

export async function handleExport(
  env: ExportEnv,
  kv: KVLike,
  cookieHeader: string,
  nowSec: number,
): Promise<{ status: number; body: unknown; contentType: string }> {
  const sessionCookie = parseCookie(cookieHeader, "oannes_session");
  const session = sessionCookie ? await verifySession(sessionCookie, env.COOKIE_SIGNING_SECRET, nowSec) : null;
  if (!session) return { status: 401, body: { error: "not_authenticated" }, contentType: "application/json" };

  const subscriber = await getSubscriber(kv, session.email);
  if (!isActive(subscriber)) return { status: 401, body: { error: "not_subscribed" }, contentType: "application/json" };

  const csv = await kv.get("premium:export");
  if (!csv) return { status: 503, body: { error: "export_not_published" }, contentType: "application/json" };

  return { status: 200, body: csv, contentType: "text/csv" };
}

export const onRequestGet: PagesFunction<ExportEnv & { SUBSCRIBERS: KVNamespace }> = async (context) => {
  const nowSec = Math.floor(Date.now() / 1000);
  const { status, body, contentType } = await handleExport(
    context.env, context.env.SUBSCRIBERS, context.request.headers.get("Cookie") ?? "", nowSec,
  );
  const payload = contentType === "text/csv" ? String(body) : JSON.stringify(body);
  return new Response(payload, { status, headers: { "Content-Type": contentType } });
};
// oannescode-site/functions/api/dossiers/[topic].ts
import { verifySession } from "../../_lib/cookie.js";
import { getSubscriber, isActive, type KVLike } from "../../_lib/kv.js";

export interface DossierEnv {
  COOKIE_SIGNING_SECRET: string;
}

function parseCookie(cookieHeader: string, name: string): string | null {
  const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${name}=`));
  return match ? match.slice(name.length + 1) : null;
}

export async function handleDossier(
  env: DossierEnv,
  kv: KVLike,
  cookieHeader: string,
  nowSec: number,
  topic: string,
): Promise<{ status: number; body: unknown }> {
  const sessionCookie = parseCookie(cookieHeader, "oannes_session");
  const session = sessionCookie ? await verifySession(sessionCookie, env.COOKIE_SIGNING_SECRET, nowSec) : null;
  if (!session) return { status: 401, body: { error: "not_authenticated" } };

  const subscriber = await getSubscriber(kv, session.email);
  if (!isActive(subscriber)) return { status: 401, body: { error: "not_subscribed" } };

  const raw = await kv.get(`premium:dossier:${topic}`);
  if (!raw) return { status: 404, body: { error: "dossier_not_found" } };

  return { status: 200, body: JSON.parse(raw) };
}

export const onRequestGet: PagesFunction<DossierEnv & { SUBSCRIBERS: KVNamespace }> = async (context) => {
  const nowSec = Math.floor(Date.now() / 1000);
  const topic = String(context.params.topic ?? "");
  const { status, body } = await handleDossier(
    context.env, context.env.SUBSCRIBERS, context.request.headers.get("Cookie") ?? "", nowSec, topic,
  );
  return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
};

Run: cd oannescode-site && npm test -- export dossiers Expected: 5 tests pass (2 export + 3 dossiers).

git add oannescode-site/functions/api/export.ts "oannescode-site/functions/api/dossiers/[topic].ts" oannescode-site/tests/export.test.ts oannescode-site/tests/dossiers.test.ts
git commit -m "feat(oannescode-site): GET /api/export + GET /api/dossiers/:topic"

Task 13: Typecheck all Functions together

Files: - Modify: none (verification-only task)

Interfaces: - Consumes: everything from Tasks 3-12. - Produces: confidence that the PagesFunction<Env> types used across every onRequest* export are internally consistent before touching the Python/UI side.

Run: cd oannescode-site && npm run typecheck Expected: no errors. If PagesFunction/KVNamespace are not found, confirm @cloudflare/workers-types is in tsconfig.json‘s types array (Task 2, Step 2) and re-run npm install.

Run: cd oannescode-site && npm test Expected: every test file from Tasks 2-12 passes (should be ~35 tests total).

git add oannescode-site/
git commit -m "fix(oannescode-site): resolve cross-file type errors in Pages Functions"

Task 14: export_codex.py — split free/premium output, publish premium to KV

Files: - Modify: oannes/Oannes/_scripts/export_codex.py - Modify: oannes/Oannes/_scripts/tests/test_export_codex.py

Interfaces: - Produces: default invocation (python3 export_codex.py) writes the free static file with corroboration counts only (breaking change from the current shape, per the spec’s Feature Matrix — free tier must not ship resolvable corroboration ids); --premium writes the full-fidelity dataset to Cloudflare KV via the REST API (needs CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_KV_NAMESPACE_ID env vars — the same account-scoped credentials problem from Task 1 applies here: these must be the real oannescode account’s token, not the locally-authenticated one).

Add to oannes/Oannes/_scripts/tests/test_export_codex.py:

def test_free_output_has_counts_not_ids(tmp_path):
    # Two claims that corroborate each other.
    a = _write(tmp_path, "a.md", """---
id: "clm-a"
claimant: "[[Jane Doe]]"
topics:
  - x
corroborated_by:
  - "Claim - b"
contradicted_by: []
date_display: ""
---

# Claim: First claim

## The Claim
First claim
""")
    b = _write(tmp_path, "b.md", """---
id: "clm-b"
claimant: "[[John Roe]]"
topics:
  - x
corroborated_by:
  - "Claim - a"
contradicted_by: []
date_display: ""
---

# Claim: Second claim

## The Claim
Second claim
""")
    from export_codex import build_datasets
    free, premium = build_datasets([a, b])
    free_claim = next(c for c in free["claims"] if c["id"] == "clm-a")
    assert free_claim["corroboratedCount"] == 1
    assert "corroborated_by" not in free_claim  # no resolvable ids in the free payload

    premium_claim = next(c for c in premium["claims"] if c["id"] == "clm-a")
    assert premium_claim["corroborated_by"] == ["clm-b"]


def test_premium_dataset_includes_full_claim_fields(tmp_path):
    a = _write(tmp_path, "a.md", """---
id: "clm-a"
claimant: "[[Jane Doe]]"
topics:
  - x
corroborated_by: []
contradicted_by: []
date_display: "2023"
---

# Claim: First claim

## The Claim
First claim
""")
    from export_codex import build_datasets
    free, premium = build_datasets([a])
    assert premium["claims"][0]["text"] == "First claim"
    assert premium["claims"][0]["claimant"] == "Jane Doe"

Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/test_export_codex.py -q Expected: FAIL — build_datasets does not exist yet.

Replace the body of oannes/Oannes/_scripts/export_codex.py from the def main(): line onward (keep everything above def main()unwiki, parse_claim_file — unchanged):

def build_datasets(paths: list[str]) -> tuple[dict, dict]:
    """Parse claim files at `paths` and return (free_dataset, premium_dataset).

    Free: full text/claimant/topics/date for every claim, but corroboration/
    contradiction links are reduced to a count (no resolvable ids) — the
    paywall's core enforcement point (see 2026-07-05 design spec).
    Premium: identical claim fields, plus corroborated_by/contradicted_by as
    resolved claim-id arrays (same shape the pre-paywall public file had).
    """
    claims = []
    for p in paths:
        c = parse_claim_file(p)
        if c:
            claims.append(c)

    stem_to_id = {c["_stem"]: c["id"] for c in claims}

    free_claims = []
    premium_claims = []
    for c in claims:
        resolved_corr = [stem_to_id[s] for s in c["corroborated_by"] if s in stem_to_id]
        resolved_contra = [stem_to_id[s] for s in c["contradicted_by"] if s in stem_to_id]

        base = {
            "id": c["id"],
            "text": c["text"],
            "claimant": c["claimant"],
            "topics": c["topics"],
            "date": c["date"],
        }
        free_claims.append({**base, "corroboratedCount": len(resolved_corr), "contradictedCount": len(resolved_contra)})
        premium_claims.append({**base, "corroborated_by": resolved_corr, "contradicted_by": resolved_contra})

    now = datetime.now(timezone.utc).isoformat()
    free = {"generatedAt": now, "count": len(free_claims), "claims": free_claims}
    premium = {"generatedAt": now, "count": len(premium_claims), "claims": premium_claims}
    return free, premium


def to_csv(premium_dataset: dict) -> str:
    """Flatten the premium dataset into export-friendly CSV (used by /api/export)."""
    import csv
    import io

    buf = io.StringIO()
    writer = csv.writer(buf)
    writer.writerow(["id", "text", "claimant", "topics", "date", "corroborated_by", "contradicted_by"])
    for c in premium_dataset["claims"]:
        writer.writerow([
            c["id"], c["text"], c["claimant"], ";".join(c["topics"]), c["date"],
            ";".join(c["corroborated_by"]), ";".join(c["contradicted_by"]),
        ])
    return buf.getvalue()


def publish_to_kv(key: str, value: str) -> None:
    """Push a value into the oannescode Cloudflare KV namespace via the REST API.

    Requires CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_KV_NAMESPACE_ID
    in the environment -- these must belong to the same Cloudflare account that
    owns the oannescode Pages project (see the paywall plan's Global Constraints;
    neither the locally-logged-in wrangler session nor cambium/.env's token has
    access to that account as of 2026-07-05).
    """
    import urllib.request

    token = os.environ["CLOUDFLARE_API_TOKEN"]
    account_id = os.environ["CLOUDFLARE_ACCOUNT_ID"]
    namespace_id = os.environ["CLOUDFLARE_KV_NAMESPACE_ID"]
    url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/values/{key}"
    req = urllib.request.Request(url, data=value.encode("utf-8"), method="PUT",
                                  headers={"Authorization": f"Bearer {token}"})
    with urllib.request.urlopen(req) as resp:
        if resp.status >= 300:
            raise RuntimeError(f"KV publish failed for {key}: HTTP {resp.status}")


def main():
    ap = argparse.ArgumentParser(description="Export claims dataset for the Codex (free + premium)")
    ap.add_argument("--out", default=DEFAULT_OUT, help="path for the free static claims.json")
    ap.add_argument("--premium", action="store_true", help="also build + publish the premium dataset + CSV export to KV")
    args = ap.parse_args()

    paths = [os.path.join(CLAIMS_DIR, f) for f in sorted(os.listdir(CLAIMS_DIR)) if f.endswith(".md")]
    free, premium = build_datasets(paths)

    os.makedirs(os.path.dirname(args.out), exist_ok=True)
    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(free, f, ensure_ascii=False, separators=(",", ":"))
    print(f"exported {free['count']} claims (free) -> {args.out}")

    if args.premium:
        publish_to_kv("premium:full", json.dumps(premium, ensure_ascii=False, separators=(",", ":")))
        publish_to_kv("premium:export", to_csv(premium))
        print(f"published {premium['count']} claims (premium) + CSV export to KV")

    return 0

Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/test_export_codex.py -q Expected: all tests pass (existing 4 + 2 new = 6). Note: the existing tests reference parse_claim_file directly and are unaffected by this refactor; only the removed old main() body changes.

Run: cd oannes/Oannes/_scripts && python3 export_codex.py Expected: exported 4121 claims (free) -> .../oannescode-site/codex/claims.json, and python3 -c "import json; d=json.load(open('../../oannescode-site/codex/claims.json')); print(d['claims'][0].keys())" shows corroboratedCount/contradictedCount, not corroborated_by/contradicted_by.

This is a breaking change to the shape the current codex/index.html (shipped 2026-07-05) expects — Task 16 updates the UI to match. Do not deploy this regenerated claims.json until Task 16 is committed alongside it (same commit), or the live site’s corroboration-expand feature will break silently for free users between the two deploys.

cd /Users/joshua/Projects/Unicorn.Land
git add oannes/Oannes/_scripts/export_codex.py oannes/Oannes/_scripts/tests/test_export_codex.py
git commit -m "feat(oannes): split Codex export into free (counts-only) + premium (full) datasets

Free static claims.json no longer ships resolvable corroboration ids --
that was the whole enforcement gap the paywall design flagged (anyone
could read the full cross-link graph from dev tools regardless of UI).
--premium mode publishes the full dataset + a CSV export to Cloudflare
KV via the REST API, consumed by /api/premium and /api/export.

NOT deploying the regenerated claims.json in this commit -- it changes
shape and the live UI (shipped 2026-07-05) still expects the old shape.
Task 16 updates the UI and ships both together."

(Note: do not git push after this commit — the regenerated claims.json in the working tree/last commit is intentionally not yet live-compatible until Task 16 lands. Task 16’s plan step handles the actual push.)


Task 15: generate_dossiers.py — AI-synthesized Topic Dossiers

Files: - Create: oannes/Oannes/_scripts/generate_dossiers.py - Create: oannes/Oannes/_scripts/tests/test_generate_dossiers.py

Interfaces: - Produces: one JSON dossier per topic ({topic, synthesis, claimIds, generatedAt}), state-hashed like corroborate.py so only new/changed topics reprocess, published to KV under premium:dossier:<topic> — consumed by Task 12’s /api/dossiers/:topic.

# oannes/Oannes/_scripts/tests/test_generate_dossiers.py
"""Tests for the Topic Dossier generator (pure logic; no live Claude calls)."""

import json

from generate_dossiers import build_dossier_prompt, parse_dossier_response, topics_needing_dossiers


def _claim(cid, text, claimant="Someone", corroborated_by=None):
    return {"id": cid, "text": text, "claimant": claimant, "corroborated_by": corroborated_by or [], "contradicted_by": []}


def test_build_dossier_prompt_includes_claims_and_corroboration_context():
    claims = [
        _claim("clm-1", "A craft hovered over the base", corroborated_by=["clm-2"]),
        _claim("clm-2", "A similar craft was seen nearby"),
    ]
    prompt = build_dossier_prompt("crash-retrieval-programs", claims)
    assert "crash-retrieval-programs" in prompt
    assert "A craft hovered over the base" in prompt
    assert "corroborated by 1 other claim" in prompt


def test_parse_dossier_response_extracts_fenced_json():
    raw = '```json\n{"synthesis": "The strongest claims converge on..."}\n```'
    result = parse_dossier_response(raw)
    assert result["synthesis"].startswith("The strongest claims converge")


def test_parse_dossier_response_rejects_empty_synthesis():
    import pytest
    with pytest.raises(Exception):
        parse_dossier_response('```json\n{"synthesis": ""}\n```')


def test_topics_needing_dossiers_skips_unchanged():
    groups = {"a": [_claim("clm-1", "x")], "b": [_claim("clm-2", "y")]}
    from corroborate import topic_hash
    state = {"a": topic_hash(groups["a"])}
    pending = topics_needing_dossiers(groups, state)
    assert pending == ["b"]

Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/test_generate_dossiers.py -q Expected: FAIL — module not found.

"""
generate_dossiers.py - AI-synthesized Topic Dossiers for Codex premium subscribers.

Mirrors corroborate.py's pattern: topic-blocked Claude batches, per-topic
state hash so only new/changed topics reprocess, same consecutive-failure
circuit breaker. For each linkable topic, asks Claude to synthesize the
claims (with their existing corroboration links) into a short narrative
dossier in the Oannes brand voice -- the flagship premium feature from the
2026-07-05 paywall design.

Usage (from _scripts/, after corroborate.py + export_codex.py --premium
have run so premium claim data with resolved links is fresh):
    python3 generate_dossiers.py --limit 15
    python3 generate_dossiers.py --topic disclosure --dry-run
"""

import argparse
import json
import os
import re
import sys
import time

from extract import call_claude_cli
from corroborate import topic_hash, MIN_TOPIC_CLAIMS
from export_codex import build_datasets, publish_to_kv
from shared.logging import configure_logging, get_logger

log = get_logger("generate_dossiers")

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
VAULT_ROOT = os.path.dirname(SCRIPT_DIR)
CLAIMS_DIR = os.path.join(VAULT_ROOT, "Claims")
STATE_PATH = os.path.join(VAULT_ROOT, "_assets", "dossiers", "state.json")


def build_dossier_prompt(topic: str, claims: list[dict]) -> str:
    lines = [
        "You write a Topic Dossier for The Oannes Codex -- a synthesized narrative for",
        "paying subscribers who want the full cross-referenced picture of a topic, not",
        "just a list of claims. Voice: fascinated true believer, constrained by attribution.",
        "Never assert a contested claim as settled fact -- always 'X says Y'.",
        "",
        f"TOPIC: {topic}",
        "CLAIMS (with existing corroboration counts):",
    ]
    for c in claims:
        corr_note = f" (corroborated by {len(c['corroborated_by'])} other claim{'s' if len(c['corroborated_by']) != 1 else ''})" if c["corroborated_by"] else ""
        lines.append(f"- [{c['claimant']}] {c['text']}{corr_note}")
    lines += [
        "",
        "Write a synthesis (350-600 words): an overview of the topic, the strongest",
        "corroborated claims (name the witnesses), notable contradictions if any, and",
        "open questions the claim graph doesn't yet answer.",
        'Return ONLY JSON inside a ```json fenced block: {"synthesis": "..."}',
    ]
    return "\n".join(lines)


def parse_dossier_response(raw: str) -> dict:
    m = re.search(r"```json\s*([\s\S]*?)```", raw, re.IGNORECASE) or re.search(r"```\s*([\s\S]*?)```", raw)
    data = json.loads((m.group(1) if m else raw).strip())
    synthesis = data.get("synthesis", "").strip()
    if not synthesis:
        raise ValueError("empty synthesis in dossier response")
    return {"synthesis": synthesis}


def topics_needing_dossiers(groups: dict[str, list[dict]], state: dict[str, str]) -> list[str]:
    pending = [t for t, cs in groups.items() if state.get(t) != topic_hash(cs)]
    pending.sort(key=lambda t: len(groups[t]), reverse=True)
    return pending


def load_state() -> dict:
    try:
        with open(STATE_PATH, "r", encoding="utf-8") as f:
            return json.load(f)
    except (OSError, json.JSONDecodeError):
        return {}


def save_state(state: dict):
    os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
    tmp = STATE_PATH + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(state, f, indent=2)
    os.rename(tmp, STATE_PATH)


def _premium_claims_by_topic() -> dict[str, list[dict]]:
    """Premium claim dicts (with resolved corroborated_by ids) grouped by topic,
    matching export_codex.build_datasets' shape -- so dossiers see the same
    cross-links the /api/premium payload does."""
    paths = [os.path.join(CLAIMS_DIR, f) for f in sorted(os.listdir(CLAIMS_DIR)) if f.endswith(".md")]
    _free, premium = build_datasets(paths)
    by_id = {c["id"]: c for c in premium["claims"]}
    groups: dict[str, list[dict]] = {}
    for c in premium["claims"]:
        for t in c["topics"]:
            groups.setdefault(t, []).append(c)
    return {t: cs for t, cs in groups.items() if len(cs) >= MIN_TOPIC_CLAIMS}


def main():
    ap = argparse.ArgumentParser(description="Generate AI Topic Dossiers for Codex premium subscribers")
    ap.add_argument("--limit", type=int, default=10)
    ap.add_argument("--topic")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    groups = _premium_claims_by_topic()
    state = load_state()
    log.info(f"{len(groups)} topics eligible for dossiers")

    if args.topic:
        todo = [args.topic] if args.topic in groups else []
        if not todo:
            log.error(f"topic not found or too small: {args.topic}")
            sys.exit(1)
    else:
        todo = topics_needing_dossiers(groups, state)[: args.limit]

    log.info(f"processing {len(todo)} topics")
    succeeded = errored = consecutive_failures = 0
    STRIKE_THRESHOLD, MAX_BACKOFF_ROUNDS = 5, 4

    for t in todo:
        cs = groups[t]
        try:
            raw = call_claude_cli(build_dossier_prompt(t, cs), timeout=300)
            dossier = parse_dossier_response(raw)
        except Exception as e:
            log.error(f"{t}: {e}")
            errored += 1
            consecutive_failures += 1
            if consecutive_failures % STRIKE_THRESHOLD == 0:
                round_num = consecutive_failures // STRIKE_THRESHOLD
                if round_num > MAX_BACKOFF_ROUNDS:
                    log.error(f"{consecutive_failures} consecutive failures, giving up for this run")
                    break
                backoff = 2 * 60 * (2 ** (round_num - 1))
                log.warning(f"backing off {backoff}s after {consecutive_failures} consecutive failures")
                time.sleep(backoff)
            continue

        consecutive_failures = 0
        succeeded += 1
        payload = {
            "topic": t,
            "synthesis": dossier["synthesis"],
            "claimIds": [c["id"] for c in cs],
        }
        log.info(f"{t}: dossier generated ({len(dossier['synthesis'])} chars){' [DRY RUN]' if args.dry_run else ''}")
        if not args.dry_run:
            publish_to_kv(f"premium:dossier:{t}", json.dumps(payload, ensure_ascii=False))
            state[t] = topic_hash(cs)
            save_state(state)

    log.info(f"done: {succeeded} dossiers generated, {errored} errored, out of {len(todo)} attempted")
    if errored and succeeded == 0 and todo:
        sys.exit(1)


if __name__ == "__main__":
    configure_logging()
    main()

Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/test_generate_dossiers.py -q Expected: 4 tests pass.

Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/ -q Expected: all tests pass (68 existing + 2 export_codex + 4 generate_dossiers = 74).

git add oannes/Oannes/_scripts/generate_dossiers.py oannes/Oannes/_scripts/tests/test_generate_dossiers.py
git commit -m "feat(oannes): generate_dossiers.py -- AI Topic Dossiers for Codex premium"

Task 16: Codex UI — paywall CTAs, free-tier truncation, premium unlock

Files: - Modify: oannescode-site/codex/index.html - Create: oannescode-site/codex/paywall.js - Create: oannescode-site/tests/paywall.test.ts

Interfaces: - Consumes: the new free claims.json shape from Task 14 (corroboratedCount/contradictedCount, no ids). - Produces: truncateForFree(filteredClaims, limit): { visible: Claim[]; hiddenCount: number }, mergePremiumClaims(freeClaims, premiumClaims): Claim[] (by-id merge, premium fields win) — pulled into a standalone module so they’re unit-testable; the rest of index.html‘s inline script calls these and stays thin.

// oannescode-site/tests/paywall.test.ts
import { describe, it, expect } from "vitest";
import { truncateForFree, mergePremiumClaims } from "../codex/paywall.js";

describe("truncateForFree", () => {
  it("caps results at the given limit and reports how many were hidden", () => {
    const claims = Array.from({ length: 20 }, (_, i) => ({ id: `c${i}` }));
    const { visible, hiddenCount } = truncateForFree(claims, 15);
    expect(visible).toHaveLength(15);
    expect(hiddenCount).toBe(5);
  });

  it("hiddenCount is 0 when under the limit", () => {
    const claims = [{ id: "a" }, { id: "b" }];
    const { visible, hiddenCount } = truncateForFree(claims, 15);
    expect(visible).toHaveLength(2);
    expect(hiddenCount).toBe(0);
  });
});

describe("mergePremiumClaims", () => {
  it("premium fields (resolved corroboration ids) override the free counts-only shape", () => {
    const free = [{ id: "c1", text: "x", corroboratedCount: 2 }];
    const premium = [{ id: "c1", text: "x", corroborated_by: ["c2", "c3"] }];
    const merged = mergePremiumClaims(free, premium);
    expect(merged[0]).toMatchObject({ id: "c1", corroborated_by: ["c2", "c3"] });
  });

  it("keeps free-only claims (e.g. premium fetch failed) unchanged", () => {
    const free = [{ id: "c1", text: "x", corroboratedCount: 0 }];
    const merged = mergePremiumClaims(free, []);
    expect(merged).toEqual(free);
  });
});

Run: cd oannescode-site && npm test -- paywall Expected: FAIL — module not found.

// oannescode-site/codex/paywall.js
// Pure helpers for the Codex paywall UI, kept out of index.html's inline
// script so they're unit-testable (see tests/paywall.test.ts).

export function truncateForFree(filteredClaims, limit) {
  return {
    visible: filteredClaims.slice(0, limit),
    hiddenCount: Math.max(0, filteredClaims.length - limit),
  };
}

export function mergePremiumClaims(freeClaims, premiumClaims) {
  const premiumById = new Map(premiumClaims.map((c) => [c.id, c]));
  return freeClaims.map((c) => (premiumById.has(c.id) ? { ...c, ...premiumById.get(c.id) } : c));
}

Run: cd oannescode-site && npm test -- paywall Expected: 4 tests pass.

In oannescode-site/codex/index.html, make these changes to the existing inline <script> (the file already has a render() function, a fetch('./claims.json') call, and FREE_LIMIT-shaped logic did not exist before — add it now):

  1. Add near the top of the <script> block: import { truncateForFree, mergePremiumClaims } from './paywall.js'; — this requires changing the <script> tag to <script type="module"> (find-and-replace the opening tag).
  2. Add a module-level let premiumClaims = []; and const FREE_LIMIT = 15;.
  3. In render(), after computing filtered, replace the existing filtered.slice(0, shown) pagination with: if the visitor has no premium data loaded, apply truncateForFree(filtered, FREE_LIMIT) and render an upsell banner ("Subscribe to see all ${hiddenCount} more results + the full corroboration network") when hiddenCount > 0, linking to a new “Unlock the Codex” button that calls POST /api/checkout and redirects to the returned URL; if premium data IS loaded, keep the existing shown-based “Show more” pagination unchanged (no cap).
  4. Replace the corroboration badge logic: for a claim with no premium data, render 🔒 ${c.corroboratedCount} related claim(s) — subscribe to see them (no expand button, since there’s nothing resolvable client-side) instead of the current corroborated_by-length-based expand button; for a claim with premium data merged in (has corroborated_by), keep the existing expand-button behavior unchanged.
  5. On page load, after fetching claims.json, also attempt fetch('/api/premium'); on a 200 response, call mergePremiumClaims(allClaims, data.claims) and re-render; on any non-200 (401/503/network error), silently continue with free-only data — no error shown to the visitor.
  6. Add a “Manage subscription” link (visible only when premium data is loaded) that POSTs to /api/billing-portal and redirects to the returned URL.
  7. After a ?session_id=... query param is present (post-Stripe-redirect), call GET /api/checkout/verify?session_id=... once, then strip the query param via history.replaceState and re-run the premium fetch.

Run: cd oannescode-site && python3 -m http.server 8940 in one terminal, then in the browser at http://127.0.0.1:8940/codex/: - Confirm the free experience still works: search, topic filters, and now a “🔒 N related claims” badge (no expand) plus an upsell banner once a search returns more than 15 results. - Confirm /api/premium fails gracefully (404/network error locally, since no Functions runtime is present under plain http.server) — the page must not show a broken/error state, just the free view. - This does NOT verify the actual Stripe checkout redirect or premium unlock — that requires either wrangler pages dev (which can run Functions locally against mocked bindings) or the live site with real Stripe test keys, both deferred to Task 18.

cd /Users/joshua/Projects/Unicorn.Land
git add oannescode-site/codex/index.html oannescode-site/codex/paywall.js oannescode-site/tests/paywall.test.ts oannescode-site/codex/claims.json
git commit -m "feat(oannescode-site): Codex paywall UI -- free-tier cap, subscribe CTA, premium unlock

Ships alongside the Task 14 claims.json shape change (counts-only for
free) so the two deploy together and the corroboration-expand feature
never breaks for free users mid-rollout."

Task 17: Extend the weekly-ingest cron to publish premium data

Files: - Modify: oannes-engine/deploy/oannes-weekly-ingest.sh - Modify: oannes-engine/.env (add CLOUDFLARE_KV_NAMESPACE_ID alongside existing CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID if not already present — reuse the same account-scoped token Task 1 used, once Joshua confirms it has KV write scope)

Interfaces: - Consumes: export_codex.py --premium (Task 14), generate_dossiers.py (Task 15). - Produces: premium data + dossiers stay fresh automatically on the existing Sunday cron, right after corroborate.py — this is the “early access” feature’s actual mechanism.

In oannes-engine/deploy/oannes-weekly-ingest.sh, immediately after the existing "$PY" corroborate.py --limit ... line (inside the success branch), add:

  # Publish premium Codex data (full corroboration graph + CSV export + dossiers)
  # to Cloudflare KV, so paid subscribers see new claims/links before the next
  # manual free claims.json regeneration+deploy.
  "$PY" export_codex.py --premium >>"$CORR_LOG" 2>&1
  "$PY" generate_dossiers.py --limit 15 >>"$CORR_LOG" 2>&1

Run: cd oannes-engine/deploy && bash -n oannes-weekly-ingest.sh (bash syntax check only) Expected: no output (syntax valid). Do not run the script for real yet — CLOUDFLARE_KV_NAMESPACE_ID isn’t set locally until the Task 1 workflow’s namespace ID is known and copied into .env, and export_codex.py --premium/generate_dossiers.py will correctly fail loudly (not silently) if that env var is missing, per their os.environ[...] (not .get(...)) lookups in Task 14/15’s implementation.

Append to oannes-engine/.env (using the production namespace id captured in Task 1, Step 2):

CLOUDFLARE_KV_NAMESPACE_ID=<production-id-from-task-1>

(CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID for this script must be the account that owns oannescode – if no such token exists in oannes-engine/.env yet, this step blocks on Joshua providing one scoped to that account with KV read/write permission, the same constraint noted in Task 1.)

git add oannes-engine/deploy/oannes-weekly-ingest.sh
git commit -m "feat(oannes): weekly cron publishes premium Codex data + dossiers after corroborate.py"

(.env is gitignored — no commit needed for Step 3.)


Task 18: Live Stripe test-mode verification (BLOCKED until Joshua creates the Stripe account)

Files: none new — this is an end-to-end verification pass over everything above.

echo "$STRIPE_SECRET_KEY" | wrangler pages secret put STRIPE_SECRET_KEY --project-name=oannescode
echo "$STRIPE_PRICE_ID" | wrangler pages secret put STRIPE_PRICE_ID --project-name=oannescode
echo "$STRIPE_WEBHOOK_SECRET" | wrangler pages secret put STRIPE_WEBHOOK_SECRET --project-name=oannescode