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
- Every Stripe/KV interaction must be unit-testable with mocked
fetch/KV — no live Stripe keys exist until Joshua creates the Stripe account (Task 18, explicitly deferred). - Fail closed: any Stripe error, KV miss, or signature mismatch → treat as “not subscribed,” never grant access by accident.
- Price is read from a
STRIPE_PRICE_IDsecret, never hardcoded (per spec). - Free tier keeps working exactly as shipped 2026-07-05 for anyone with no session cookie — no regression to the existing search/browse/topic-filter UI.
- The KV namespace must be created in the same Cloudflare account that owns the
oannescodePages project (account idbc0c90d4bc8c0d8b4b247f42c57aa65b) — confirmed during planning that neither the locally-authenticatedwranglersession nor thecambium/.envtoken has access to that account. Only the GitHub Actions repo secrets (CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID, already used bydeploy-oannescode.yml) can reach it — Task 1 provisions infra via a one-offworkflow_dispatchjob using those secrets.
Task 1: Provision Cloudflare KV namespace + cookie signing secret
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).
- [ ] Step 1: Write the one-off provisioning workflow
# .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)"
- [ ] Step 2: Trigger the workflow and capture the namespace IDs
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.
- [ ] Step 3: Write
wrangler.tomlwith the captured IDs
# 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>"
- [ ] Step 4: Commit
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.
- [ ] Step 1: Write
package.json
{
"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"
}
}
- [ ] Step 2: Write
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noEmit": true
},
"include": ["functions", "tests"]
}
- [ ] Step 3: Write
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
- [ ] Step 4: Write a trivial smoke module + test to prove the harness works
// 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);
});
});
- [ ] Step 5: Install deps and run
Run: cd oannescode-site && npm install && npm test
Expected: 1 test passes.
- [ ] Step 6: Commit
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"
Task 3: Cookie signing library
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.
- [ ] Step 1: Write the failing tests
// 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();
});
});
- [ ] Step 2: Run tests to verify they fail
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'
- [ ] Step 3: Write the implementation
// 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 };
}
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- cookie
Expected: 6 tests pass.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing tests
// 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" });
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- stripeClient
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
// 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) };
}
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- stripeClient
Expected: 5 tests pass.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing tests
// 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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- webhookVerify
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
// 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);
}
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- webhookVerify
Expected: 5 tests pass.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing tests
// 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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- kv
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
// 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";
}
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- kv
Expected: 5 tests pass.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing tests
// 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");
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- checkout
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
// 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" } });
};
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- checkout
Expected: 2 tests pass.
- [ ] Step 5: Commit
git add oannescode-site/functions/api/checkout.ts oannescode-site/tests/checkout.test.ts
git commit -m "feat(oannescode-site): POST /api/checkout"
Task 8: GET /api/checkout/verify — finalize checkout, set session cookie
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 }>.
- [ ] Step 1: Write the failing tests
// 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();
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- checkout-verify
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
// 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 });
};
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- checkout-verify
Expected: 3 tests pass.
- [ ] Step 5: Commit
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 }>.
- [ ] Step 1: Write the failing tests
// 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.
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- stripe-webhook
Expected: FAIL — module not found.
- [ ] Step 3: Add the customer->email index to
kv.ts, then implement the webhook handler
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" } });
};
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- stripe-webhook kv
Expected: all pass (5 webhook tests + updated kv tests still green).
- [ ] Step 5: Commit
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 }>.
- [ ] Step 1: Write the failing tests
// 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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- billing-portal
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
// 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" } });
};
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- billing-portal
Expected: 3 tests pass.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing tests
// 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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- premium
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
// 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" } });
};
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- premium
Expected: 4 tests pass.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing tests
// 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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- export dossiers
Expected: FAIL — modules not found.
- [ ] Step 3: Write the implementations
// 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" } });
};
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- export dossiers
Expected: 5 tests pass (2 export + 3 dossiers).
- [ ] Step 5: Commit
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.
- [ ] Step 1: Run the typechecker
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.
- [ ] Step 2: Run the full test suite
Run: cd oannescode-site && npm test
Expected: every test file from Tasks 2-12 passes (should be ~35 tests total).
- [ ] Step 3: Commit (only if Step 1 required a fix)
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).
- [ ] Step 1: Write the failing tests
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"
- [ ] Step 2: Run tests to verify they fail
Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/test_export_codex.py -q
Expected: FAIL — build_datasets does not exist yet.
- [ ] Step 3: Refactor
export_codex.pyto abuild_datasetsfunction + KV publish
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
- [ ] Step 4: Run tests to verify they pass
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.
- [ ] Step 5: Regenerate the live free
claims.jsonwith the new (counts-only) shape and update the Codex UI’s data assumptions
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.
- [ ] Step 6: Commit
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.
- [ ] Step 1: Write the failing tests
# 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"]
- [ ] Step 2: Run tests to verify they fail
Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/test_generate_dossiers.py -q
Expected: FAIL — module not found.
- [ ] Step 3: Write the implementation
"""
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()
- [ ] Step 4: Run tests to verify they pass
Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/test_generate_dossiers.py -q
Expected: 4 tests pass.
- [ ] Step 5: Run the full pipeline test suite to confirm no regressions
Run: cd oannes/Oannes/_scripts && python3 -m pytest tests/ -q
Expected: all tests pass (68 existing + 2 export_codex + 4 generate_dossiers = 74).
- [ ] Step 6: Commit
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.
- [ ] Step 1: Write the failing tests
// 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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: cd oannescode-site && npm test -- paywall
Expected: FAIL — module not found.
- [ ] Step 3: Write
paywall.js
// 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));
}
- [ ] Step 4: Run tests to verify they pass
Run: cd oannescode-site && npm test -- paywall
Expected: 4 tests pass.
- [ ] Step 5: Wire
paywall.jsintocodex/index.html
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):
- 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). - Add a module-level
let premiumClaims = [];andconst FREE_LIMIT = 15;. - In
render(), after computingfiltered, replace the existingfiltered.slice(0, shown)pagination with: if the visitor has no premium data loaded, applytruncateForFree(filtered, FREE_LIMIT)and render an upsell banner ("Subscribe to see all ${hiddenCount} more results + the full corroboration network") whenhiddenCount > 0, linking to a new “Unlock the Codex” button that callsPOST /api/checkoutand redirects to the returned URL; if premium data IS loaded, keep the existingshown-based “Show more” pagination unchanged (no cap). - 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 currentcorroborated_by-length-based expand button; for a claim with premium data merged in (hascorroborated_by), keep the existing expand-button behavior unchanged. - On page load, after fetching
claims.json, also attemptfetch('/api/premium'); on a 200 response, callmergePremiumClaims(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. - Add a “Manage subscription” link (visible only when premium data is loaded) that
POSTs to/api/billing-portaland redirects to the returned URL. - After a
?session_id=...query param is present (post-Stripe-redirect), callGET /api/checkout/verify?session_id=...once, then strip the query param viahistory.replaceStateand re-run the premium fetch.
- [ ] Step 6: Manual browser verification (no live Stripe needed for this part)
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.
- [ ] Step 7: Commit
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.
- [ ] Step 1: Add the publish step to the wrapper script
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
- [ ] Step 2: Verify the script still runs correctly with a dry, no-network-credential smoke test
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.
- [ ] Step 3: Add the KV namespace id to
.env
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.)
- [ ] Step 4: Commit
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.
- [ ] Step 1: Joshua creates the Stripe account, a Product, and a recurring Price; provides:
STRIPE_SECRET_KEY(test mode,sk_test_...)STRIPE_PUBLISHABLE_KEY(test mode,pk_test_...— not actually used server-side by this design, but useful if a client-side Stripe.js element is ever added later; safe to record now)-
STRIPE_PRICE_ID(price_...) -
[ ] Step 2: Set the Pages secrets (requires the correct-account credentials — via the same
provision-codex-kv.yml-style one-off workflow pattern from Task 1, or directly if Joshua has proper local access by then)
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
- [ ] Step 3: Create the webhook endpoint in the Stripe dashboard (test mode), pointed at
https://oannescode.com/api/stripe-webhook, subscribed tocheckout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed. Copy the generated signing secret and set it:
echo "$STRIPE_WEBHOOK_SECRET" | wrangler pages secret put STRIPE_WEBHOOK_SECRET --project-name=oannescode
-
[ ] Step 4: Deploy (push to
main—deploy-oannescode.ymlpicks up the new Functions +wrangler.tomlKV binding automatically) -
[ ] Step 5: End-to-end walkthrough using Stripe’s test card
4242 4242 4242 42421. Visitoannescode.com/codex/, search for something returning >15 results, confirm the upsell banner appears. 2. Click “Unlock the Codex” → complete Stripe Checkout with the test card. 3. Confirm redirect back to/codex/?session_id=..., confirm/api/checkout/verifysucceeds, confirm the corroboration badges become expandable and the result cap disappears. 4. Confirm the “Manage subscription” link opens the Stripe Billing Portal. 5. In the Billing Portal, cancel the subscription; confirm thecustomer.subscription.deletedwebhook fires (Stripe dashboard shows delivery success) and that reloading/codex/reverts to the free-capped view. 6. Confirm/api/dossiers/:topicand/api/exportboth work while subscribed and both 401 after cancellation. -
[ ] Step 6: Switch to live Stripe keys (repeat Step 2-3 with
sk_live_.../live webhook secret) once Step 5 passes cleanly. Do not flip to live keys before every item in Step 5 has been manually confirmed.