From c3e680fba982820550610ba525a890765e86ee24 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 10:07:53 +0300 Subject: [PATCH 01/24] =?UTF-8?q?chore(worker):=20GITHUB=5FCLIENT=5FID=20o?= =?UTF-8?q?ut=20of=20wrangler.toml=20=E2=80=94=20deploy-time=20secret=20fr?= =?UTF-8?q?om=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub-App client id (technically public) is no longer hardcoded in the repo: [vars] entry removed; it becomes a wrangler secret sourced from the repo-root .env GITHUB_APP_CLIENT_ID, alongside GITHUB_CLIENT_SECRET. README runbook updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- workers/learn-api/README.md | 10 ++++++---- workers/learn-api/wrangler.toml | 9 ++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/workers/learn-api/README.md b/workers/learn-api/README.md index 0135aed..acd6ca1 100644 --- a/workers/learn-api/README.md +++ b/workers/learn-api/README.md @@ -117,10 +117,11 @@ wrangler d1 execute learn-ledger --file schema.sql ``` Copy the returned ids into `wrangler.toml` (replace the `REPLACE_ME_*` -placeholders for `id`, `preview_id`, and `database_id`), and set -`GITHUB_CLIENT_ID` there too (it is public). The route + `PUBLIC_URL` / -`APP_URL` / `CORS_ORIGIN` / `PAGES_ORIGIN` vars are already filled in -`wrangler.toml` from the live Phase-1 deploy. +placeholders for `id`, `preview_id`, and `database_id`). The route + +`PUBLIC_URL` / `APP_URL` / `CORS_ORIGIN` / `PAGES_ORIGIN` vars are already +filled in `wrangler.toml` from the live Phase-1 deploy. `GITHUB_CLIENT_ID` +is NOT committed — it is set as a secret in step 3 (sourced from the +repo-root `.env` `GITHUB_APP_CLIENT_ID`). Apply the schema to the **remote** D1 (the `--local` form only touches the `wrangler dev` SQLite): @@ -133,6 +134,7 @@ wrangler d1 execute learn-ledger --remote --file schema.sql ```bash wrangler secret put SESSION_SECRET # random >= 32 bytes (e.g. openssl rand -base64 48) +wrangler secret put GITHUB_CLIENT_ID # GitHub-App client id (.env GITHUB_APP_CLIENT_ID; public but not committed) wrangler secret put GITHUB_CLIENT_SECRET # from the OAuth app wrangler secret put INFERENCE_TOKEN # bearer for the served inference endpoint ``` diff --git a/workers/learn-api/wrangler.toml b/workers/learn-api/wrangler.toml index 6632146..4f0f95e 100644 --- a/workers/learn-api/wrangler.toml +++ b/workers/learn-api/wrangler.toml @@ -35,9 +35,11 @@ routes = [ # (an OpenAI-shaped HTTP API), NEVER a bespoke provider SDK. The broker only # ever POSTs JSON to this URL. Leave it unset to disable tutoring (broker 503s). [vars] -# GITHUB_CLIENT_ID is public (the OAuth/GitHub-App client id). The matching -# GITHUB_CLIENT_SECRET is set via `wrangler secret put`, never committed. -GITHUB_CLIENT_ID = "Ov23liX8uNduVkEqVeQB" +# GITHUB_CLIENT_ID (the OAuth/GitHub-App client id) is technically public but +# deliberately NOT committed here: like GITHUB_CLIENT_SECRET it is set via +# `wrangler secret put GITHUB_CLIENT_ID`, sourced from the repo-root .env +# (GITHUB_APP_CLIENT_ID). Out of [vars], a plain `wrangler deploy` never +# overwrites it and no environment-specific id is hardcoded in the repo. PUBLIC_URL = "https://agentculture.org" # origin used to build the OAuth callback APP_URL = "https://agentculture.org/learn/" # where the web callback redirects post-login CORS_ORIGIN = "https://agentculture.org" # allow the /learn site to call the API with creds @@ -47,6 +49,7 @@ PAGES_ORIGIN = "https://agentculture-learn.pages.dev" # static-site origin the # --- Secrets (NEVER committed) --------------------------------------------- # Set with: wrangler secret put # SESSION_SECRET random >=32 bytes; signs session tokens (HMAC-SHA256) +# GITHUB_CLIENT_ID the GitHub-App client id (.env GITHUB_APP_CLIENT_ID) # GITHUB_CLIENT_SECRET from the GitHub OAuth app # INFERENCE_TOKEN bearer token for the served inference endpoint From 34ce56eee6e91ed8607e6428d80458cbabe2efc6 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 10:08:01 +0300 Subject: [PATCH 02/24] feat(worker): D1 consent schema + db helpers (t2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a consents table (github_user_id, terms_version, granted_at; PK on the first two) to schema.sql, idempotent and FK-light so consent can be recorded before a learners row exists (the pending-consent flow, t5). Add getConsent/recordConsent/deleteLearnerData to db.js, following the existing prepared-statement/ON CONFLICT/ISO-8601 style, with unit tests against an extended D1Stub (consents storage + batch()). No route wiring — index.js/auth.js/session.js untouched, that's t5/t7. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- workers/learn-api/schema.sql | 34 +++++- workers/learn-api/src/db.js | 72 ++++++++++++- workers/learn-api/test/db.test.js | 172 ++++++++++++++++++++++++++++++ workers/learn-api/test/helpers.js | 55 +++++++++- 4 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 workers/learn-api/test/db.test.js diff --git a/workers/learn-api/schema.sql b/workers/learn-api/schema.sql index 6e4e7a0..d0347ed 100644 --- a/workers/learn-api/schema.sql +++ b/workers/learn-api/schema.sql @@ -1,11 +1,18 @@ -- learn API — D1 schema (learner ledger). -- --- Two tables. Privacy invariant: the ONLY persisted identity is the GitHub +-- Three tables. Privacy invariant: the ONLY persisted identity is the GitHub -- numeric id + a display name. No password, no email, no PII beyond the id and --- the name the learner already shows publicly on GitHub. +-- the name the learner already shows publicly on GitHub. The `consents` table +-- (below) is the one exception to "nothing is written before it's decided" — +-- it exists so that everything else CAN be gated on it (t5): no learner row, +-- no record, is written for a github_user_id until a consent row for the +-- currently published terms_version exists for them. -- -- Apply with: wrangler d1 execute learn-ledger --file schema.sql -- (add --local for the wrangler-dev SQLite; omit it for the remote D1). +-- Every statement below is idempotent (CREATE ... IF NOT EXISTS), so +-- re-running this file against the existing remote database is a safe no-op +-- for tables/indexes that already exist and only adds what's new. -- One row per learner. `state` is the cross-subject learner profile blob -- (preferences, streak anchors, etc.) — small JSON, owned by learn-cli. @@ -42,3 +49,26 @@ CREATE INDEX IF NOT EXISTS idx_records_user_subject CREATE INDEX IF NOT EXISTS idx_records_user_subject_item ON records (github_user_id, subject, item_id); + +-- Consent ledger. One row per learner per terms_version they've accepted — +-- re-granting the SAME version updates granted_at in place (idempotent +-- accept, no duplicate row); a NEW published terms_version always inserts a +-- fresh row (re-consent, t6). "Current" consent for a learner is the row with +-- the latest granted_at (see db.js getConsent). No FOREIGN KEY to learners +-- on purpose: consent can be recorded before a learners row exists (the +-- pending-consent flow, t5, writes nothing to `learners` until accept). +-- +-- Withdrawal (t7) hard-deletes every row here for the learner — an explicit, +-- narrow override of the records table's append-only rule above. Erasure +-- beats immutability at the whole-learner-exit granularity; the two rules +-- don't conflict because they operate at different scopes (per-row edits vs +-- a learner's full exit). +CREATE TABLE IF NOT EXISTS consents ( + github_user_id TEXT NOT NULL, + terms_version TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY (github_user_id, terms_version) +); + +CREATE INDEX IF NOT EXISTS idx_consents_user + ON consents (github_user_id); diff --git a/workers/learn-api/src/db.js b/workers/learn-api/src/db.js index a8ac936..e7a4e45 100644 --- a/workers/learn-api/src/db.js +++ b/workers/learn-api/src/db.js @@ -1,15 +1,21 @@ // D1 data access — the cross-subject learner ledger. // -// Two tables (see schema.sql): +// Three tables (see schema.sql): // learners — one row per GitHub user: id, display name, learner-state JSON. // NO password, NO email. This is the entire persisted identity. // records — APPEND-ONLY ledger of recorded results, keyed by // (github_user_id, subject, item_id). Never UPDATEd, never // DELETEd — the motivation layer (t9) reduces over the full // history, so every raw observation is kept. +// consents — one row per (github_user_id, terms_version) accepted, with a +// server-time granted_at. This is the resource gate: no other +// table is written for a learner until a consent row exists +// for the currently published terms_version (t5 wires the +// gate; this module only provides the data-layer functions). // -// All access goes through the D1 binding `env.DB` (prepare/bind/run/all/first), -// so tests swap in an in-memory D1 stub with the same surface. +// All access goes through the D1 binding `env.DB` (prepare/bind/run/all/first, +// plus batch for the multi-statement delete), so tests swap in an in-memory +// D1 stub with the same surface. /** Upsert a learner's identity. Only id + display name are written on login. */ export async function upsertLearner(env, learner) { @@ -75,3 +81,63 @@ export async function listRecords(env, uid, subject) { .all(); return (out && out.results) || []; } + +/** Read a learner's most recent consent row (by granted_at), or null. */ +export async function getConsent(env, uid) { + const row = await env.DB.prepare( + `SELECT github_user_id, terms_version, granted_at + FROM consents + WHERE github_user_id = ? + ORDER BY granted_at DESC + LIMIT 1`, + ) + .bind(String(uid)) + .first(); + if (!row) return null; + return { + github_user_id: row.github_user_id, + terms_version: row.terms_version, + granted_at: row.granted_at, + }; +} + +/** + * Record a learner's consent to a terms version (server time, ISO-8601). + * Re-granting the SAME version updates granted_at in place rather than + * inserting a duplicate row; a different terms_version always inserts a new + * row (re-consent, t6). + */ +export async function recordConsent(env, uid, termsVersion) { + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO consents (github_user_id, terms_version, granted_at) + VALUES (?, ?, ?) + ON CONFLICT(github_user_id, terms_version) DO UPDATE SET granted_at = ?`, + ) + .bind(String(uid), termsVersion, now, now) + .run(); + return { github_user_id: String(uid), terms_version: termsVersion, granted_at: now }; +} + +/** + * Erase everything persisted for a learner in one D1 batch: records and + * consents first, the learners row last (records/consents reference the + * learner, so deleting them first keeps the batch FK-safe even though this + * table's FK isn't declared — see schema.sql). Withdrawal of consent means + * deletion (spec c18): this is the sole override of the records table's + * append-only rule, at whole-learner-exit granularity. + * Returns how many rows were removed from each table. + */ +export async function deleteLearnerData(env, uid) { + const id = String(uid); + const [recordsResult, consentsResult, learnersResult] = await env.DB.batch([ + env.DB.prepare(`DELETE FROM records WHERE github_user_id = ?`).bind(id), + env.DB.prepare(`DELETE FROM consents WHERE github_user_id = ?`).bind(id), + env.DB.prepare(`DELETE FROM learners WHERE github_user_id = ?`).bind(id), + ]); + return { + records: (recordsResult && recordsResult.meta && recordsResult.meta.changes) || 0, + consents: (consentsResult && consentsResult.meta && consentsResult.meta.changes) || 0, + learners: (learnersResult && learnersResult.meta && learnersResult.meta.changes) || 0, + }; +} diff --git a/workers/learn-api/test/db.test.js b/workers/learn-api/test/db.test.js new file mode 100644 index 0000000..fb6bf3f --- /dev/null +++ b/workers/learn-api/test/db.test.js @@ -0,0 +1,172 @@ +// Unit tests for the consent data-layer functions in src/db.js +// (getConsent / recordConsent / deleteLearnerData). See schema.sql for the +// `consents` table this exercises: PRIMARY KEY (github_user_id, terms_version). +// +// No route wiring here (that's t5/t7) — these drive db.js directly against +// the D1Stub, the same way session.test.js drives session.js directly. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { getConsent, recordConsent, deleteLearnerData } from "../src/db.js"; +import { makeEnv } from "./helpers.js"; + +// --- getConsent -------------------------------------------------------- + +test("getConsent returns null when the learner has no consent on file", async () => { + const env = makeEnv(); + assert.equal(await getConsent(env, "42"), null); +}); + +test("getConsent returns the recorded consent after recordConsent", async () => { + const env = makeEnv(); + await recordConsent(env, "42", "v1"); + const consent = await getConsent(env, "42"); + assert.equal(consent.github_user_id, "42"); + assert.equal(consent.terms_version, "v1"); + assert.match( + consent.granted_at, + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + "granted_at is server-time ISO-8601", + ); +}); + +test("getConsent coerces a numeric uid the same as a string uid", async () => { + const env = makeEnv(); + await recordConsent(env, 42, "v1"); + const consent = await getConsent(env, "42"); + assert.equal(consent.github_user_id, "42"); +}); + +test("getConsent returns the most recent row when multiple terms_versions are recorded", async () => { + const env = makeEnv(); + // Seeded directly (not via recordConsent) so ordering is deterministic + // rather than racing wall-clock granularity within one test. + env.DB.consents.push( + { github_user_id: "42", terms_version: "v1", granted_at: "2026-01-01T00:00:00.000Z" }, + { github_user_id: "42", terms_version: "v2", granted_at: "2026-06-01T00:00:00.000Z" }, + ); + const consent = await getConsent(env, "42"); + assert.equal(consent.terms_version, "v2", "most recent by granted_at, not insertion order"); +}); + +test("getConsent is per-learner isolated", async () => { + const env = makeEnv(); + await recordConsent(env, "42", "v1"); + assert.equal(await getConsent(env, "99"), null); +}); + +// --- recordConsent ------------------------------------------------------- + +test("recordConsent inserts one row per distinct (uid, terms_version)", async () => { + const env = makeEnv(); + await recordConsent(env, "42", "v1"); + await recordConsent(env, "42", "v2"); + assert.equal(env.DB.consents.length, 2); +}); + +test("recordConsent re-granting the SAME version updates in place, does not duplicate", async () => { + const env = makeEnv(); + await recordConsent(env, "42", "v1"); + await recordConsent(env, "42", "v1"); + assert.equal(env.DB.consents.length, 1, "same (uid, terms_version) must not duplicate the row"); +}); + +test("recordConsent returns the recorded consent shape", async () => { + const env = makeEnv(); + const result = await recordConsent(env, "42", "v1"); + assert.equal(result.github_user_id, "42"); + assert.equal(result.terms_version, "v1"); + assert.match(result.granted_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); +}); + +test("recordConsent is per-learner isolated", async () => { + const env = makeEnv(); + await recordConsent(env, "42", "v1"); + await recordConsent(env, "99", "v1"); + assert.equal(env.DB.consents.length, 2); + assert.equal((await getConsent(env, "42")).github_user_id, "42"); + assert.equal((await getConsent(env, "99")).github_user_id, "99"); +}); + +// --- deleteLearnerData ----------------------------------------------------- + +test("deleteLearnerData removes the learner's records, consents, and learner row", async () => { + const env = makeEnv(); + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + env.DB.records.push( + { id: 1, github_user_id: "42", subject: "french", item_id: "a1" }, + { id: 2, github_user_id: "42", subject: "french", item_id: "a2" }, + ); + env.DB.consents.push({ + github_user_id: "42", + terms_version: "v1", + granted_at: "2026-01-01T00:00:00.000Z", + }); + + const result = await deleteLearnerData(env, "42"); + + assert.deepEqual(result, { records: 2, consents: 1, learners: 1 }); + assert.equal(env.DB.learners.has("42"), false); + assert.equal(env.DB.records.length, 0); + assert.equal(env.DB.consents.length, 0); +}); + +test("deleteLearnerData is per-learner isolated: another learner's rows survive", async () => { + const env = makeEnv(); + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + env.DB.learners.set("99", { github_user_id: "99", display_name: "Linus", state: "{}" }); + env.DB.records.push( + { id: 1, github_user_id: "42", subject: "french", item_id: "a1" }, + { id: 2, github_user_id: "99", subject: "french", item_id: "b1" }, + ); + env.DB.consents.push( + { github_user_id: "42", terms_version: "v1", granted_at: "2026-01-01T00:00:00.000Z" }, + { github_user_id: "99", terms_version: "v1", granted_at: "2026-01-01T00:00:00.000Z" }, + ); + + const result = await deleteLearnerData(env, "42"); + + assert.deepEqual(result, { records: 1, consents: 1, learners: 1 }); + assert.equal(env.DB.learners.has("99"), true); + assert.equal(env.DB.records.length, 1); + assert.equal(env.DB.records[0].github_user_id, "99"); + assert.equal(env.DB.consents.length, 1); + assert.equal(env.DB.consents[0].github_user_id, "99"); +}); + +test("deleteLearnerData on an unknown learner is a no-op that returns zero counts", async () => { + const env = makeEnv(); + const result = await deleteLearnerData(env, "does-not-exist"); + assert.deepEqual(result, { records: 0, consents: 0, learners: 0 }); +}); + +test("deleteLearnerData deletes records and consents before the learners row (FK-safe order)", async () => { + const env = makeEnv(); + const order = []; + const realBatch = env.DB.batch.bind(env.DB); + env.DB.batch = async (statements) => { + for (const stmt of statements) order.push(stmt.sql); + return realBatch(statements); + }; + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + + await deleteLearnerData(env, "42"); + + const learnersIdx = order.findIndex((s) => s.includes("delete from learners")); + const recordsIdx = order.findIndex((s) => s.includes("delete from records")); + const consentsIdx = order.findIndex((s) => s.includes("delete from consents")); + assert.ok(recordsIdx >= 0 && recordsIdx < learnersIdx, "records deleted before learners"); + assert.ok(consentsIdx >= 0 && consentsIdx < learnersIdx, "consents deleted before learners"); +}); + +test("deleteLearnerData revokes nothing beyond D1 rows (session revocation is t7's job)", async () => { + // Documents the boundary: this function only ever touches env.DB. Session + // tombstoning (env.SESSIONS) on withdrawal is wired by t7 at the route + // layer, not here. + const env = makeEnv(); + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + const sessionsBefore = env.SESSIONS.map.size; + await deleteLearnerData(env, "42"); + assert.equal(env.SESSIONS.map.size, sessionsBefore, "deleteLearnerData does not touch KV"); +}); diff --git a/workers/learn-api/test/helpers.js b/workers/learn-api/test/helpers.js index 313dd57..119ad79 100644 --- a/workers/learn-api/test/helpers.js +++ b/workers/learn-api/test/helpers.js @@ -35,13 +35,14 @@ export class KVStub { /** * Minimal Cloudflare D1 stub. Recognizes exactly the statements db.js issues - * (matched by keyword), backing them with a Map of learners + an array of - * append-only records. + * (matched by keyword), backing them with a Map of learners, an array of + * append-only records, and an array of consent rows. */ export class D1Stub { constructor() { this.learners = new Map(); this.records = []; + this.consents = []; this._id = 0; } @@ -52,6 +53,18 @@ export class D1Stub { _norm(sql) { return sql.replace(/\s+/g, " ").trim().toLowerCase(); } + + /** Cloudflare D1's batch API: run already-bound statements in order, + * returning their results array. The real D1 wraps this in a transaction; + * the stub just runs sequentially, which is enough to test statement order + * and aggregate results. */ + async batch(statements) { + const results = []; + for (const stmt of statements) { + results.push(await stmt.run()); + } + return results; + } } class D1Prepared { @@ -94,6 +107,37 @@ class D1Prepared { }); return { success: true, meta: { changes: 1, last_row_id: id } }; } + if (this.sql.includes("insert into consents")) { + // args: uid, terms_version, granted_at, granted_at(on-conflict update) + const [uid, termsVersion, grantedAt] = this.args; + const id = String(uid); + const existing = this.db.consents.find( + (c) => c.github_user_id === id && c.terms_version === termsVersion, + ); + if (existing) { + existing.granted_at = grantedAt; + } else { + this.db.consents.push({ github_user_id: id, terms_version: termsVersion, granted_at: grantedAt }); + } + return { success: true, meta: { changes: 1 } }; + } + if (this.sql.includes("delete from records")) { + const [uid] = this.args; + const before = this.db.records.length; + this.db.records = this.db.records.filter((r) => r.github_user_id !== String(uid)); + return { success: true, meta: { changes: before - this.db.records.length } }; + } + if (this.sql.includes("delete from consents")) { + const [uid] = this.args; + const before = this.db.consents.length; + this.db.consents = this.db.consents.filter((c) => c.github_user_id !== String(uid)); + return { success: true, meta: { changes: before - this.db.consents.length } }; + } + if (this.sql.includes("delete from learners")) { + const [uid] = this.args; + const existed = this.db.learners.delete(String(uid)); + return { success: true, meta: { changes: existed ? 1 : 0 } }; + } return { success: true, meta: {} }; } @@ -102,6 +146,13 @@ class D1Prepared { const [uid] = this.args; return this.db.learners.get(String(uid)) || null; } + if (this.sql.includes("from consents")) { + const [uid] = this.args; + const rows = this.db.consents + .filter((c) => c.github_user_id === String(uid)) + .sort((a, b) => (a.granted_at < b.granted_at ? 1 : a.granted_at > b.granted_at ? -1 : 0)); + return rows[0] || null; + } const { results } = await this.all(); return results[0] || null; } From 6ad14b9e56aeb1460ec9a34071d3e3d324765c56 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 10:14:02 +0300 Subject: [PATCH 03/24] feat(t1): versioned Terms of Use + Privacy Policy pages with shared TERMS_VERSION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /learn/terms/ and /learn/privacy/ (site-astro), both rendering a visible version + effective date and linked from the /learn footer. The Privacy Policy names the three processors (GitHub OAuth, Cloudflare hosting, AWS Bedrock Nova Sonic 2 / Nova Pro for the approved tutoring tier — with an explicit speech/text-leaves-to-the-model-provider callout), states what's collected (GitHub id + display name, the learning ledger, a session cookie; no email, no password), why, retention (kept while the account exists, hard-deleted on delete/withdraw), and the learner's rights (access/export/delete/withdraw). Terms of Use covers GitHub-based accounts, acceptable use, "as is" service, the approved AI-tutoring tier, data ownership, termination/suspension, changes-to-terms -> version bump -> re-consent, and a simple contact/governing-law section (GitHub issues, no published email). shared/terms-version.mjs is the single TERMS_VERSION/TERMS_EFFECTIVE_DATE source of truth, started at 1.0.0 / 2026-07-11, re-exported (not copied) by both site-astro/src/lib/terms.ts and workers/learn-api/src/terms.js — proven importable under both Vite/astro build and Node's own --test runner. tests/test_policy_pages.py cross-checks the pages' data claims against workers/learn-api/schema.sql (no email/password column) and proves the rendered version can't drift from the shared source. check-export-pages.mjs and check-static-auth.mjs are updated to treat terms/ and privacy/ as known non-subject, non-learner-panel pages so the new routes don't trip the orphan-page or signed-out-invitation checks. --- shared/terms-version.mjs | 30 +++ site-astro/scripts/check-export-pages.mjs | 5 +- site-astro/scripts/check-static-auth.mjs | 8 +- site-astro/src/components/Footer.astro | 12 ++ site-astro/src/lib/terms.ts | 10 + site-astro/src/pages/privacy/index.astro | 207 ++++++++++++++++++ site-astro/src/pages/terms/index.astro | 193 +++++++++++++++++ tests/test_policy_pages.py | 247 ++++++++++++++++++++++ workers/learn-api/src/terms.js | 13 ++ workers/learn-api/test/terms.test.js | 23 ++ 10 files changed, 745 insertions(+), 3 deletions(-) create mode 100644 shared/terms-version.mjs create mode 100644 site-astro/src/lib/terms.ts create mode 100644 site-astro/src/pages/privacy/index.astro create mode 100644 site-astro/src/pages/terms/index.astro create mode 100644 tests/test_policy_pages.py create mode 100644 workers/learn-api/src/terms.js create mode 100644 workers/learn-api/test/terms.test.js diff --git a/shared/terms-version.mjs b/shared/terms-version.mjs new file mode 100644 index 0000000..525808f --- /dev/null +++ b/shared/terms-version.mjs @@ -0,0 +1,30 @@ +// Single source of truth for the published Terms of Use / Privacy Policy +// version (spec "agentculture-org-learn-is-now-a-consent-first-role", #11; +// plan task t1). Two consumers import this file, and only this file: +// +// - the Astro site's pages (site-astro/src/lib/terms.ts -> src/pages/terms/, +// src/pages/privacy/) — renders the version + effective date visibly on +// both policy pages. +// - the Cloudflare Worker (workers/learn-api/src/terms.js) — a later wave +// (plan task t6) stamps the consent row's `terms_version` with this exact +// value and treats a mismatch as "re-consent required." +// +// Deliberately a plain ES module, not a `.json` file: `import x from +// "./f.json"` needs an import-assertion clause (`with { type: "json" }` / +// the older `assert { type: "json" }`) whose required syntax has moved +// across recent Node versions, and this file is loaded three different ways +// (Node's own `--test` runner in workers/learn-api, Vite/Rollup for `astro +// build`, esbuild inside `wrangler`/`wrangler deploy`). A plain `export +// const` sidesteps that version skew entirely — every one of those three +// loaders already understands plain ESM with zero extra configuration. +// +// Bumping TERMS_VERSION (and TERMS_EFFECTIVE_DATE alongside it) is what +// forces re-consent: a learner's stored `consent.terms_version` must equal +// this value exactly, or the next authenticated request routes back to the +// consent screen (spec requirement "Consent record", honesty condition h2). +// Keep the date in ISO-8601 (YYYY-MM-DD) to match every other timestamp +// convention in this repo (schema.sql's `created_at`/`updated_at`, the +// ledger's `at`). + +export const TERMS_VERSION = "1.0.0"; +export const TERMS_EFFECTIVE_DATE = "2026-07-11"; diff --git a/site-astro/scripts/check-export-pages.mjs b/site-astro/scripts/check-export-pages.mjs index 81aba82..c40eae6 100644 --- a/site-astro/scripts/check-export-pages.mjs +++ b/site-astro/scripts/check-export-pages.mjs @@ -120,7 +120,10 @@ function listDirs(dir) { .map((entry) => entry.name); } -const KNOWN_NON_SUBJECT_DIRS = new Set(["_astro"]); +// "terms"/"privacy" are t1's versioned policy pages (src/pages/terms/, +// src/pages/privacy/) — real top-level routes with no backing entry in the +// content-export, since they aren't subject content. +const KNOWN_NON_SUBJECT_DIRS = new Set(["_astro", "terms", "privacy"]); for (const dirName of listDirs(distDir)) { if (KNOWN_NON_SUBJECT_DIRS.has(dirName)) continue; if (!subjectNames.has(dirName)) { diff --git a/site-astro/scripts/check-static-auth.mjs b/site-astro/scripts/check-static-auth.mjs index c6772f8..c793898 100644 --- a/site-astro/scripts/check-static-auth.mjs +++ b/site-astro/scripts/check-static-auth.mjs @@ -165,12 +165,16 @@ check("global.css hides .signedin-only by an unconditional display:none", () => }); check("the landing page and every subject page render the signed-out invitation in raw HTML", () => { - const mustContain = [distDir /* landing */]; const missing = []; const landing = readFileSync(path.join(distDir, "index.html"), "utf8"); if (!landing.includes("Sign in to track progress")) missing.push("dist/index.html"); + // t1's versioned policy pages (src/pages/terms/, src/pages/privacy/) are + // plain static prose with no learner panel — they carry no + // signed-in/signed-out split at all, so this check (which is about that + // split, not "every top-level page") doesn't apply to them. + const NOT_A_LEARNER_PANEL_PAGE = new Set(["_astro", "terms", "privacy"]); for (const entry of readdirSync(distDir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name === "_astro") continue; + if (!entry.isDirectory() || NOT_A_LEARNER_PANEL_PAGE.has(entry.name)) continue; const subjectIndex = path.join(distDir, entry.name, "index.html"); if (!existsSync(subjectIndex)) continue; const html = readFileSync(subjectIndex, "utf8"); diff --git a/site-astro/src/components/Footer.astro b/site-astro/src/components/Footer.astro index 62d451c..607a81c 100644 --- a/site-astro/src/components/Footer.astro +++ b/site-astro/src/components/Footer.astro @@ -4,6 +4,12 @@ // always leaves for the org root, the "Learn" segment and the local nav // link stay on this site via the caller-supplied `homeHref` (see // Layout.astro and Header.astro for why it's relative, not base-prefixed). +// +// t1 adds "Terms" and "Privacy", linking src/pages/terms/ and +// src/pages/privacy/ the same relative-to-homeHref way "Subjects" already +// does (`${homeHref}terms/`) — org's own footer has no equivalent, since +// the policy pages live here, not there (spec decision: "Policy docs live +// /learn-local"). import site from "../data/site"; import Mark from "./Mark.astro"; @@ -37,6 +43,12 @@ const year = new Date().getFullYear();
  • Subjects
  • +
  • + Terms +
  • +
  • + Privacy +
  • { orgLinks.map(({ href, label }) => (
  • diff --git a/site-astro/src/lib/terms.ts b/site-astro/src/lib/terms.ts new file mode 100644 index 0000000..040d5c4 --- /dev/null +++ b/site-astro/src/lib/terms.ts @@ -0,0 +1,10 @@ +// Site-side re-export of the shared Terms/Privacy version, mirroring +// ../lib/content.ts's role as the typed loader every page imports through. +// Both policy pages (src/pages/terms/, src/pages/privacy/) import from this +// local path rather than reaching across the repo boundary themselves — the +// fact that the value actually lives in ../../../shared/terms-version.mjs is +// an implementation detail of this one file. +// +// See ../../../shared/terms-version.mjs for why that file is a plain ES +// module (not JSON) and for the re-consent contract TERMS_VERSION backs. +export { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../../shared/terms-version.mjs"; diff --git a/site-astro/src/pages/privacy/index.astro b/site-astro/src/pages/privacy/index.astro new file mode 100644 index 0000000..45223ff --- /dev/null +++ b/site-astro/src/pages/privacy/index.astro @@ -0,0 +1,207 @@ +--- +// Privacy Policy — versioned per spec #11 (plan task t1). Same shell as +// terms/index.astro; see that file's header comment for the shared-version +// wiring (src/lib/terms.ts -> ../../../shared/terms-version.mjs) and the +// cross-check test (tests/test_policy_pages.py at the repo root), which +// asserts this page names all three processors and that its "no email, no +// password" claim actually matches workers/learn-api/schema.sql. +import Layout from "../../layouts/Layout.astro"; +import PageHero from "../../components/PageHero.astro"; +import site from "../../data/site"; +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../lib/terms"; + +const description = `The Privacy Policy for ${site.orgTitle} ${site.title} (agentculture.org/learn) — what we collect, who we share it with (GitHub, Cloudflare, AWS Bedrock), and your rights.`; +--- + + + + + + +
    +
    +

    + Version {TERMS_VERSION} + + Effective {TERMS_EFFECTIVE_DATE} +

    +

    + Also see the Terms of Use, which covers acceptable use + and the tutoring tier's terms. +

    +
    +
    + +
    +
    +

    What we collect

    +

    Signing in and using Learn stores exactly:

    +
      +
    • your GitHub numeric id and your public GitHub display + name — the identity GitHub itself hands us during sign-in;
    • +
    • an append-only learning ledger: the activities you complete, + their results, the mastery level they produce, and when each happened, across + every subject you use; and
    • +
    • a session cookie that keeps you signed in between visits.
    • +
    +

    + We do not collect or store your email address. GitHub sign-in is + requested with the minimum scope needed to read your public profile; we never + request or read your email. We do not collect or store a password — + there is no Learn-specific password at all; authentication happens entirely + through GitHub's own OAuth flow. +

    + +

    Why we collect it

    +

    We use what's listed above to:

    +
      +
    • authenticate you — know it's really you signing back in, without + a password to manage;
    • +
    • track your progress across subjects — one learner, many + subjects, so your French, Spanish, and Culture Guide progress live under one + profile; and
    • +
    • personalize learning — recommend a sensible next step, and, if + you're approved for the tutoring tier, generate exercises and feedback aimed at + what you're actually weak on.
    • +
    + +

    Who we share it with

    +

    + Three outside services process your data on our behalf, and only these three: +

    +
      +
    • + GitHub — the OAuth identity provider. GitHub verifies who you + are during sign-in; Learn never sees or stores anything beyond the numeric id + and display name GitHub returns. +
    • +
    • + Cloudflare — hosting for everything: Workers (the API), KV + (session bookkeeping), D1 (the learner ledger database), and Pages (the static + site you're reading right now). Cloudflare is where your data physically lives. +
    • +
    • + AWS Bedrock — only if an admin has approved you for the + tutoring tier. Bedrock runs Nova Sonic 2 (the voice tutor) and Nova Pro (the + text tutor). If you use the approved tutoring tier, your spoken or + written practice is sent to AWS Bedrock — the model provider — so it can + generate a response. This only ever happens for the approved tier, and + only after you've separately consented to it; if you're not approved, or you + haven't consented, nothing you do reaches Bedrock. +
    • +
    + +

    Retention

    +

    + Your data is kept for as long as your account exists — there's no separate + expiry timer counting down underneath it. The moment you delete your account + (equivalently, withdraw your consent), everything tied to it is hard-deleted, + in full: your profile row, your entire learning ledger, and the consent + record itself. This is self-serve — you don't need to ask anyone, and it isn't + a soft delete or a grace period; once you confirm it, it's gone. +

    + +

    Your rights

    +

    Whenever you're signed in, you can:

    +
      +
    • access what's stored about you — your profile and your full + ledger, on demand;
    • +
    • export a complete copy of your data as JSON, to keep or take + elsewhere;
    • +
    • delete your account and every row tied to it, permanently, in + one action; and
    • +
    • withdraw consent to this policy at any time — which, per the + Retention section above, means the same thing as deleting your account, since + Learn can't keep using data it no longer has your consent to hold.
    • +
    + +

    Changes to this policy

    +

    + When this policy changes in any way that matters, we publish a new version and + bump the version number at the top of this page. A version bump forces + re-consent: the next time you sign in (or use the API/CLI), you're asked to + accept the new version before anything further is saved. Consent recorded + against one version never silently carries forward to the next. +

    + +

    Contact

    +

    + For questions, requests, or concerns about this policy, open an issue on + + github.com/agentculture/learn-cli + . We don't publish a personal contact email for privacy requests — use the + issue tracker, or the in-account export/delete actions described above for + anything you can already do yourself. +

    +
    +
    +
    + + diff --git a/site-astro/src/pages/terms/index.astro b/site-astro/src/pages/terms/index.astro new file mode 100644 index 0000000..cc2ad90 --- /dev/null +++ b/site-astro/src/pages/terms/index.astro @@ -0,0 +1,193 @@ +--- +// Terms of Use — versioned per spec #11 (plan task t1). Plain prose page, +// same Layout/PageHero shell as every other page here; no new design +// tokens. TERMS_VERSION/TERMS_EFFECTIVE_DATE come from src/lib/terms.ts (a +// re-export of ../../../shared/terms-version.mjs), the one place both this +// site and workers/learn-api read the published version from — bumping it +// is what forces re-consent (t6). tests/test_policy_pages.py (repo root) +// cross-checks this page's wiring and copy against that shared source and +// against workers/learn-api/schema.sql. +import Layout from "../../layouts/Layout.astro"; +import PageHero from "../../components/PageHero.astro"; +import site from "../../data/site"; +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../lib/terms"; + +const description = `The Terms of Use for ${site.orgTitle} ${site.title} (agentculture.org/learn) — GitHub-based accounts, acceptable use, and the approved AI-tutoring tier.`; +--- + + + + + + +
    +
    +

    + Version {TERMS_VERSION} + + Effective {TERMS_EFFECTIVE_DATE} +

    +

    + Also see the Privacy Policy, which covers what we + collect and who we share it with. +

    +
    +
    + +
    +
    +

    1. Accounts

    +

    + Learn accounts are GitHub-based only. There is no separate username or + password to create or remember — signing in runs GitHub's own OAuth flow, + and the account it creates here is identified by your GitHub numeric id and + your public GitHub display name. You're responsible for whatever GitHub + account you sign in with. +

    + +

    2. Acceptable use

    +

    Use Learn to learn. In particular, don't:

    +
      +
    • try to bypass the consent step, the admin approval gate, or any other access control;
    • +
    • probe, scrape, or overload the site or the API beyond ordinary study use;
    • +
    • submit content to the tutoring tier that you don't have the right to share, or that's + abusive, illegal, or intended to misuse the underlying AI model; or
    • +
    • impersonate another learner or misrepresent your identity beyond what GitHub already + reports.
    • +
    + +

    3. The service is provided "as is"

    +

    + Learn is an open-source, community-run project. It's provided "as is," without + warranty of any kind — we don't guarantee it will always be available, error-free, + or fit for any particular purpose. Lesson and story content, and anything the + tutoring tier generates, may contain mistakes; verify anything that matters before + you rely on it. +

    + +

    4. The approved AI-tutoring tier

    +

    + Some learners are approved by an admin for the tutoring tier: a Nova Pro-graded + text tutor and a Nova Sonic 2 voice session, both running on AWS Bedrock. This + tier is opt-in on the admin's side (it costs real money to run) and, separately, + requires your own consent before any of your speech or text reaches the model + provider — see the Privacy Policy for exactly what that + means. Nothing the AI tutor says is guaranteed accurate; it's a study aid, not an + authority, and grading/feedback it gives is a best-effort estimate, not a + certification. +

    + +

    5. Your data is yours

    +

    + Everything Learn stores about you — your profile row and your learning ledger + (the record of activities, results, and mastery you've built up) — belongs to + you. You can export a full copy or delete all of it, permanently, at any time + from your account settings; deleting your account also withdraws your consent + to these Terms and to the Privacy Policy. See the Privacy Policy for the + mechanics and the retention rule. +

    + +

    6. Termination and suspension

    +

    + You can stop using Learn and delete your account whenever you like — that's + always available and always immediate. We may suspend or terminate access for + an account that violates Section 2 (acceptable use), including revoking + tutoring-tier approval independently of the underlying account. Where practical + we'll try to give notice first; for clear abuse (for example, hammering the API + or attempting to bypass the approval gate) we may act without warning. +

    + +

    7. Changes to these terms

    +

    + When these Terms change in any way that matters, we publish a new version and + bump the version number at the top of this page. A version bump means the next + time you sign in (or use the API/CLI) you're asked to re-consent before + anything further is saved — your previous consent covered the version you + accepted, not whatever comes after it. Consent recorded against a given version + is never silently carried forward to the next one. +

    + +

    8. Contact and governing law

    +

    + Learn is maintained by AgentCulture's agents and human maintainers as an + open-source project, not a registered company — so we're keeping this section + simple rather than pretending otherwise. Questions, disputes, or requests about + these Terms should be opened as an issue on + + github.com/agentculture/learn-cli + — we don't publish a personal contact email for this. Absent a more + specific agreement, these Terms are interpreted under the same open-source, + good-faith norms as the rest of the AgentCulture project. +

    +
    +
    +
    + + diff --git a/tests/test_policy_pages.py b/tests/test_policy_pages.py new file mode 100644 index 0000000..a4737ae --- /dev/null +++ b/tests/test_policy_pages.py @@ -0,0 +1,247 @@ +"""Terms of Use + Privacy Policy — the versioned /learn-local pages (t1). + +Cross-checks the policy pages' *claims* against what the rest of the repo +actually does, rather than trusting the prose: + +* the published TERMS_VERSION/TERMS_EFFECTIVE_DATE live in exactly ONE place + (``shared/terms-version.mjs``) and both the Astro site and the Worker + re-export from that same path, never a hardcoded copy; +* both policy pages render the version by importing those constants, not by + spelling the version number out themselves — so what's rendered cannot + drift from the shared source (spec #11, honesty condition h9); +* the Privacy Policy names all three data processors the code actually + calls (GitHub, Cloudflare, AWS Bedrock) and states the Bedrock disclosure + explicitly (speech/text leaves to the model provider, approved tier only); +* the Privacy Policy's "no email, no password" claim is checked against + ``workers/learn-api/schema.sql`` itself, not just against the prose next to + it — if a future migration ever added an email/password column, this test + would fail even though nobody touched this file. + +Deliberately pure file reads (like ``tests/conftest.py``'s fixtures and +``tests/test_site_export.py``) — no ``npm``/``astro build``/Node subprocess, +since the CI ``test`` job (``uv run pytest``, see ``.github/workflows/tests.yml``) +never provisions Node. ``site-astro/scripts/check-export-pages.mjs`` and +``check-static-auth.mjs`` (run via ``npm run check`` after ``npm run build``, +wired into ``deploy-site.yml``) are the complementary build-time proof that +the pages actually compile and stay consistent with the rest of the site. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SHARED_TERMS = ROOT / "shared" / "terms-version.mjs" +WORKER_TERMS = ROOT / "workers" / "learn-api" / "src" / "terms.js" +SITE_TERMS = ROOT / "site-astro" / "src" / "lib" / "terms.ts" +TERMS_PAGE = ROOT / "site-astro" / "src" / "pages" / "terms" / "index.astro" +PRIVACY_PAGE = ROOT / "site-astro" / "src" / "pages" / "privacy" / "index.astro" +SCHEMA = ROOT / "workers" / "learn-api" / "schema.sql" +FOOTER = ROOT / "site-astro" / "src" / "components" / "Footer.astro" + +REEXPORT_PATH = "../../../shared/terms-version.mjs" + + +def _read(path: Path) -> str: + assert path.is_file(), f"expected {path} to exist" + return path.read_text(encoding="utf-8") + + +def _shared_terms() -> dict[str, str]: + text = _read(SHARED_TERMS) + version = re.search(r'export const TERMS_VERSION = "([^"]+)";', text) + date = re.search(r'export const TERMS_EFFECTIVE_DATE = "([^"]+)";', text) + assert version, "shared/terms-version.mjs must export TERMS_VERSION" + assert date, "shared/terms-version.mjs must export TERMS_EFFECTIVE_DATE" + return {"version": version.group(1), "date": date.group(1)} + + +# --- the single source of truth ------------------------------------------------ + + +def test_shared_terms_version_starts_at_1_0_0_dated_today() -> None: + terms = _shared_terms() + assert terms["version"] == "1.0.0" + assert terms["date"] == "2026-07-11" + + +def test_shared_terms_version_is_semver() -> None: + assert re.match(r"^\d+\.\d+\.\d+$", _shared_terms()["version"]) + + +def test_worker_reexports_the_shared_source_not_a_copy() -> None: + text = _read(WORKER_TERMS) + assert REEXPORT_PATH in text, "workers/learn-api/src/terms.js must re-export the shared file" + assert '"1.0.0"' not in text, "the worker wrapper must not hardcode the version itself" + + +def test_site_reexports_the_shared_source_not_a_copy() -> None: + text = _read(SITE_TERMS) + assert REEXPORT_PATH in text, "site-astro/src/lib/terms.ts must re-export the shared file" + assert '"1.0.0"' not in text, "the site wrapper must not hardcode the version itself" + + +# --- both pages render the version by importing it, not by copying it --------- + + +def _assert_page_imports_terms(page: Path) -> None: + text = _read(page) + import_re = ( + r"import\s*\{\s*TERMS_VERSION\s*,\s*TERMS_EFFECTIVE_DATE\s*\}" + r'\s*from\s*"\.\./\.\./lib/terms"' + ) + assert re.search( + import_re, text + ), f"{page.name} must import TERMS_VERSION/TERMS_EFFECTIVE_DATE from ../../lib/terms" + # The literal version string must appear nowhere in the page source — if + # it did, the page could drift from shared/terms-version.mjs by editing + # only one of the two files. + assert "1.0.0" not in text + + +def test_terms_page_renders_the_version_via_the_shared_import() -> None: + _assert_page_imports_terms(TERMS_PAGE) + text = _read(TERMS_PAGE) + assert "{TERMS_VERSION}" in text + assert "{TERMS_EFFECTIVE_DATE}" in text + assert "Version" in text and "Effective" in text + + +def test_privacy_page_renders_the_version_via_the_shared_import() -> None: + _assert_page_imports_terms(PRIVACY_PAGE) + text = _read(PRIVACY_PAGE) + assert "{TERMS_VERSION}" in text + assert "{TERMS_EFFECTIVE_DATE}" in text + assert "Version" in text and "Effective" in text + + +def test_rendered_version_matches_the_shared_source_when_built() -> None: + """If `npm run build` has produced dist/, the literal version string must + appear in both built pages — the strongest available proof once a build + exists. Skips gracefully when dist/ hasn't been built (the normal case + in the Python test job, which never runs Node — see module docstring).""" + terms = _shared_terms() + dist = ROOT / "site-astro" / "dist" + terms_html = dist / "terms" / "index.html" + privacy_html = dist / "privacy" / "index.html" + if not terms_html.is_file() or not privacy_html.is_file(): + return + for built in (terms_html, privacy_html): + html = built.read_text(encoding="utf-8") + assert terms["version"] in html + assert terms["date"] in html + + +# --- footer links both pages --------------------------------------------------- + + +def test_footer_links_terms_and_privacy() -> None: + text = _read(FOOTER) + assert "terms/" in text + assert "privacy/" in text + + +# --- Privacy Policy: processors, collected data, retention, rights ------------ + + +def test_privacy_policy_names_all_three_processors() -> None: + text = _read(PRIVACY_PAGE) + assert "GitHub" in text + assert "Cloudflare" in text + assert "AWS Bedrock" in text + + +def test_privacy_policy_calls_out_bedrock_speech_text_disclosure() -> None: + text = _read(PRIVACY_PAGE) + assert "Nova Sonic 2" in text + assert "Nova Pro" in text + # The explicit "your data leaves to the model provider" callout, scoped + # to the approved tier only (spec requirement, honesty condition h9). + assert "sent to AWS Bedrock" in text + assert "approved" in text.lower() + + +def test_privacy_policy_states_what_is_collected() -> None: + text = _read(PRIVACY_PAGE) + assert "GitHub numeric id" in text + assert "display name" in text + assert "ledger" in text.lower() + assert "mastery" in text.lower() + assert "session cookie" in text.lower() + + +def test_privacy_policy_explicitly_disclaims_email_and_password() -> None: + text = _read(PRIVACY_PAGE) + assert "do not collect or store your email" in text.lower() + assert "do not collect or store a password" in text.lower() + + +def test_privacy_policy_states_why_data_is_collected() -> None: + text = _read(PRIVACY_PAGE) + lowered = text.lower() + assert "authenticate" in lowered + assert "progress across subjects" in lowered + assert "personalize" in lowered + + +def test_privacy_policy_states_retention() -> None: + text = _read(PRIVACY_PAGE).lower() + assert "kept for as long as your account exists" in text + assert "hard-deleted" in text + + +def test_privacy_policy_states_the_learner_rights() -> None: + text = _read(PRIVACY_PAGE).lower() + for right in ("access", "export", "delete", "withdraw consent"): + assert right in text, f"Privacy Policy must state the right to {right}" + + +def test_privacy_policy_contact_point_is_the_issue_tracker_not_an_email() -> None: + text = _read(PRIVACY_PAGE) + assert "github.com/agentculture/learn-cli" in text + assert "@" not in text, "no personal email address should be published in the policy" + + +# --- Terms of Use: the decisions recorded in the plan (risk r5) --------------- + + +def test_terms_of_use_covers_the_recorded_drafting_decisions() -> None: + text = _read(TERMS_PAGE) + lowered = text.lower() + assert "github" in lowered # GitHub-based accounts + assert "as is" in lowered # service provided "as is" + assert "approved" in lowered and ("bedrock" in lowered or "tutoring" in lowered) + assert "belongs to you" in lowered or "yours" in lowered # data ownership + assert "termination" in lowered or "suspend" in lowered # termination/suspension + assert "re-consent" in lowered # changes -> version bump -> re-consent + assert "github.com/agentculture/learn-cli" in text # contact point + + +def test_terms_of_use_contact_point_is_the_issue_tracker_not_an_email() -> None: + text = _read(TERMS_PAGE) + assert "@" not in text, "no personal email address should be published in the terms" + + +# --- the schema itself backs the "no email, no password" claim ---------------- + + +def test_schema_has_no_email_or_password_column_anywhere() -> None: + # Strip `--` comment lines first: schema.sql's own header comment SAYS + # "no password, no email" in prose, which would otherwise trip a naive + # whole-file substring check on the very sentence declaring the + # invariant. What must actually be absent is a COLUMN, not the word. + code_only = "\n".join( + line for line in _read(SCHEMA).lower().splitlines() if not line.strip().startswith("--") + ) + assert "email" not in code_only + assert "password" not in code_only + + +def test_schema_learners_table_columns_have_no_email_or_password() -> None: + schema = _read(SCHEMA) + match = re.search(r"CREATE TABLE IF NOT EXISTS learners \((.*?)\n\);", schema, re.S) + assert match, "expected a `learners` table definition in schema.sql" + columns = match.group(1).lower() + assert "email" not in columns + assert "password" not in columns diff --git a/workers/learn-api/src/terms.js b/workers/learn-api/src/terms.js new file mode 100644 index 0000000..0bb0f11 --- /dev/null +++ b/workers/learn-api/src/terms.js @@ -0,0 +1,13 @@ +// Worker-side re-export of the shared Terms/Privacy version. This file +// exists (rather than every worker module reaching across the repo boundary +// itself) so worker code always imports from a local `./terms.js` path, +// matching the shape of every other `src/*.js` module here — the fact that +// the value actually lives in ../../../shared/terms-version.mjs is an +// implementation detail of this one file. +// +// See ../../../shared/terms-version.mjs for why that file is a plain ES +// module and not JSON, and for the re-consent contract TERMS_VERSION backs +// (plan task t6, spec honesty condition h2). Not wired into any route yet — +// this task (t1) only establishes the shared source; recording/consulting +// `consent.terms_version` is t5/t6's job. +export { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../../shared/terms-version.mjs"; diff --git a/workers/learn-api/test/terms.test.js b/workers/learn-api/test/terms.test.js new file mode 100644 index 0000000..266e72d --- /dev/null +++ b/workers/learn-api/test/terms.test.js @@ -0,0 +1,23 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../src/terms.js"; + +// Proves the worker side of the "single source of truth" design (spec #11, +// plan task t1): src/terms.js resolves through to shared/terms-version.mjs +// under Node's own module loader — the same loader `node --test` uses for +// every other file in this suite, no bundler involved. The Astro site's +// half of this same proof lives in the Python cross-check +// (tests/test_policy_pages.py at the repo root), which reads +// shared/terms-version.mjs directly since there's no Node toolchain in the +// Python test job. + +test("TERMS_VERSION resolves through to the shared source", () => { + assert.equal(TERMS_VERSION, "1.0.0"); + assert.match(TERMS_VERSION, /^\d+\.\d+\.\d+$/, "expected a semver string"); +}); + +test("TERMS_EFFECTIVE_DATE is an ISO-8601 date matching the version", () => { + assert.equal(TERMS_EFFECTIVE_DATE, "2026-07-11"); + assert.match(TERMS_EFFECTIVE_DATE, /^\d{4}-\d{2}-\d{2}$/); +}); From 750d9a6218028f55121b3a8e5db36bbede4358e3 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 10:24:49 +0300 Subject: [PATCH 04/24] feat(contract): add pick-the-right-word cloze exercises (t3, c16/h8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing `cloze` exercise type (unchanged since contract 1.0) with two OPTIONAL fields — `text` (passage with `{{blank_id}}` placeholders) and `blanks` ({id, options, answer}) — for a multi-blank, closed-option variant a reader can check without a driver or model call. The legacy single-blank free-text cloze shape (prompt + answer) is untouched. - learn/contract/schemas/{practice,story}.json: additive `cloze_blank` $def + exercise.text/blanks, open payloads so nothing predating them breaks. - learn/subjects/conformance.py: new `cloze-items` doctor check — reads every declared story and verifies well-formed blanks (options include the answer, blank ids unique + match text placeholders 1:1, item_id present), passing trivially for subjects that declare none. - No changes needed to record.json or workers/learn-api/src/validate.js: `recorded` carries no exercise-shape field, so a cloze result (its multi-blank tally lands in the pre-existing correct/total counters) was already contract-valid — proven by new tests, not just asserted. - site-astro: renders a pick-the-right-word cloze passage as inline button groups per blank with instant right/wrong feedback, entirely client-side (wireClozeExercises() in learner.js, zero fetch calls, works signed-out). - tests/test_site_export.py locks the export byte-stable for a subject declaring no cloze items (acceptance criterion 5). docs/specs/subject-plugin-contract.md §3.6.1 documents the shape and the least-invasive design decisions for the subject-CLI follow-up tasks (french-cli, spanish-cli, culture-guide) to author against. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- docs/specs/subject-plugin-contract.md | 118 ++++++++- learn/contract/schemas/practice.json | 35 ++- learn/contract/schemas/story.json | 33 +++ learn/subjects/conformance.py | 181 +++++++++++++- site-astro/src/lib/content.ts | 15 ++ .../pages/[subject]/stories/[id]/index.astro | 164 ++++++++++++- site-astro/src/scripts/learner.js | 44 ++++ tests/conftest.py | 14 ++ .../fixtures/subjects/cloze_broken_subject.py | 213 ++++++++++++++++ .../subjects/cloze_conformant_subject.py | 231 ++++++++++++++++++ tests/test_site_export.py | 41 ++++ tests/test_subject_doctor.py | 161 +++++++++++- workers/learn-api/src/validate.js | 9 + workers/learn-api/test/validate.test.js | 37 +++ 14 files changed, 1276 insertions(+), 20 deletions(-) create mode 100755 tests/fixtures/subjects/cloze_broken_subject.py create mode 100755 tests/fixtures/subjects/cloze_conformant_subject.py diff --git a/docs/specs/subject-plugin-contract.md b/docs/specs/subject-plugin-contract.md index 0012c19..6c5d9eb 100644 --- a/docs/specs/subject-plugin-contract.md +++ b/docs/specs/subject-plugin-contract.md @@ -129,7 +129,118 @@ plus the directive (optionally with a `persona`). Item ids in the lesson are exa A batch of exercises scoped to an item, a module, or `review` (no argument: the subject picks its weakest touched items). Exercise types: `multiple_choice`, `true_false`, `cloze`, `short_answer`, `translation`, `open`, `discussion`. Checkable types carry `answer`; open types carry `rubric` -(what passes, what is partial). +(what passes, what is partial). `cloze` has two variants — §3.6.1. + +#### 3.6.1 The `cloze` exercise type: two variants, one `type` value + +`cloze` (fill-in-the-blank) has shipped since contract 1.0 as a **single-blank, free-text** +exercise: `prompt` writes the blank as `___` and a top-level `answer` is the expected string, +graded conversationally by the driver exactly like `short_answer`/`translation`. That variant is +**unchanged** — it is still valid, still renders the same, and needs no update from any subject. + +t3 adds a second, richer variant to the same `type: "cloze"` value: **pick-the-right-word**, a +passage with one or more blanks where the learner (or an unauthenticated site reader) picks from a +closed set of options per blank, checkable without a driver or a model call. It is carried by two +new, OPTIONAL exercise fields — additive to contract family 1.0, so every payload that predates +them keeps validating unchanged (open payloads, §2): + +| Field | Type | Meaning | +| --- | --- | --- | +| `text` | string | The passage, with each blank marked as a `{{blank_id}}` placeholder. | +| `blanks` | array | One entry per placeholder in `text`: `{id, options, answer}`. | + +Each `blanks[]` entry: + +| Field | Req | Meaning | +| --- | --- | --- | +| `id` | ✓ | Matches one `{{id}}` placeholder in `text`; unique within the exercise. | +| `options` | ✓ | ≥2 words the reader picks from: the correct word plus one or more distractors. Must include `answer`. | +| `answer` | ✓ | The correct option for this blank; must be one of `options`. | + +`text` and `blanks` are always present **together** — an exercise with only one of them is +malformed. An exercise is the pick-the-right-word variant if and only if it carries `text` or +`blanks`; an exercise with neither is the legacy single-blank form, untouched by anything below. + +**Design decision — least invasive shape (spec c16/h8):** + +- **No new exercise `type`.** `cloze` already existed and already meant "a fill-in-the-blank"; + pick-the-right-word is a richer *shape* of the same type, not a new kind. A driver/site that + doesn't understand `text`/`blanks` yet can still fall back to `prompt`/`answer` (both remain + present on a well-authored pick-the-right-word item, as the driver-facing instruction), since + every field addition here is optional. +- **No new activity.** `record`'s `recorded.activity` stays `lesson | practice | story` — a cloze + item's result is recorded exactly like any other exercise's, via whichever activity hosted it + (typically `practice` for a practice-batch cloze item, `story` for a comprehension cloze item). + `record.json`'s `recorded` object carries no `type`/exercise-shape field at all, so it was + **already** forward-compatible with cloze before this change — this is why neither the Python + contract validator nor the worker's `validate.js` (`workers/learn-api/src/validate.js`) needed + any code change for acceptance criterion 2. What DOES change: a multi-blank cloze result tallies + naturally into the pre-existing `recorded.correct`/`recorded.total` counters (already part of + `record.json`) — e.g. two blanks, one right, records `--correct 1 --total 2`. No new field. +- **`item_id` rules are unchanged.** A pick-the-right-word cloze exercise carries `item_id` exactly + like every other exercise type: the join key `record --item` expects, the same string other + exercises MAY legitimately reuse when they evidence the same curriculum item. This contract does + **not** require cloze `item_id`s to be globally unique — only present. What `learn subject + doctor` (below) does additionally require unique is the exercise's own `id` (its slug, e.g. + `"fr-p1-b1"`) among the subject's declared pick-the-right-word cloze items, since that id is what + a driver/site addresses a specific cloze instance by; `item_id` (the mastery join key) keeps its + existing, unrestricted reuse semantics. +- **Marker syntax is `{{blank_id}}`**, chosen for being unambiguous in both markdown-flavored + `body`/`text` content and plain prose, and trivial to parse with one regex + (`` /\{\{([^{}]+)\}\}/ ``) in Python, JavaScript, or Astro's build-time templating — no parser + dependency added anywhere in the pipeline. + +**Example** (a `practice` exercise; the same shape is legal inside a `story`'s `exercises`): + +```json +{ + "id": "fr-p1-b1", + "type": "cloze", + "item_id": "numbers-money", + "prompt": "Fill in each blank with the right word.", + "text": "Je vais au marché pour acheter {{qty}} pommes.", + "blanks": [ + { "id": "qty", "options": ["trois", "gris", "trop"], "answer": "trois" } + ] +} +``` + +Recording the result once both blanks are graded (one blank here, so `--total 1`): + +```bash +french record --learner ori --item numbers-money --activity practice \ + --exercise fr-p1-b1 --result pass --correct 1 --total 1 --json +``` + +**Validation split** (structural vs. semantic — the mini JSON-Schema validator, §8, only checks +the former): + +- **Schema-checkable** (`learn.contract.validate` / a subject's own schema validation): `text` is + a non-empty string; `blanks` is a non-empty array; each blank is an object with `id` (pattern + `^[a-z0-9][a-z0-9._-]*$`), `options` (≥2 non-empty strings), and `answer` (non-empty string) — + all required. +- **Semantic, checked by `learn subject doctor`'s `cloze-items` check** (not expressible in the + stdlib validator's supported keyword subset, §8): every blank's `answer` is one of its own + `options`; blank `id`s are unique within the exercise and match a `{{id}}` placeholder in `text` + 1:1 (no orphan placeholder, no blank without one); the exercise carries a non-empty `item_id`; + the exercise's own `id` is unique among the subject's other declared pick-the-right-word cloze + items. The check reads every story via `story read` (the one exception to "runtime gate stays + read-only" in `learn/subjects/conformance.py` — still side-effect-free against the dedicated + probe learner) and inspects each `type: "cloze"` exercise that carries `text` or `blanks`. **A + subject that declares none passes trivially** — this never penalizes pre-cloze content or a + subject using only the legacy single-blank form, satisfying the "existing subject exports + identically" requirement (spec h8). + +**Rendering (`learn site export` + site-astro):** the exporter (`learn/front/_export.py`) needed +**no code change** — it already writes each subject's `story read` output verbatim, so `text` and +`blanks` pass through untouched the moment a subject starts emitting them. `site-astro`'s story +reader page renders a pick-the-right-word cloze exercise (`type: "cloze"` with a non-empty +`blanks` array) as the passage with one button group per blank (one button per option); clicking +an option marks it right/wrong immediately, entirely client-side, against the `answer` already +present in the (public) exported JSON — no fetch, no sign-in required, matching the zero-API-when- +signed-out invariant the rest of the site enforces. A legacy single-blank cloze exercise (no +`blanks`) keeps rendering exactly as before (plain prompt, no picker). See +`site-astro/src/scripts/learner.js`'s `wireClozeExercises()`. ### 3.7 `record` — the write-back (the motivation layer's input) @@ -160,8 +271,9 @@ Result inference default (culture-guide's proven mapping): `fail → introduced` Health checks in the established doctor shape (`{id, passed, severity, message, remediation}`) plus **`contract_version`** — the contract version this subject pins. `learn subject doctor` (t3) reads the pin first, then validates the other seven verbs' payloads against that version's -schemas. Exit 0 when healthy, 2 when not. Recommended checks: content files validate against -`story.json`, learner-state dir writable, pinned contract version supported. +schemas — plus, since t3, a `cloze-items` check verifying every declared pick-the-right-word cloze +exercise (§3.6.1). Exit 0 when healthy, 2 when not. Recommended checks: content files validate +against `story.json`, learner-state dir writable, pinned contract version supported. ## 4. Shared vocabularies diff --git a/learn/contract/schemas/practice.json b/learn/contract/schemas/practice.json index e2ce2e7..6ea6aa4 100644 --- a/learn/contract/schemas/practice.json +++ b/learn/contract/schemas/practice.json @@ -50,7 +50,40 @@ "items": { "type": "string" } }, "answer": { "type": "string" }, - "rubric": { "type": "string" } + "rubric": { "type": "string" }, + "text": { + "type": "string", + "minLength": 1, + "description": "For the pick-the-right-word cloze variant ONLY: the passage with each blank marked as a `{{blank_id}}` placeholder, one per entry in `blanks`. Omit for a single-blank free-text cloze exercise (use `prompt` with the blank written as `___`, plus a top-level `answer`, exactly as contract 1.0 always allowed) and for every non-cloze type." + }, + "blanks": { + "type": "array", + "minItems": 1, + "description": "For the pick-the-right-word cloze variant ONLY: one entry per `{{blank_id}}` placeholder in `text`, always present together with `text`. Absent for a single-blank free-text cloze exercise and for every non-cloze type.", + "items": { "$ref": "#/$defs/cloze_blank" } + } + } + }, + "cloze_blank": { + "type": "object", + "required": ["id", "options", "answer"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Matches one `{{id}}` placeholder in the exercise's `text`; unique within the exercise." + }, + "options": { + "type": "array", + "minItems": 2, + "items": { "type": "string", "minLength": 1 }, + "description": "The words the reader picks from for this blank: the correct word plus one or more distractors. Must include `answer`." + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "The correct option for this blank; must be one of `options`." + } } }, "directive": { diff --git a/learn/contract/schemas/story.json b/learn/contract/schemas/story.json index 8ef2d24..ea7ca91 100644 --- a/learn/contract/schemas/story.json +++ b/learn/contract/schemas/story.json @@ -124,6 +124,39 @@ "rubric": { "type": "string", "description": "Grading guidance for open-ended exercises: what passes, what is partial." + }, + "text": { + "type": "string", + "minLength": 1, + "description": "For the pick-the-right-word cloze variant ONLY: the passage with each blank marked as a `{{blank_id}}` placeholder, one per entry in `blanks`. Omit for a single-blank free-text cloze exercise (use `prompt` with the blank written as `___`, plus a top-level `answer`, exactly as contract 1.0 always allowed) and for every non-cloze type." + }, + "blanks": { + "type": "array", + "minItems": 1, + "description": "For the pick-the-right-word cloze variant ONLY: one entry per `{{blank_id}}` placeholder in `text`, always present together with `text`. Absent for a single-blank free-text cloze exercise and for every non-cloze type.", + "items": { "$ref": "#/$defs/cloze_blank" } + } + } + }, + "cloze_blank": { + "type": "object", + "required": ["id", "options", "answer"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Matches one `{{id}}` placeholder in the exercise's `text`; unique within the exercise." + }, + "options": { + "type": "array", + "minItems": 2, + "items": { "type": "string", "minLength": 1 }, + "description": "The words the reader picks from for this blank: the correct word plus one or more distractors. Must include `answer`." + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "The correct option for this blank; must be one of `options`." } } } diff --git a/learn/subjects/conformance.py b/learn/subjects/conformance.py index 1c57e3c..a4b0b18 100644 --- a/learn/subjects/conformance.py +++ b/learn/subjects/conformance.py @@ -19,24 +19,40 @@ * ``verb-overview`` / ``verb-progress`` / ``verb-advice`` / ``verb-story-list`` — each read-only verb responds with a schema-valid, version-compatible payload; +* ``cloze-items`` — every pick-the-right-word cloze exercise (``type: "cloze"`` + carrying ``text``/``blanks``, see ``docs/specs/subject-plugin-contract.md`` + §3.6.1) declared anywhere in the subject's stories is well-formed: each blank + has >=2 options that include its answer, blank ids are unique within the + exercise and match a ``{{blank_id}}`` placeholder in ``text`` 1:1, and the + exercise carries a non-empty ``item_id``. A subject with no such items passes + trivially — this check never penalizes pre-cloze content; * ``error-contract`` — a deliberately bad invocation exits non-zero with the ``{code, message, remediation}`` shape on stderr, an empty stdout, and an exit code equal to the payload's ``code``. -Mutating/learner-authoring verbs (``lesson``, ``practice``, ``record``, -``story read``) are validated by golden payloads in each subject repo's own CI; -the runtime gate stays read-only and safe to run against any learner state. +Mutating/learner-authoring verbs (``lesson``, ``practice``, ``record``) are +validated by golden payloads in each subject repo's own CI; the runtime gate +stays read-only. ``story read`` is the one exception: since ``cloze-items`` +needs each story's full exercise bodies (not just ``story list``'s summaries) +to verify declared blanks, this module additionally reads every listed story — +still read-only and safe against any learner state (``story read`` may only +advance the probe learner's own reading position, per the contract's state +table), driven with the same dedicated :data:`PROBE_LEARNER`. """ from __future__ import annotations import json +import re import subprocess # nosec B404 - driving subjects as subprocesses is the design from typing import Any from learn.contract import CONTRACT_VERSION, validate from learn.subjects import SubjectEntry, resolve_executable +#: Matches a `{{blank_id}}` placeholder inside a cloze exercise's `text`. +_BLANK_PLACEHOLDER_RE = re.compile(r"\{\{([^{}]+)\}\}") + #: Learner id used for learner-scoped probes. Read-only verbs never mutate #: state, so this never touches a real learner's ledger. PROBE_LEARNER = "learn-conformance-probe" @@ -229,6 +245,159 @@ def _probe_error_contract(exe: str, entry: SubjectEntry, timeout: float) -> dict ) +def _is_cloze_blanks_item(exercise: Any) -> bool: + """True for the pick-the-right-word cloze variant (has ``text`` or ``blanks``). + + A legacy single-blank free-text cloze exercise (``type: "cloze"`` with only + ``prompt``/``answer``, no ``text``/``blanks``) is NOT this variant — it keeps + validating and rendering exactly as contract 1.0 always allowed, untouched + by this check. + """ + return ( + isinstance(exercise, dict) + and exercise.get("type") == "cloze" + and ("text" in exercise or "blanks" in exercise) + ) + + +def _validate_cloze_exercise(story_id: str, exercise: dict[str, Any]) -> list[str]: + """Semantic checks the mini JSON-Schema validator can't express: cross-field + rules (an option list contains its own answer, blank ids are unique and + match `text`'s placeholders 1:1). Structural shape (types, minItems, ...) is + already covered by :func:`learn.contract.validate` against the schema. + """ + errors: list[str] = [] + exercise_id = exercise.get("id", "?") + prefix = f"story '{story_id}' exercise '{exercise_id}'" + + text = exercise.get("text") + blanks = exercise.get("blanks") + if text is None or blanks is None: + errors.append(f"{prefix}: a pick-the-right-word cloze item needs BOTH `text` and `blanks`") + return errors + if not isinstance(text, str) or not text.strip(): + errors.append(f"{prefix}: `text` must be a non-empty string") + if not exercise.get("item_id"): + errors.append(f"{prefix}: missing `item_id` (the join key `record --item` expects)") + if not isinstance(blanks, list) or not blanks: + errors.append(f"{prefix}: `blanks` must be a non-empty array") + return errors + + placeholder_ids = set(_BLANK_PLACEHOLDER_RE.findall(text)) if isinstance(text, str) else set() + seen_ids: set[str] = set() + for i, blank in enumerate(blanks): + bpath = f"{prefix} blanks[{i}]" + if not isinstance(blank, dict): + errors.append(f"{bpath}: must be an object") + continue + bid = blank.get("id") + options = blank.get("options") + answer = blank.get("answer") + if not isinstance(bid, str) or not bid: + errors.append(f"{bpath}: missing/empty `id`") + elif bid in seen_ids: + errors.append(f"{bpath}: duplicate blank id '{bid}' within this exercise") + else: + seen_ids.add(bid) + if bid not in placeholder_ids: + errors.append( + f"{bpath}: id '{bid}' has no matching {{{{{bid}}}}} placeholder in `text`" + ) + if not isinstance(options, list) or len(options) < 2: + errors.append(f"{bpath}: `options` must list at least 2 words") + if not isinstance(answer, str) or not answer: + errors.append(f"{bpath}: missing/empty `answer`") + elif isinstance(options, list) and answer not in options: + errors.append(f"{bpath}: `answer` {answer!r} is not among its own `options`") + + for orphan in sorted(placeholder_ids - seen_ids): + errors.append( + f"{prefix}: `text` placeholder {{{{{orphan}}}}} has no matching `blanks` entry" + ) + return errors + + +def _probe_cloze_items( + exe: str, + entry: SubjectEntry, + story_list_payload: dict[str, Any] | None, + timeout: float, +) -> dict[str, Any]: + """Verify every pick-the-right-word cloze exercise declared in any story. + + Reads each story ``story list`` named (via ``story read``) and checks every + cloze exercise carrying ``text``/``blanks`` for well-formedness. A subject + that declares none passes trivially — this never penalizes subjects that + haven't shipped cloze content yet (or use only the legacy free-text form). + """ + cid = "cloze-items" + remediation = ( + "fix the cloze item: `text` and `blanks` must both be present, each blank needs a " + "unique `id` matching one `{{id}}` placeholder in `text`, >=2 `options`, and an " + "`answer` that is one of those `options`" + ) + stories = story_list_payload.get("stories") if isinstance(story_list_payload, dict) else None + if not isinstance(stories, list): + # story-list itself failed/was unavailable — that's `verb-story-list`'s + # failure to report, not this check's; nothing to verify here. + return _check(cid, True, "story list unavailable; skipped cloze verification") + + errors: list[str] = [] + first_story_for_exercise_id: dict[str, str] = {} + found = 0 + for summary in stories: + story_id = summary.get("id") if isinstance(summary, dict) else None + if not isinstance(story_id, str) or not story_id: + continue + result = _run(exe, entry, ("story", "read", story_id), learner=True, timeout=timeout) + if isinstance(result, str): + errors.append(f"story '{story_id}': could not read to verify cloze items ({result})") + continue + rc, out, _err = result + if rc != 0: + errors.append( + f"story '{story_id}': story read exited {rc}; could not verify cloze items" + ) + continue + try: + payload = json.loads(out) + except json.JSONDecodeError: + errors.append(f"story '{story_id}': story read did not emit valid JSON") + continue + story = payload.get("story") if isinstance(payload, dict) else None + exercises = story.get("exercises") if isinstance(story, dict) else None + if not isinstance(exercises, list): + continue + for exercise in exercises: + if not _is_cloze_blanks_item(exercise): + continue + found += 1 + errors.extend(_validate_cloze_exercise(story_id, exercise)) + exercise_id = exercise.get("id") + if isinstance(exercise_id, str) and exercise_id: + prior = first_story_for_exercise_id.get(exercise_id) + if prior is not None and prior != story_id: + errors.append( + f"cloze exercise id '{exercise_id}' is reused in both '{prior}' and " + f"'{story_id}' — exercise ids must be unique" + ) + else: + first_story_for_exercise_id.setdefault(exercise_id, story_id) + + if found == 0: + return _check(cid, True, "no pick-the-right-word cloze items declared (nothing to verify)") + if errors: + return _check( + cid, + False, + f"{errors[0]} ({len(errors)} issue(s) total across {found} cloze item(s))", + remediation=remediation, + ) + return _check( + cid, True, f"{found} pick-the-right-word cloze item(s) verified: well-formed blanks" + ) + + def run_conformance(entry: SubjectEntry, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: """Drive ``entry``'s verbs and return the ``subject_doctor`` payload. @@ -256,12 +425,16 @@ def run_conformance(entry: SubjectEntry, *, timeout: float = DEFAULT_TIMEOUT) -> doctor_checks, _pin = _probe_doctor(exe, entry, timeout) checks.extend(doctor_checks) + story_list_payload: dict[str, Any] | None = None for cid, verb, schema, learner in _READONLY_PROBES: - check, _payload_out = _probe_verb( + check, payload_out = _probe_verb( exe, entry, cid, verb, schema, learner=learner, timeout=timeout ) checks.append(check) + if cid == "verb-story-list" and check["passed"]: + story_list_payload = payload_out + checks.append(_probe_cloze_items(exe, entry, story_list_payload, timeout)) checks.append(_probe_error_contract(exe, entry, timeout)) return _payload(entry, checks) diff --git a/site-astro/src/lib/content.ts b/site-astro/src/lib/content.ts index 62ab703..c90baea 100644 --- a/site-astro/src/lib/content.ts +++ b/site-astro/src/lib/content.ts @@ -36,6 +36,15 @@ export interface GlossaryEntry { note?: string; } +// A pick-the-right-word cloze blank: the reader picks `answer` out of +// `options` (distractors + the correct word). See docs/specs/ +// subject-plugin-contract.md §3.6.1 (learn-cli's t3 uplift). +export interface ClozeBlank { + id: string; + options: string[]; + answer: string; +} + export interface Exercise { id: string; type: string; @@ -44,6 +53,12 @@ export interface Exercise { choices?: string[]; answer?: string; rubric?: string; + // Pick-the-right-word cloze variant ONLY (§3.6.1): the passage with each + // blank marked `{{blank_id}}`, plus one `blanks[]` entry per placeholder. + // Absent for the legacy single-blank cloze form (prompt + answer only, + // unchanged since contract 1.0) and for every non-cloze exercise type. + text?: string; + blanks?: ClozeBlank[]; } export interface Story { diff --git a/site-astro/src/pages/[subject]/stories/[id]/index.astro b/site-astro/src/pages/[subject]/stories/[id]/index.astro index 1dfb5ac..e628ede 100644 --- a/site-astro/src/pages/[subject]/stories/[id]/index.astro +++ b/site-astro/src/pages/[subject]/stories/[id]/index.astro @@ -1,12 +1,23 @@ --- // The story reader: clean prose, glossary, and comprehension questions -// listed statically (no interactive form — practice/checking is CLI or -// signed-in territory, a later wave). One page per story in the -// content-export; scripts/check-export-pages.mjs enforces the pairing. +// listed statically. Most exercise types render as plain text (no form — +// practice/checking is CLI or signed-in territory, a later wave); the +// pick-the-right-word cloze variant (§3.6.1) is the one exception: a +// dependency-free, sign-in-independent picker wired by +// src/scripts/learner.js's wireClozeExercises(), giving instant right/wrong +// feedback entirely client-side (no fetch — the answer is already public in +// this page's own data). One page per story in the content-export; +// scripts/check-export-pages.mjs enforces the pairing. import Layout from "../../../../layouts/Layout.astro"; import PageHero from "../../../../components/PageHero.astro"; import site from "../../../../data/site"; -import { getSubjects, getStories, type Subject, type Story } from "../../../../lib/content"; +import { + getSubjects, + getStories, + type Subject, + type Story, + type ClozeBlank, +} from "../../../../lib/content"; export function getStaticPaths() { return getSubjects().flatMap((subject) => @@ -24,6 +35,38 @@ interface Props { const { subject, story } = Astro.props; +// Split a cloze exercise's `text` on `{{blank_id}}` placeholders into an +// ordered list of plain-text runs and blank widgets, for the pick-the- +// right-word variant (§3.6.1). A placeholder with no matching `blanks` entry +// renders as literal text rather than breaking the build — malformed content +// is `learn subject doctor`'s `cloze-items` check's job to catch upstream, +// not this page's. +type ClozeSegment = { kind: "text"; value: string } | { kind: "blank"; blank: ClozeBlank }; + +function clozeSegments(text: string, blanks: ClozeBlank[]): ClozeSegment[] { + const byId = new Map(blanks.map((b) => [b.id, b])); + const segments: ClozeSegment[] = []; + const placeholderRe = /\{\{([^{}]+)\}\}/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = placeholderRe.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ kind: "text", value: text.slice(lastIndex, match.index) }); + } + const blank = byId.get(match[1]); + if (blank) { + segments.push({ kind: "blank", blank }); + } else { + segments.push({ kind: "text", value: match[0] }); + } + lastIndex = placeholderRe.lastIndex; + } + if (lastIndex < text.length) { + segments.push({ kind: "text", value: text.slice(lastIndex) }); + } + return segments; +} + // Paragraphs are separated by a blank line; a lone \n inside a paragraph is // just a wrapped source line, not an intentional break, so it collapses to // a space rather than a
    . @@ -121,13 +164,39 @@ const typeLabel: Record = { {typeLabel[exercise.type] ?? exercise.type} -

    {exercise.prompt}

    - {exercise.choices && exercise.choices.length > 0 && ( -
      - {exercise.choices.map((choice) => ( -
    • {choice}
    • - ))} -
    + {exercise.type === "cloze" && exercise.blanks && exercise.blanks.length > 0 ? ( +
    +

    {exercise.prompt}

    +

    + {clozeSegments(exercise.text ?? "", exercise.blanks).map((segment) => + segment.kind === "text" ? ( + segment.value + ) : ( + + + {segment.blank.options.map((option) => ( + + ))} + + + + ) + )} +

    +
    + ) : ( + <> +

    {exercise.prompt}

    + {exercise.choices && exercise.choices.length > 0 && ( +
      + {exercise.choices.map((choice) => ( +
    • {choice}
    • + ))} +
    + )} + )} {exercise.item_id && (
    = { background: color-mix(in srgb, var(--mesh-node) 55%, var(--bg)); } + .exercises-section { + /* "Not quite" state: a burnt-orange/coral warm counterpoint to the + aurora-teal accent, tuned per theme (>=4.5:1 against --surface/--bg + in both — see the dataviz-adjacent verification in the t3 PR). */ + --cloze-wrong: #b3401c; + } + + @media (prefers-color-scheme: dark) { + .exercises-section { + --cloze-wrong: #ff9466; + } + } + + .cloze-passage { + font-size: 1rem; + line-height: 2.1; + } + + .cloze-blank { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin: 0 0.15rem; + vertical-align: middle; + } + + .cloze-options { + display: inline-flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .cloze-option { + font: inherit; + font-size: 0.85rem; + font-weight: 600; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.2rem 0.75rem; + cursor: pointer; + } + + .cloze-option:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + color: var(--accent-strong); + } + + .cloze-option:disabled { + cursor: default; + } + + .cloze-option.is-correct { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 22%, var(--surface)); + color: var(--accent-strong); + opacity: 1; + } + + .cloze-option.is-incorrect { + border-color: var(--cloze-wrong); + background: color-mix(in srgb, var(--cloze-wrong) 16%, var(--surface)); + color: var(--cloze-wrong); + opacity: 1; + } + + .cloze-status { + font-size: 0.78rem; + font-style: italic; + } + .cta-note { margin-top: 1.6rem; font-size: 0.94rem; diff --git a/site-astro/src/scripts/learner.js b/site-astro/src/scripts/learner.js index 390b948..db6426f 100644 --- a/site-astro/src/scripts/learner.js +++ b/site-astro/src/scripts/learner.js @@ -231,6 +231,44 @@ function wireExerciseRecorders() { }); } +// --- pick-the-right-word cloze exercises (§3.6.1) ----------------------- + +/** Wire the pick-the-right-word cloze blanks the story reader renders inline + * in the passage (see [subject]/stories/[id]/index.astro's `.cloze-blank` + * markup). Purely client-side: the answer is already public in this page's + * own exported data, so checking it makes ZERO network calls and needs no + * sign-in — unlike wireExerciseRecorders(), this runs unconditionally, + * before bootstrap()'s auth check, so it works on a fully signed-out page + * too. Locks the blank to its first pick and reveals the correct option when + * the pick was wrong. */ +function wireClozeExercises() { + document.querySelectorAll("[data-cloze-blank]").forEach((blank) => { + const answer = blank.getAttribute("data-answer"); + const status = blank.querySelector("[data-cloze-status]"); + const buttons = Array.from(blank.querySelectorAll(".cloze-option")); + if (!answer || buttons.length === 0) return; + + buttons.forEach((btn) => { + btn.addEventListener("click", () => { + if (buttons.some((b) => b.disabled)) return; // already answered + const chosen = btn.getAttribute("data-option"); + buttons.forEach((b) => { + b.disabled = true; + }); + if (chosen === answer) { + btn.classList.add("is-correct"); + if (status) status.textContent = "Correct!"; + } else { + btn.classList.add("is-incorrect"); + const correctBtn = buttons.find((b) => b.getAttribute("data-option") === answer); + if (correctBtn) correctBtn.classList.add("is-correct"); + if (status) status.textContent = `Not quite — "${answer}" is right.`; + } + }); + }); + }); +} + function wireSignOut() { document.querySelectorAll("[data-sign-out]").forEach((btn) => { btn.addEventListener("click", async () => { @@ -275,4 +313,10 @@ async function bootstrap() { await Promise.all([hydratePanels(), Promise.resolve(wireExerciseRecorders())]); } +// Sign-in independent: wired before bootstrap() and its auth check, so cloze +// blanks are interactive even fully signed-out (see wireClozeExercises()'s +// own header comment). Makes zero fetch() calls; the whitelist check in +// scripts/check-static-auth.mjs only inspects bootstrap()'s body. +wireClozeExercises(); + bootstrap(); diff --git a/tests/conftest.py b/tests/conftest.py index 20ee4cf..47e3364 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,8 @@ FIXTURE_SUBJECTS = Path(__file__).parent / "fixtures" / "subjects" CONFORMANT_SCRIPT = FIXTURE_SUBJECTS / "conformant_subject.py" DRIFTED_SCRIPT = FIXTURE_SUBJECTS / "drifted_subject.py" +CLOZE_CONFORMANT_SCRIPT = FIXTURE_SUBJECTS / "cloze_conformant_subject.py" +CLOZE_BROKEN_SCRIPT = FIXTURE_SUBJECTS / "cloze_broken_subject.py" def _make_executable(path: Path) -> str: @@ -62,6 +64,18 @@ def drifted_prefix() -> list[str]: return [_make_executable(DRIFTED_SCRIPT)] +@pytest.fixture +def cloze_conformant_prefix() -> list[str]: + """argv_prefix for the well-formed pick-the-right-word cloze fixture.""" + return [_make_executable(CLOZE_CONFORMANT_SCRIPT)] + + +@pytest.fixture +def cloze_broken_prefix() -> list[str]: + """argv_prefix for the malformed-cloze-blank fixture (answer not in options).""" + return [_make_executable(CLOZE_BROKEN_SCRIPT)] + + @pytest.fixture def install_registry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/fixtures/subjects/cloze_broken_subject.py b/tests/fixtures/subjects/cloze_broken_subject.py new file mode 100755 index 0000000..b0a42fa --- /dev/null +++ b/tests/fixtures/subjects/cloze_broken_subject.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""A subject CLI whose one cloze exercise is malformed: the blank's `answer` +is not among its own `options`. Every OTHER verb the gate probes is valid, so +`learn subject doctor` must fail on exactly the `cloze-items` check — proving +the check actually inspects content, not just plumbing. + +Used by tests/test_subject_doctor.py's FAIL case for the `cloze-items` check. +""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +SUBJECT = "clozebroken" +SCHEMA_VERSION = "1.0" +CONTRACT_VERSION = "1.0" + + +def _emit(payload: dict[str, Any]) -> int: + json.dump(payload, sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + +def _fail(message: str, remediation: str) -> int: + json.dump( + {"code": 1, "message": message, "remediation": remediation}, + sys.stderr, + ensure_ascii=False, + ) + sys.stderr.write("\n") + return 1 + + +def _overview() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "subject_overview", + "subject": SUBJECT, + "display_name": "Cloze Broken Language", + "tagline": "A dummy subject proving `cloze-items` catches malformed blanks.", + "description": "The broken-cloze fixture: every verb is contract-valid EXCEPT the " + "cloze exercise's blank, whose answer is missing from its own options.", + "modules": [ + { + "id": "m1", + "title": "First Module", + "summary": "One story, one malformed cloze item.", + "level": "beginner", + } + ], + "content": {"stories": 1, "lessons": 0, "exercises": 1}, + } + + +def _doctor() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "subject_doctor", + "subject": SUBJECT, + "contract_version": CONTRACT_VERSION, + "healthy": True, + "checks": [ + { + "id": "content-present", + "passed": True, + "severity": "info", + "message": "content and story files load and validate", + "remediation": "", + } + ], + } + + +def _progress(learner: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "progress", + "subject": SUBJECT, + "learner": learner, + "items_total": 1, + "items_touched": 0, + "items_mastered": 0, + "completed": [], + "mastery": {}, + "next": { + "done": False, + "module_id": "m1", + "item_id": "greetings", + "text": "read the first story", + "command": "clozebroken story read s-cloze-bad --json", + }, + } + + +def _advice(learner: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "advice", + "subject": SUBJECT, + "learner": learner, + "advice": [], + } + + +def _story_list() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "story_list", + "subject": SUBJECT, + "stories": [ + { + "id": "s-cloze-bad", + "title": "Broken Cloze Story", + "level": "beginner", + "level_detail": "A1", + "summary": "A passage whose cloze answer is not in its own options.", + "exercises": 1, + } + ], + } + + +_STORIES: dict[str, dict[str, Any]] = { + "s-cloze-bad": { + "schema_version": SCHEMA_VERSION, + "kind": "story", + "id": "s-cloze-bad", + "subject": SUBJECT, + "title": "Broken Cloze Story", + "level": "beginner", + "level_detail": "A1", + "summary": "A passage whose cloze answer is not in its own options.", + "body": "Bonjour, je m'appelle Marie.", + "glossary": [], + "exercises": [ + { + "id": "cloze-bad-1", + "type": "cloze", + "item_id": "greetings", + "prompt": "Fill in the blank with the right word.", + "text": "Bonjour, je m'appelle {{name}}.", + # The blank's `answer` ("Marie") is NOT one of `options` — + # exactly the violation the `cloze-items` check must catch. + "blanks": [{"id": "name", "options": ["Paris", "bleu"], "answer": "Marie"}], + } + ], + "audio": None, + } +} + + +def _story_read(learner: str, story_id: str) -> dict[str, Any] | None: + story = _STORIES.get(story_id) + if story is None: + return None + return { + "schema_version": SCHEMA_VERSION, + "kind": "story_read", + "subject": SUBJECT, + "learner": learner, + "story": story, + "directive": { + "instructions": ["Present the story one paragraph at a time."], + "record_with": [ + "clozebroken record --item greetings --exercise cloze-bad-1 " + "--activity story --result pass --json" + ], + }, + } + + +def main(argv: list[str]) -> int: + tokens = [t for t in argv if t != "--json"] + learner = "anonymous" + if "--learner" in tokens: + i = tokens.index("--learner") + if i + 1 < len(tokens): + learner = tokens[i + 1] + del tokens[i : i + 2] + else: + del tokens[i] + verb = tokens[0] if tokens else "" + rest = tokens[1:] + + if verb == "overview": + return _emit(_overview()) + if verb == "doctor": + return _emit(_doctor()) + if verb == "progress": + return _emit(_progress(learner)) + if verb == "advice": + return _emit(_advice(learner)) + if verb == "story" and rest[:1] == ["list"]: + return _emit(_story_list()) + if verb == "story" and rest[:1] == ["read"]: + payload = _story_read(learner, rest[1] if len(rest) > 1 else "") + if payload is None: + return _fail( + f"unknown story: {rest[1] if len(rest) > 1 else ''}", + "run 'clozebroken story list --json' to see valid story ids", + ) + return _emit(payload) + return _fail( + f"unknown verb: {' '.join(tokens) or ''}", + "run 'clozebroken overview --json' to see valid verbs", + ) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/fixtures/subjects/cloze_conformant_subject.py b/tests/fixtures/subjects/cloze_conformant_subject.py new file mode 100755 index 0000000..36bf04b --- /dev/null +++ b/tests/fixtures/subjects/cloze_conformant_subject.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""A conformant subject CLI whose one story ships a well-formed pick-the-right-word +cloze exercise (``text`` + ``blanks``, docs/specs/subject-plugin-contract.md +§3.6.1) alongside a legacy single-blank free-text cloze exercise (``prompt`` + +``answer`` only, no ``blanks``) — proving the two coexist under the same +``type: "cloze"`` value exactly as the contract decision requires. + +Not installed as a dependency and never imported by learn-cli: driven purely as +an external subprocess over ``--json``, same as ``conformant_subject.py``. Used +by ``tests/test_subject_doctor.py``'s PASS case for the ``cloze-items`` check. +""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +SUBJECT = "clozelang" +SCHEMA_VERSION = "1.0" +CONTRACT_VERSION = "1.0" + + +def _emit(payload: dict[str, Any]) -> int: + json.dump(payload, sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + +def _fail(message: str, remediation: str) -> int: + json.dump( + {"code": 1, "message": message, "remediation": remediation}, + sys.stderr, + ensure_ascii=False, + ) + sys.stderr.write("\n") + return 1 + + +def _overview() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "subject_overview", + "subject": SUBJECT, + "display_name": "Cloze Language", + "tagline": "A dummy subject proving the pick-the-right-word cloze shape.", + "description": "The cloze-fixture subject: a hand-rolled conformant CLI used to prove " + "`learn subject doctor` verifies well-formed cloze blanks.", + "modules": [ + { + "id": "m1", + "title": "First Module", + "summary": "One story, one of each cloze flavor.", + "level": "beginner", + } + ], + "content": {"stories": 1, "lessons": 0, "exercises": 2}, + } + + +def _doctor() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "subject_doctor", + "subject": SUBJECT, + "contract_version": CONTRACT_VERSION, + "healthy": True, + "checks": [ + { + "id": "content-present", + "passed": True, + "severity": "info", + "message": "content and story files load and validate", + "remediation": "", + } + ], + } + + +def _progress(learner: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "progress", + "subject": SUBJECT, + "learner": learner, + "items_total": 2, + "items_touched": 0, + "items_mastered": 0, + "completed": [], + "mastery": {}, + "next": { + "done": False, + "module_id": "m1", + "item_id": "greetings", + "text": "read the first story", + "command": "clozelang story read s-cloze --json", + }, + } + + +def _advice(learner: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "advice", + "subject": SUBJECT, + "learner": learner, + "advice": [], + } + + +def _story_list() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "kind": "story_list", + "subject": SUBJECT, + "stories": [ + { + "id": "s-cloze", + "title": "Cloze Story", + "level": "beginner", + "level_detail": "A1", + "summary": "A tiny passage exercising both cloze flavors.", + "exercises": 2, + } + ], + } + + +#: The one full story the fixture serves, keyed by id (for `story read `). +_STORIES: dict[str, dict[str, Any]] = { + "s-cloze": { + "schema_version": SCHEMA_VERSION, + "kind": "story", + "id": "s-cloze", + "subject": SUBJECT, + "title": "Cloze Story", + "level": "beginner", + "level_detail": "A1", + "summary": "A tiny passage exercising both cloze flavors.", + "body": "Bonjour, je m'appelle Marie et j'ai dix ans.", + "glossary": [{"term": "bonjour", "definition": "hello"}], + "exercises": [ + { + "id": "cloze-good-1", + "type": "cloze", + "item_id": "greetings", + "prompt": "Fill in each blank with the right word.", + "text": "Bonjour, je m'appelle {{name}} et j'ai {{age}} ans.", + "blanks": [ + {"id": "name", "options": ["Marie", "Paris", "bleu"], "answer": "Marie"}, + {"id": "age", "options": ["dix", "rouge", "vite"], "answer": "dix"}, + ], + }, + { + # The legacy single-blank free-text cloze shape (contract 1.0, + # unchanged): no `text`/`blanks`, so `_is_cloze_blanks_item` + # skips it entirely — it is not subject to the new check. + "id": "cloze-legacy-1", + "type": "cloze", + "item_id": "numbers-money", + "prompt": "« C'est ___ ? » — « Dix euros, madame. »", + "answer": "combien", + }, + ], + "audio": None, + } +} + + +def _story_read(learner: str, story_id: str) -> dict[str, Any] | None: + story = _STORIES.get(story_id) + if story is None: + return None + return { + "schema_version": SCHEMA_VERSION, + "kind": "story_read", + "subject": SUBJECT, + "learner": learner, + "story": story, + "directive": { + "instructions": [ + "Present the story one paragraph at a time.", + "Run each comprehension exercise and record every result.", + ], + "record_with": [ + "clozelang record --item greetings --exercise cloze-good-1 " + "--activity story --correct 2 --total 2 --result pass --json" + ], + }, + } + + +def main(argv: list[str]) -> int: + tokens = [t for t in argv if t != "--json"] + learner = "anonymous" + if "--learner" in tokens: + i = tokens.index("--learner") + if i + 1 < len(tokens): + learner = tokens[i + 1] + del tokens[i : i + 2] + else: + del tokens[i] + verb = tokens[0] if tokens else "" + rest = tokens[1:] + + if verb == "overview": + return _emit(_overview()) + if verb == "doctor": + return _emit(_doctor()) + if verb == "progress": + return _emit(_progress(learner)) + if verb == "advice": + return _emit(_advice(learner)) + if verb == "story" and rest[:1] == ["list"]: + return _emit(_story_list()) + if verb == "story" and rest[:1] == ["read"]: + payload = _story_read(learner, rest[1] if len(rest) > 1 else "") + if payload is None: + return _fail( + f"unknown story: {rest[1] if len(rest) > 1 else ''}", + "run 'clozelang story list --json' to see valid story ids", + ) + return _emit(payload) + return _fail( + f"unknown verb: {' '.join(tokens) or ''}", + "run 'clozelang overview --json' to see valid verbs", + ) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_site_export.py b/tests/test_site_export.py index 620c318..31843f8 100644 --- a/tests/test_site_export.py +++ b/tests/test_site_export.py @@ -114,6 +114,47 @@ def test_stories_file_only_for_available_subjects(tmp_path, mixed_registry) -> N assert story["exercises"] +# --- t3 acceptance criterion 5: no cloze items -> export is untouched ------ + + +def test_export_unchanged_for_a_subject_declaring_no_cloze_items(tmp_path, mixed_registry) -> None: + """The t3 cloze contract change (learn/contract/schemas/*.json's optional + `text`/`blanks` fields, learn/subjects/conformance.py's `cloze-items` + check) touches NEITHER `export_site` nor the exported bytes for a subject + that declares no cloze items — the fixture subject predates t3 and ships + none. This locks the export's exercise object to its EXACT pre-t3 shape: + no `text`/`blanks` keys anywhere, proving the item_id join-key rules and + every other exercise field are untouched. + """ + export_site(tmp_path) + story = _load(tmp_path / "stories-fourthlang.json")["stories"][0] + exercise = story["exercises"][0] + # Semantically identical to the exact pre-t3 payload the conformant + # fixture has always emitted (tests/fixtures/subjects/conformant_subject.py). + assert exercise == { + "id": "s1-q1", + "type": "multiple_choice", + "item_id": "greetings", + "prompt": "What does the character say first?", + "choices": ["Hello", "Goodbye"], + "answer": "Hello", + } + assert "text" not in exercise + assert "blanks" not in exercise + + +def test_export_is_byte_stable_across_repeated_runs_with_no_cloze_content( + tmp_path, mixed_registry +) -> None: + # Re-run export_site twice more; the stories file must be byte-identical + # every time — the cloze contract addition changed nothing here. + export_site(tmp_path / "run1") + export_site(tmp_path / "run2") + a = (tmp_path / "run1" / "stories-fourthlang.json").read_bytes() + b = (tmp_path / "run2" / "stories-fourthlang.json").read_bytes() + assert a == b + + def test_docs_exported(tmp_path, mixed_registry) -> None: export_site(tmp_path) docs_dir = tmp_path / "docs" diff --git a/tests/test_subject_doctor.py b/tests/test_subject_doctor.py index d7653fe..2256650 100644 --- a/tests/test_subject_doctor.py +++ b/tests/test_subject_doctor.py @@ -21,7 +21,11 @@ from learn.cli import main from learn.contract import validate from learn.subjects import get_subject -from learn.subjects.conformance import run_conformance +from learn.subjects.conformance import ( + _is_cloze_blanks_item, + _validate_cloze_exercise, + run_conformance, +) from tests.conftest import entry @@ -89,6 +93,161 @@ def test_drift_report_still_validates_as_subject_doctor(install_registry, drifte assert validate(report, "doctor") == [] +# --- t3: the `cloze-items` check (pick-the-right-word cloze exercises) ------ + + +def test_cloze_free_subject_passes_trivially(install_registry, conformant_prefix) -> None: + # A subject that declares NO pick-the-right-word cloze items (the fourthlang + # fixture ships none) must not be penalized — the check passes trivially. + install_registry([entry("fourthlang", conformant_prefix)]) + report = run_conformance(get_subject("fourthlang")) + check = next(c for c in report["checks"] if c["id"] == "cloze-items") + assert check["passed"] is True + assert "no pick-the-right-word cloze items" in check["message"] + + +def test_well_formed_cloze_subject_passes( + install_registry, cloze_conformant_prefix, capsys +) -> None: + install_registry([entry("clozelang", cloze_conformant_prefix)]) + rc = main(["subject", "doctor", "clozelang", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert rc == 0 + assert payload["healthy"] is True + check = next(c for c in payload["checks"] if c["id"] == "cloze-items") + assert check["passed"] is True + # The fixture ships ONE pick-the-right-word item plus one legacy + # single-blank free-text cloze item — only the former is counted here. + assert "1 pick-the-right-word cloze item" in check["message"] + + +def test_well_formed_cloze_report_validates_as_subject_doctor( + install_registry, cloze_conformant_prefix +) -> None: + install_registry([entry("clozelang", cloze_conformant_prefix)]) + report = run_conformance(get_subject("clozelang")) + assert validate(report, "doctor") == [] + + +def test_malformed_cloze_subject_fails_doctor( + install_registry, cloze_broken_prefix, capsys +) -> None: + install_registry([entry("clozebroken", cloze_broken_prefix)]) + rc = main(["subject", "doctor", "clozebroken", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert rc == 2 + assert payload["healthy"] is False + check = next(c for c in payload["checks"] if c["id"] == "cloze-items") + assert check["passed"] is False + assert "not among its own `options`" in check["message"] + assert check["remediation"] + # Every OTHER check passed — the failure is specifically about content, + # not plumbing (proves the check inspects content, not just wiring). + other_failures = { + c["id"] for c in payload["checks"] if not c["passed"] and c["id"] != "cloze-items" + } + assert other_failures == set() + + +def test_malformed_cloze_report_still_validates_as_subject_doctor( + install_registry, cloze_broken_prefix +) -> None: + install_registry([entry("clozebroken", cloze_broken_prefix)]) + report = run_conformance(get_subject("clozebroken")) + assert report["healthy"] is False + assert validate(report, "doctor") == [] + + +# --- t3: `_validate_cloze_exercise` unit coverage (granular pass/fail cases) -- + + +def _good_cloze() -> dict: + return { + "id": "ex1", + "type": "cloze", + "item_id": "greetings", + "prompt": "Fill in the blanks.", + "text": "Bonjour, je m'appelle {{name}} et j'ai {{age}} ans.", + "blanks": [ + {"id": "name", "options": ["Marie", "Paris", "bleu"], "answer": "Marie"}, + {"id": "age", "options": ["dix", "rouge", "vite"], "answer": "dix"}, + ], + } + + +def test_is_cloze_blanks_item_true_for_blanks_shape() -> None: + assert _is_cloze_blanks_item(_good_cloze()) is True + + +def test_is_cloze_blanks_item_false_for_legacy_single_blank_cloze() -> None: + legacy = {"id": "ex2", "type": "cloze", "item_id": "x", "prompt": "___", "answer": "y"} + assert _is_cloze_blanks_item(legacy) is False + + +def test_is_cloze_blanks_item_false_for_non_cloze_type() -> None: + mc = { + "id": "ex3", + "type": "multiple_choice", + "item_id": "x", + "prompt": "?", + "choices": ["a", "b"], + } + assert _is_cloze_blanks_item(mc) is False + + +def test_validate_cloze_exercise_well_formed_has_no_errors() -> None: + assert _validate_cloze_exercise("s1", _good_cloze()) == [] + + +def test_validate_cloze_exercise_requires_both_text_and_blanks() -> None: + only_text = _good_cloze() + del only_text["blanks"] + errors = _validate_cloze_exercise("s1", only_text) + assert any("BOTH `text` and `blanks`" in e for e in errors) + + +def test_validate_cloze_exercise_rejects_missing_item_id() -> None: + ex = _good_cloze() + del ex["item_id"] + errors = _validate_cloze_exercise("s1", ex) + assert any("missing `item_id`" in e for e in errors) + + +def test_validate_cloze_exercise_rejects_answer_not_in_options() -> None: + ex = _good_cloze() + ex["blanks"][0]["answer"] = "Nope" + errors = _validate_cloze_exercise("s1", ex) + assert any("not among its own `options`" in e for e in errors) + + +def test_validate_cloze_exercise_rejects_duplicate_blank_ids() -> None: + ex = _good_cloze() + ex["blanks"][1]["id"] = "name" # duplicate of blanks[0]'s id + errors = _validate_cloze_exercise("s1", ex) + assert any("duplicate blank id" in e for e in errors) + + +def test_validate_cloze_exercise_rejects_blank_id_missing_from_text() -> None: + ex = _good_cloze() + ex["blanks"][0]["id"] = "nickname" # no {{nickname}} in text + errors = _validate_cloze_exercise("s1", ex) + assert any("no matching {{nickname}} placeholder" in e for e in errors) + + +def test_validate_cloze_exercise_rejects_orphan_placeholder() -> None: + ex = _good_cloze() + ex["text"] = ex["text"].replace("{{age}}", "{{stray}}") + errors = _validate_cloze_exercise("s1", ex) + assert any("placeholder {{stray}} has no matching `blanks` entry" in e for e in errors) + + +def test_validate_cloze_exercise_rejects_too_few_options() -> None: + ex = _good_cloze() + ex["blanks"][0]["options"] = ["Marie"] + errors = _validate_cloze_exercise("s1", ex) + assert any("at least 2 words" in e for e in errors) + + # --- environment + usage error paths --------------------------------------- diff --git a/workers/learn-api/src/validate.js b/workers/learn-api/src/validate.js index 8af308c..ed92e88 100644 --- a/workers/learn-api/src/validate.js +++ b/workers/learn-api/src/validate.js @@ -9,6 +9,15 @@ // // Kept deliberately in sync (by hand + by test) with: // learn/contract/schemas/record.json -> properties.recorded +// +// Cloze exercises (t3, docs/specs/subject-plugin-contract.md §3.6.1): this +// file needs NO change for the pick-the-right-word cloze variant. `recorded` +// carries no exercise-type/shape field at all — a cloze result is recorded +// exactly like any other exercise's result, via `activity` (unchanged: +// lesson|practice|story) plus the pre-existing `correct`/`total` counters, +// which a multi-blank cloze tallies into naturally (e.g. one right of two +// blanks records `correct: 1, total: 2`). See validate.test.js's +// "a cloze-originated record validates unchanged" for the proof. export const CONTRACT_VERSION = "1.0"; export const SCHEMA_VERSION_RE = /^1\.[0-9]+$/; diff --git a/workers/learn-api/test/validate.test.js b/workers/learn-api/test/validate.test.js index 3e354c6..6ffc64f 100644 --- a/workers/learn-api/test/validate.test.js +++ b/workers/learn-api/test/validate.test.js @@ -71,3 +71,40 @@ test("mastery inference matches culture-guide's mapping", () => { assert.equal(inferMastery("fail"), "introduced"); assert.equal(inferMastery("???"), "unknown"); }); + +// --- t3: cloze exercises (docs/specs/subject-plugin-contract.md §3.6.1) ----- +// +// `recorded` carries no exercise-type/shape field, so a cloze item's result — +// whether the legacy single-blank free-text form or the new pick-the-right- +// word form (`text` + `blanks` live only on the PRACTICE/STORY exercise +// payload, never on `recorded`) — is an ORDINARY contract-valid record. No +// code change to this module was needed for cloze support; these tests prove +// it rather than assert it. + +test("a cloze-originated record validates unchanged (no code change needed)", () => { + // A 2-blank pick-the-right-word cloze exercise, one blank right: the driver + // tallies into the pre-existing correct/total counters, exactly as any + // other countable exercise would. + const clozeRecorded = { + item_id: "numbers-money", + activity: "practice", + exercise_id: "fr-p1-b1", + result: "partial", + correct: 1, + total: 2, + at: "2026-07-11T10:00:00Z", + }; + assert.deepEqual(validateRecorded(clozeRecorded), []); +}); + +test("a legacy single-blank cloze record (no correct/total) also validates unchanged", () => { + const legacyClozeRecorded = { + item_id: "food-vocab", + activity: "story", + story_id: "fr-beg-le-marche-du-samedi", + exercise_id: "marche-q3", + result: "pass", + at: "2026-07-11T10:05:00Z", + }; + assert.deepEqual(validateRecorded(legacyClozeRecorded), []); +}); From f4148265c8209254a77ed589c143551bdd79830a Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 10:37:48 +0300 Subject: [PATCH 05/24] =?UTF-8?q?feat(infra):=20voice-bridge=20serverless?= =?UTF-8?q?=20spike=20+=20SAM=20template=20=E2=80=94=20Nova=20Sonic=202=20?= =?UTF-8?q?over=20API=20GW=20WebSocket=20(t4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike verdict (infra/SPIKE.md, run live against us-east-1, read-only): InvokeModelWithBidirectionalStream is SigV4-only — Bedrock API keys get 403 "This operation does not support API Keys" — but a plain Python 3.12 process holding the stream via the experimental aws_sdk_bedrock_runtime SDK completed a FULL audio round trip (Polly speech in, transcript + 19 audioOutput events back), so the Lambda relay authenticates with its execution role and python3.12/arm64 stays (league's convention, no Node). Bonus t15 intel: the bearer key serves Nova Pro on /converse (200 "Pong.") but NOT on /openai/v1/chat/completions (model_not_found in us-east-1). infra/template.yaml mirrors league-of-agents-platform's conventions: SAM, one arm64 Lambda (router + self-invoked session holder), long-form intrinsics parseable as plain YAML, capacity caps as Parameters cross-checked against voice_bridge/config.py by test, an AWS Budgets alarm pinned to a 20 USD/month ceiling with every sizing choice commented against it, zero idle cost. h7 groundwork: the bridge accepts ONLY short-lived voice tokens (session.js-symmetric HMAC format, scope=voice, approved=true) minted by learn-api (t16 mints; verification implemented + unit-tested here). No token, no upstream Bedrock connection — enforced structurally at $connect and proven by test. 65 new tests (tokens 21, handler 12, relay 6, template 26, incl. sam validate when the CLI is present); suite 420 passed. TDD: tests written red first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- infra/SPIKE.md | 163 ++++++++++++ infra/requirements.txt | 14 + infra/template.yaml | 397 ++++++++++++++++++++++++++++ infra/voice_bridge/__init__.py | 12 + infra/voice_bridge/config.py | 38 +++ infra/voice_bridge/handler.py | 275 +++++++++++++++++++ infra/voice_bridge/relay.py | 266 +++++++++++++++++++ infra/voice_bridge/tokens.py | 115 ++++++++ pyproject.toml | 3 + tests/conftest.py | 7 + tests/test_voice_bridge_handler.py | 184 +++++++++++++ tests/test_voice_bridge_relay.py | 156 +++++++++++ tests/test_voice_bridge_template.py | 297 +++++++++++++++++++++ tests/test_voice_bridge_tokens.py | 180 +++++++++++++ uv.lock | 2 + 15 files changed, 2109 insertions(+) create mode 100644 infra/SPIKE.md create mode 100644 infra/requirements.txt create mode 100644 infra/template.yaml create mode 100644 infra/voice_bridge/__init__.py create mode 100644 infra/voice_bridge/config.py create mode 100644 infra/voice_bridge/handler.py create mode 100644 infra/voice_bridge/relay.py create mode 100644 infra/voice_bridge/tokens.py create mode 100644 tests/test_voice_bridge_handler.py create mode 100644 tests/test_voice_bridge_relay.py create mode 100644 tests/test_voice_bridge_template.py create mode 100644 tests/test_voice_bridge_tokens.py diff --git a/infra/SPIKE.md b/infra/SPIKE.md new file mode 100644 index 0000000..4fd39f7 --- /dev/null +++ b/infra/SPIKE.md @@ -0,0 +1,163 @@ +# Voice-bridge spike: Lambda ↔ Bedrock Nova Sonic 2 bidirectional stream + +Task t4 of the consent-tutoring plan (spec claims c15/h7 groundwork, hosting +decision c27; plan risks r1/r3). Ran 2026-07-11 against live AWS, us-east-1, +**read-only API probing only** — nothing deployed, nothing created. No +credential value appears in this document; probes below show the secret as +`${AWS_BEDROCK_API_KEY_SECRET}` (the env var name from `.env`), and the +SigV4 probes used locally configured AWS credentials. + +## Verdict + +| Path | Auth | Verdict | Evidence | +| --- | --- | --- | --- | +| `InvokeModelWithBidirectionalStream` (Nova Sonic 2, voice) | Bedrock API key (Bearer) | **NO-GO** | HTTP 403 `{"Message":"This operation does not support API Keys"}` — the service rejects the operation for API keys outright | +| `InvokeModelWithBidirectionalStream` (Nova Sonic 2, voice) | SigV4 (role/env credentials) | **GO** | Full audio round trip from a plain Python 3.12 process: real speech in, transcript + spoken answer streamed back on the same HTTP/2 connection | +| `/openai/v1/chat/completions` (Nova Pro, text) | Bedrock API key (Bearer) | **NO-GO (today, us-east-1)** | Auth passes, but both `amazon.nova-pro-v1:0` and `us.amazon.nova-pro-v1:0` return 404 `model_not_found` — the OpenAI-compat surface does not serve Nova models here | +| `/model/us.amazon.nova-pro-v1%3A0/converse` (Nova Pro, text) | Bedrock API key (Bearer) | **GO** | HTTP 200, completion `"Pong."`, `usage: {inputTokens: 8, outputTokens: 3}` | + +**Production shape (recommended, and what `infra/template.yaml` builds):** +the voice bridge authenticates to Bedrock with its **Lambda execution role +(SigV4)** — the API-key NO-GO costs nothing, because the role is the natural +serverless credential anyway. The Bedrock API key stays useful for the text +path (t15), **but t15's spec assumption needs a correction**: point +`INFERENCE_URL` at the native `/converse` endpoint (or keep the broker +OpenAI-shaped and translate), not at `/openai/v1/chat/completions`, which +does not serve Nova Pro in us-east-1 today. + +## What was actually observed + +All runtime probes hit `https://bedrock-runtime.us-east-1.amazonaws.com` +(Nova home region — first and only region tried; every question answered +there). Every probe negotiated **HTTP/2** (curl `--write-out '%{http_version}'` +reported `2`), which the bidirectional protocol requires. + +### 1. Bearer key against the bidirectional operation — definitive NO-GO + +```console +$ curl --http2 -X POST \ + -H "Authorization: Bearer ${AWS_BEDROCK_API_KEY_SECRET}" \ + -H "Content-Type: application/vnd.amazon.eventstream" \ + https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.nova-2-sonic-v1:0/invoke-with-bidirectional-stream +HTTP 403 via HTTP/2 +{"Message":"This operation does not support API Keys"} +``` + +Not a signature or permission failure — the operation itself refuses API-key +auth. This is the clean answer the task anticipated: bidirectional is +SigV4/SDK-only. + +### 2. SigV4 + experimental SDK — full audio round trip (GO) + +Tooling: `aws_sdk_bedrock_runtime` 0.7.0 (AWS's experimental Smithy Python +SDK — today the only Python client for this operation; boto3 cannot open +bidirectional streams), Python 3.12, awscrt HTTP/2 underneath. Input audio +was real speech synthesized by Polly (`aws polly synthesize-speech +--output-format pcm --sample-rate 16000 --text "What is two plus two? +Answer briefly."`), streamed as base64 `audioInput` events at roughly +real-time pacing, followed by 1 s of silence for end-of-turn detection: + +```text +STREAM ESTABLISHED + <- userSpeechStart: {"inputAudioOffsetMs": 0, ...} +sent 28 audioInput chunks (110782 pcm bytes incl. silence) + <- completionStart: {...} + <- userSpeechEnd: {"inputAudioDetectionOffsetMs": 3080, ...} + <- textOutput: 'what is two plus two? answer briefly.' # ASR of our audio + <- textOutput: 'Two plus two equals four.' # the model's reply +event tally: {'usageEvent': 12, 'userSpeechStart': 1, 'completionStart': 1, + 'userSpeechEnd': 1, 'contentStart': 3, 'textOutput': 2, + 'contentEnd': 3, 'audioOutput': 19} +AUDIO BACK: 19 audioOutput events, 78720 pcm bytes (~1.64s at 24kHz) +VERDICT full-audio-round-trip: GO +``` + +A plain Python process held the stream, sent audio up, and received the +model's speech back on the same connection — exactly what the Lambda session +holder does. Total spike model spend: a few hundred speech tokens, well under +one cent. + +Two SDK traps found (both now encoded in `voice_bridge/relay.open_stream`): + +- **`aws_credentials_identity_resolver` must be set explicitly** on the + `Config`. Passing raw `aws_access_key_id=...` kwargs looks accepted but the + auth resolver never finds them — and the failure is *swallowed into a + background task*, so the open call awaits forever instead of raising + (`SmithyIdentityError` surfaced only via "Task exception was never + retrieved"). A production timeout around stream-open is mandatory. +- `EnvironmentCredentialsResolver` reads the standard `AWS_*` env vars — + which is exactly how a Lambda execution role delivers credentials, so the + spike's auth path and production's are the same code. + +### 3. Runtime choice: python3.12 stays (no Node needed) + +The task allowed switching the Lambda to Node if the spike proved the relay +needed the JS SDK's bidirectional support. It did not: the experimental +Python SDK held the stream end-to-end. python3.12 matches league's +convention; `awscrt` (the SDK's one native dependency) ships manylinux +aarch64 wheels, so arm64 Lambda packaging is a plain `requirements.txt` +build. The SDK is pre-1.0 and its `Config` API has already changed shape +between published samples — pinned `>=0.7,<1` in `infra/requirements.txt`. + +### 4. Bearer key intel for the text path (t15, not this task) + +- `GET /openai/v1/models` → 404 `` (operation + not offered in us-east-1). +- `POST /openai/v1/chat/completions` with `amazon.nova-micro-v1:0`, then + `us.amazon.nova-pro-v1:0` → 404 `model_not_found` both times. The bearer + key *authenticates* (no 401/403), but the OpenAI-compat surface does not + serve Nova models in us-east-1 today. +- `POST /model/us.amazon.nova-pro-v1%3A0/converse` with the same bearer key + → **HTTP 200**, `"Pong."`. The key is live and authorizes Nova Pro on the + native Converse API. + +Also confirmed via `list-foundation-models`: **Nova Sonic 2's model id is +`amazon.nova-2-sonic-v1:0`** (SPEECH,TEXT modalities, us-east-1); v1 remains +available as `amazon.nova-sonic-v1:0`. + +## API Gateway WebSocket constraints that shaped the design (risk r3) + +- **Per-message invocation model** — the biggest one. An API GW WebSocket + Lambda integration invokes the function once *per frame*; no invocation + holds the client socket. So one function runs in two modes + (`voice_bridge/handler.py`): the router (per-frame, verifies the token at + `$connect`, buffers inbound audio in DynamoDB) async-self-invokes the same + function as a session holder, which owns the Bedrock stream and pushes + audio back via the `@connections` PostToConnection API. +- **32 KB max frame** — audio chunks are ~4 KB of PCM (~5.5 KB base64), an + 8x margin. Client frames carry `{"seq": n, "audio": base64}`; `$default` + invocations can land out of order, so ordering is client-`seq` + authoritative (`relay.order_frames`). +- **10 min idle timeout / 2 h connection cap** — both far above the 300 s + `MaxSessionSeconds` cap, so the session budget, not the transport, ends + every session. +- **Lambda 15 min max runtime** — the absolute ceiling on any one-holder + session design; the 300 s cap + 360 s function timeout sit comfortably + inside it. + +## Cost observations feeding the template comments + +- Spike-measured: ~150 speech tokens for ~3.5 s of input audio → **~43 + speech tokens/second**. At published Nova Sonic rates (~$0.0034/1k in, + ~$0.0136/1k out) that is **~1–2 cents per conversation-minute combined** — + the dominant cost line by ~5x over all plumbing together. +- A 300 s max session ≈ $0.10 of model tokens + ~$0.02 of plumbing (DynamoDB + frame writes ~$0.018, Lambda ~$0.002, API GW messages ~$0.005). +- The $20 ceiling therefore affords ~200 max-length sessions/month; + 2 concurrent sessions bound saturated abuse to ~$2.4/hour, inside what the + Budgets forecast alarm catches same-day. Rotating `VoiceTokenSecretValue` + is the instant kill switch. + +## Open questions handed to t16 + +1. **Minting**: learn-api mints the voice token (session.js-symmetric HMAC + format, `scope: "voice"`, `approved: true`, short TTL — see + `voice_bridge/tokens.py` and its tests for the exact contract) only for + admin-approved learners, and passes it as `?token=` on the wss URL. +2. **Client audio**: 16 kHz/16-bit/mono LPCM up (base64, client-sequenced + frames), 24 kHz LPCM down — the shapes `relay.py` pins. +3. **Per-learner budget**: the bridge caps per-session seconds and global + concurrency; a per-learner monthly allowance (t16's server-side cap) + belongs in learn-api where approval lives. +4. **Deploy** happens later under supervision (`sam build && sam deploy` + with `BudgetAlertEmail` supplied); nothing was deployed in this task. diff --git a/infra/requirements.txt b/infra/requirements.txt new file mode 100644 index 0000000..8289d63 --- /dev/null +++ b/infra/requirements.txt @@ -0,0 +1,14 @@ +# Installed into the Lambda bundle by SAM's default python3.12 build (this +# directory is the template's CodeUri, so no repo-root pyproject is involved — +# unlike league's Makefile build, this package is self-contained on purpose). +# +# aws_sdk_bedrock_runtime is AWS's experimental Smithy-based SDK — today the +# only Python client for InvokeModelWithBidirectionalStream (boto3 cannot open +# bidirectional streams). Proven live in the spike (see infra/SPIKE.md) on +# 0.7.0; pinned below 1.0 because the SDK is pre-stable and its Config API +# already changed shape between sample-code versions. Pulls smithy-aws-core +# and awscrt (which ships manylinux aarch64 wheels — arm64 Lambda is fine). +# +# boto3 is deliberately absent: the Lambda runtime provides it, and bundling +# our own copy would add ~80 MB of package for zero benefit. +aws_sdk_bedrock_runtime>=0.7,<1 diff --git a/infra/template.yaml b/infra/template.yaml new file mode 100644 index 0000000..6bcc6a6 --- /dev/null +++ b/infra/template.yaml @@ -0,0 +1,397 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: > + learn-cli voice bridge — the serverless Nova Sonic 2 relay (spec c15/h7 + groundwork, hosting decision c27). An API Gateway WebSocket API fronting ONE + arm64 Lambda that (a) gates every connection on a short-lived voice token + minted by learn-api and (b) holds Bedrock's InvokeModelWithBidirectionalStream + for the session, relaying learner audio up and model speech back. A DynamoDB + session/frame table and an AWS Budgets alarm pinned to a 20 USD/month ceiling + complete the stack. Mirrors league-of-agents-platform/infra/template.yaml's + conventions on purpose: every sizing choice below is commented against that + 20 USD ceiling (never generic best practice), and intrinsic functions are + written long-form (Fn::Sub/Ref/Fn::GetAtt, no shorthand tags) so this file + parses as plain YAML with any loader (see tests/test_voice_bridge_template.py). + Zero idle cost by construction: no provisioned concurrency, nothing always-on + — an idle month is 0 USD, per the serverless-only user decision (no EC2). + The live feasibility proof for this design is infra/SPIKE.md. + +# --- parameters -------------------------------------------------------------- +# The capacity caps default to voice_bridge/config.py's constants — +# tests/test_voice_bridge_template.py cross-checks them against the Python +# source directly (league's anti-drift guard), so the two cannot silently +# diverge. +# +# The price frame every number below is tuned against (spike-measured + +# published on-demand rates, order of magnitude): Nova Sonic 2 speech tokens +# are the dominant cost line at ~0.0034 USD/1k in and ~0.0136 USD/1k out, and +# the spike measured ~43 speech tokens per second of audio — call it 1-2 cents +# per conversation-minute combined. Everything else in this stack (Lambda, +# API Gateway messages, DynamoDB frames) totals ~2 cents per max-length +# session, roughly a fifth of that session's model spend. The 20 USD ceiling +# therefore buys on the order of 1,000-2,000 voice-minutes a month, and the +# caps below exist to keep the *rate* of spend inside what the Budgets alarm +# can catch — the per-learner gate is approval itself (only admin-approved +# learners ever get a voice token; t16 adds a per-learner budget on top). + +Parameters: + StageName: + Type: String + Default: prod + Description: >- + API Gateway stage / resource-name namespace. Keep it short — it + prefixes every named resource below. + MonthlyBudgetUsd: + Type: Number + Default: 20 + Description: >- + Hard monthly cost ceiling in USD (see the MonthlyBudget resource). + Default matches voice_bridge.config.DEFAULT_MONTHLY_BUDGET_USD. + BudgetAlertEmail: + Type: String + Description: >- + Email address that receives AWS Budgets threshold notifications. No + default on purpose — the operator must supply a real address at deploy + time (league's posture). + VoiceTokenSecretValue: + Type: String + NoEcho: true + Default: "" + Description: >- + HMAC shared secret for voice tokens (voice_bridge.tokens; learn-api + mints the counterpart in t16). Empty by default, which keeps the bridge + SHUT — verify_voice_token never accepts anything under an empty secret, + so a deployed-but-unwired stack refuses every connection and can spend + nothing on Bedrock. NoEcho so CloudFormation never surfaces the value. + Rotating this parameter is also the kill switch: one stack update + invalidates every outstanding token instantly. + MaxSessionSeconds: + Type: Number + Default: 300 + Description: >- + Per-session hard cap, enforced by the session holder's pump deadline + (relay.pump) with the Lambda timeout as backstop. 300 s of conversation + is ~0.10 USD of Nova Sonic 2 tokens — the ceiling affords ~200 + max-length sessions/month. Also comfortably inside every API Gateway + WebSocket limit that shapes this design (10 min idle timeout, 2 h + connection cap — see infra/SPIKE.md). + MaxConcurrentVoiceSessions: + Type: Number + Default: 2 + Description: >- + Hard cap on simultaneous voice sessions — over-cap connects are REFUSED + (429), never degraded (league's capacity posture). 2 concurrent + sessions bound the worst-case burn rate to ~2.4 USD/hour of saturated + abuse, so the FORECASTED Budgets alarm below fires the same day and the + operator's response (rotate VoiceTokenSecretValue) shuts the bridge — + the cap bounds the rate, the alarm bounds the duration. + NovaSonicModelId: + Type: String + Default: amazon.nova-2-sonic-v1:0 + Description: >- + The Bedrock model the bridge relays to. Default confirmed live in + us-east-1 by the spike (SPEECH+TEXT modalities); matches + voice_bridge.config.NOVA_SONIC_MODEL_ID. + +# --- globals ----------------------------------------------------------------- +# Applied to every AWS::Serverless::Function here (there is exactly one). + +Globals: + Function: + # python3.12, matching league: the spike proved the experimental + # aws_sdk_bedrock_runtime SDK holds the bidirectional stream from plain + # Python — no Node runtime needed (see infra/SPIKE.md, "runtime choice"). + Runtime: python3.12 + # arm64 (Graviton): ~20% cheaper per GB-second than x86_64, and the one + # native dependency (awscrt) ships manylinux aarch64 wheels — free money + # against the ceiling, no downside. + Architectures: + - arm64 + # 512 MB: the session holder is I/O-bound (HTTP/2 event pump + base64) — + # no subprocess spawning like league's 1024 MB case. A full 300 s session + # at 512 MB is 150 GB-s ≈ 0.2 cents, ~1/50th of that session's Bedrock + # spend; router invocations are milliseconds. 1024 MB would double a + # rounding error for latency this workload cannot feel. + MemorySize: 512 + # 360 s = MaxSessionSeconds' default (300) + 60 s of stream-teardown + # grace. The pump self-terminates at the deadline; this timeout is the + # backstop circuit breaker so a hung holder is killed at ~0.5 cents of + # compute instead of running to Lambda's 900 s maximum. + # tests/test_voice_bridge_template.py asserts Timeout > MaxSessionSeconds. + Timeout: 360 + # X-Ray would bill per trace with nothing to fan out to — same reasoning + # as league: an unbounded-by-traffic cost line for no operational payoff. + Tracing: Disabled + +# --- resources --------------------------------------------------------------- +# Exactly eleven: the WebSocket API, its stage, three routes, the shared +# Lambda integration, the invoke permission, the bridge function and its log +# group, the sessions table, and the Budget alarm. (SAM adds the function's +# IAM execution role implicitly.) + +Resources: + # API Gateway WEBSOCKET API (the only API Gateway flavor with server-push, + # which the audio return path needs). Pricing is per message + # (~1.00 USD/million) plus connection-minutes (~0.25 USD/million): a + # max-length session moves ~2,400 frames up and ~2,400 down ≈ 0.5 cents — + # noise against the session's ~10 cents of model tokens. Constraints that + # shaped the design (verified against AWS quotas, see infra/SPIKE.md): + # 32 KB max frame (audio chunks are ~5.5 KB base64), 10 min idle timeout + # and 2 h connection cap (both far above MaxSessionSeconds=300). + WebSocketApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: + Fn::Sub: "learn-voice-${StageName}" + ProtocolType: WEBSOCKET + # Inbound frames are opaque JSON audio chunks, not action-routed + # commands — everything lands on $default; this expression is required + # boilerplate for WEBSOCKET APIs and deliberately never matches. + RouteSelectionExpression: "$request.body.action" + + WebSocketStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: + Ref: WebSocketApi + StageName: + Ref: StageName + AutoDeploy: true + # Throttling is the cost circuit breaker for the *plumbing* (league's + # HttpApi posture): it bounds how many router-Lambda invocations and + # DynamoDB frame writes a runaway client can generate. Steady state is + # ~8 frames/s per session × 2 sessions ≈ 16 msg/s; rate 50 leaves + # headroom for reconnect bursts while capping a hostile client at ~3x + # the legitimate peak. (Bedrock spend is bounded separately, by the + # session caps above — throttling here cannot reach it.) + DefaultRouteSettings: + ThrottlingBurstLimit: 100 + ThrottlingRateLimit: 50 + + # All three routes share the one integration below — the routing happens + # inside the handler (routeKey), keeping this a one-Lambda stack. + # AuthorizationType NONE is deliberate and safe: $connect itself IS the + # auth gate (voice_bridge.handler._connect verifies the learn-api-minted + # token before anything else happens — h7's groundwork), and a REQUEST + # authorizer would be a second Lambda for the same check. + ConnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: + Ref: WebSocketApi + RouteKey: $connect + AuthorizationType: NONE + Target: + Fn::Sub: "integrations/${BridgeIntegration}" + + DisconnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: + Ref: WebSocketApi + RouteKey: $disconnect + AuthorizationType: NONE + Target: + Fn::Sub: "integrations/${BridgeIntegration}" + + DefaultRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: + Ref: WebSocketApi + RouteKey: $default + AuthorizationType: NONE + Target: + Fn::Sub: "integrations/${BridgeIntegration}" + + BridgeIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: + Ref: WebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: + Fn::Sub: >- + arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BridgeFunction.Arn}/invocations + + WebSocketInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: + Ref: BridgeFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Sub: "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + + # The ONE Lambda, in two modes (see voice_bridge/handler.py's module + # docstring): API Gateway WebSocket integrations are per-message + # invocations, so the $connect router cannot itself hold the Bedrock + # stream — after the token verifies, it async-self-invokes this same + # function in session-holder mode, which opens the bidirectional stream + # and pumps until MaxSessionSeconds. One function, one runtime, one + # codebase — the "one arm64 Lambda" the task mandates, with no second + # resource to keep in sync. + BridgeFunction: + Type: AWS::Serverless::Function + Properties: + # Explicit name so the self-invoke IAM statement below can reference + # it as a plain string — referencing the function's own attribute from + # its own policy would be a circular dependency. + FunctionName: + Fn::Sub: "learn-voice-${StageName}-bridge" + # CodeUri is this infra/ directory (not voice_bridge/) because SAM's + # default python build wants requirements.txt at the CodeUri root and + # the handler uses package imports; the few extra files bundled + # (template.yaml, SPIKE.md) are kilobytes. No Makefile build needed — + # unlike league, this package is self-contained by design. + CodeUri: ./ + Handler: voice_bridge.handler.lambda_handler + Environment: + Variables: + # Names come from voice_bridge/config.py; the template test + # cross-checks them so handler and template cannot drift. + VOICE_TOKEN_SECRET: + Ref: VoiceTokenSecretValue + SESSIONS_TABLE_NAME: + Ref: VoiceSessionsTable + MAX_SESSION_SECONDS: + Ref: MaxSessionSeconds + MAX_CONCURRENT_VOICE_SESSIONS: + Ref: MaxConcurrentVoiceSessions + NOVA_SONIC_MODEL_ID: + Ref: NovaSonicModelId + SELF_FUNCTION_NAME: + Fn::Sub: "learn-voice-${StageName}-bridge" + WEBSOCKET_CALLBACK_URL: + Fn::Sub: "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName}" + # Least privilege, every grant named: exactly what the two modes use, + # nothing wildcard. (SAM's DynamoDBCrudPolicy scopes to the one table.) + Policies: + - DynamoDBCrudPolicy: + TableName: + Ref: VoiceSessionsTable + - Version: "2012-10-17" + Statement: + # The session holder's upstream connection — the ONLY Bedrock + # action this stack can perform, on the one model it relays to. + # SigV4 via this execution role is the proven auth path; the + # spike showed Bedrock API keys are rejected by this operation + # ("This operation does not support API Keys" — infra/SPIKE.md). + - Sid: OpenNovaSonicBidirectionalStream + Effect: Allow + Action: + - bedrock:InvokeModelWithBidirectionalStream + Resource: + Fn::Sub: "arn:aws:bedrock:${AWS::Region}::foundation-model/${NovaSonicModelId}" + # The audio return path: PostToConnection (and the teardown + # DeleteConnection) on this API's connections only. + - Sid: PushAudioBackOverTheWebSocket + Effect: Allow + Action: + - execute-api:ManageConnections + Resource: + Fn::Sub: "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/${StageName}/POST/@connections/*" + # The router's async hand-off to session-holder mode (same + # function; see FunctionName comment above). + - Sid: SelfInvokeSessionHolder + Effect: Allow + Action: + - lambda:InvokeFunction + Resource: + Fn::Sub: "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:learn-voice-${StageName}-bridge" + + # CloudWatch Logs cost is retention x volume; explicit group + short + # retention instead of the auto-created "Never expire" default (league's + # reasoning verbatim — a hosted side project, not compliance retention). + BridgeFunctionLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: + Fn::Sub: "/aws/lambda/${BridgeFunction}" + RetentionInDays: 14 + + # Session registry + inbound frame buffer in one table, league's PK/SK + # single-table convention (see voice_bridge/handler.py's DynamoSessionStore + # for the item shapes). On-demand billing: voice traffic is bursty and + # capacity-capped, so there is no idle floor to pay — an idle month is + # 0 USD. Frames are the hot path: ~2,400 writes of ~6 WRU per max-length + # session ≈ 1.8 cents, the largest plumbing line and still ~1/5th of the + # session's model tokens. TTL (expires_at) garbage-collects sessions and + # frames for free, so a crashed holder cannot pin the concurrency cap + # (rows expire ~1 h past deadline) or accumulate storage cost. + VoiceSessionsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: + Fn::Sub: "learn-voice-${StageName}-sessions" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + TimeToLiveSpecification: + AttributeName: expires_at + Enabled: true + + # The hard 20 USD/month ceiling, enforced as notification (AWS Budgets + # cannot halt spend itself). Same two thresholds as league: 80% of actual + # as the early warning, 100% forecast so the operator hears BEFORE the + # ceiling is crossed. Budgets evaluates on billing-data granularity + # (hours, not minutes) — which is exactly why MaxConcurrentVoiceSessions + # bounds the burn RATE above: at ~2.4 USD/hour worst case, the forecast + # alarm fires the same day and rotating VoiceTokenSecretValue stops the + # spend instantly. + MonthlyBudget: + Type: AWS::Budgets::Budget + Properties: + Budget: + BudgetName: + Fn::Sub: "learn-voice-${StageName}-monthly-ceiling" + BudgetType: COST + TimeUnit: MONTHLY + BudgetLimit: + Amount: + Ref: MonthlyBudgetUsd + Unit: USD + NotificationsWithSubscribers: + - Notification: + NotificationType: ACTUAL + ComparisonOperator: GREATER_THAN + Threshold: 80 + ThresholdType: PERCENTAGE + Subscribers: + - SubscriptionType: EMAIL + Address: + Ref: BudgetAlertEmail + - Notification: + NotificationType: FORECASTED + ComparisonOperator: GREATER_THAN + Threshold: 100 + ThresholdType: PERCENTAGE + Subscribers: + - SubscriptionType: EMAIL + Address: + Ref: BudgetAlertEmail + +Outputs: + WebSocketUrl: + Description: >- + wss:// endpoint the /learn voice UI (t16) connects to, with + ?token= on the upgrade request. + Value: + Fn::Sub: "wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName}" + VoiceSessionsTableName: + Description: Name of the deployed voice sessions/frames table. + Value: + Ref: VoiceSessionsTable + BridgeFunctionName: + Description: Name of the bridge Lambda (router + session holder). + Value: + Ref: BridgeFunction diff --git a/infra/voice_bridge/__init__.py b/infra/voice_bridge/__init__.py new file mode 100644 index 0000000..058e2dc --- /dev/null +++ b/infra/voice_bridge/__init__.py @@ -0,0 +1,12 @@ +"""learn-cli's Nova Sonic 2 voice bridge — the Lambda behind the WebSocket API. + +Deployed by ``infra/template.yaml`` (AWS SAM, arm64, python3.12). Lives under +``infra/`` — outside the ``learn`` package — because it is deploy +infrastructure, not part of the CLI/MCP/site product: bandit and coverage +scopes stay pinned to ``learn/``, and SAM builds this directory standalone +from its own ``requirements.txt``. + +Spec anchor: honesty condition h7 — approval is enforced at the bridge entry; +no valid voice token, no upstream Bedrock connection. See ``infra/SPIKE.md`` +for the live spike this design is built on. +""" diff --git a/infra/voice_bridge/config.py b/infra/voice_bridge/config.py new file mode 100644 index 0000000..67ae852 --- /dev/null +++ b/infra/voice_bridge/config.py @@ -0,0 +1,38 @@ +"""Single source of truth for the voice bridge's caps and env-var names. + +``infra/template.yaml`` declares the same numbers as CloudFormation Parameter +defaults and the same names as Lambda environment keys; +``tests/test_voice_bridge_template.py`` cross-checks both against this module +so the template and the code that reads it cannot silently drift apart — +the exact guard league-of-agents-platform runs between its template and +``league_site.capacity.config``. + +Every default below is tuned against the 20 USD/month ceiling (see the +template's Parameters section for the full price math; it is kept in one +place there, not duplicated here): + +- ``DEFAULT_MAX_SESSION_SECONDS = 300`` — a 5-minute session is roughly + 0.10 USD of Nova Sonic 2 speech tokens at the ~1-2 cents/minute the spike + measured, so the ceiling affords ~200 max-length sessions a month. +- ``DEFAULT_MAX_CONCURRENT_VOICE_SESSIONS = 2`` — bounds the worst-case burn + *rate* (~2.4 USD/hour at continuous max use) so abuse cannot outrun the + Budgets alarm's daily cost granularity by much. +""" + +from __future__ import annotations + +DEFAULT_MONTHLY_BUDGET_USD = 20 +DEFAULT_MAX_SESSION_SECONDS = 300 +DEFAULT_MAX_CONCURRENT_VOICE_SESSIONS = 2 + +# Confirmed live in the spike (infra/SPIKE.md): Nova Sonic 2's us-east-1 id. +NOVA_SONIC_MODEL_ID = "amazon.nova-2-sonic-v1:0" + +# Lambda environment variable names, wired by infra/template.yaml. +VOICE_TOKEN_SECRET_ENV = "VOICE_TOKEN_SECRET" +SESSIONS_TABLE_ENV = "SESSIONS_TABLE_NAME" +MAX_SESSION_SECONDS_ENV = "MAX_SESSION_SECONDS" +MAX_CONCURRENT_SESSIONS_ENV = "MAX_CONCURRENT_VOICE_SESSIONS" +MODEL_ID_ENV = "NOVA_SONIC_MODEL_ID" +SELF_FUNCTION_NAME_ENV = "SELF_FUNCTION_NAME" +CALLBACK_URL_ENV = "WEBSOCKET_CALLBACK_URL" diff --git a/infra/voice_bridge/handler.py b/infra/voice_bridge/handler.py new file mode 100644 index 0000000..80d2ca6 --- /dev/null +++ b/infra/voice_bridge/handler.py @@ -0,0 +1,275 @@ +"""The one Lambda behind the WebSocket API — router and session holder in one. + +API Gateway WebSocket integrations are per-*message* invocations: the Lambda +that handles ``$connect`` is not a long-lived process holding the socket. So +the same function runs in two modes (the one-arm64-Lambda design the template +documents): + +- **router mode** — invoked by API Gateway per WebSocket event. ``$connect`` + is the approval gate: verify the learn-api-minted voice token FIRST, check + the concurrency cap, register the session, then async-self-invoke the + session holder. ``$default`` stores inbound audio frames for the holder; + ``$disconnect`` cleans up. +- **session mode** — the async self-invocation. Only THIS path ever opens a + Bedrock connection (relay.open_stream), and it is only reachable after a + token verified — h7's groundwork is structural, not a scattered check. + +Every dependency is injected through ``Deps`` so the gate logic runs under +pytest with zero AWS; ``_default_deps`` builds the boto3-backed production +set lazily on first real invocation. +""" + +from __future__ import annotations + +import json +import os +import time +import uuid +from dataclasses import dataclass +from typing import Any, Callable, Protocol + +from . import config, tokens + +# One year of TTL grace past the session deadline would hide leaks; one hour +# keeps ghost rows (a crashed holder that never cleaned up) from pinning the +# concurrency cap for longer than an operator would notice. +_TTL_GRACE_SECONDS = 3600 + + +class SessionStore(Protocol): + """What the handler needs from the voice-sessions table.""" + + def count_active(self, now: int) -> int: ... # noqa: E704 + + def put_session(self, connection_id: str, record: dict) -> None: ... # noqa: E704 + + def get_session(self, connection_id: str) -> dict | None: ... # noqa: E704 + + def delete_session(self, connection_id: str) -> None: ... # noqa: E704 + + def store_frame(self, connection_id: str, body: str) -> None: ... # noqa: E704 + + +@dataclass +class Deps: + """Injected seams; production values come from ``_default_deps``.""" + + secret: str + max_concurrent: int + max_session_seconds: int + sessions: SessionStore + launch_session: Callable[[dict], Any] + run_session: Callable[[dict], dict] + now: Callable[[], int] + + +def lambda_handler(event: dict, context: Any = None, deps: Deps | None = None) -> dict: + deps = deps or _default_deps() + if isinstance(event, dict) and event.get("voiceBridge") == "session": + return deps.run_session(event) + request = event.get("requestContext") or {} + route = request.get("routeKey") + connection_id = request.get("connectionId", "") + if route == "$connect": + return _connect(event, connection_id, deps) + if route == "$default": + return _frame(event, connection_id, deps) + if route == "$disconnect": + deps.sessions.delete_session(connection_id) + return {"statusCode": 200} + return {"statusCode": 400} + + +def _connect(event: dict, connection_id: str, deps: Deps) -> dict: + """The bridge entry — h7's gate. Order matters: verify before anything else.""" + token = (event.get("queryStringParameters") or {}).get("token") + claims = tokens.verify_voice_token(deps.secret, token, now=deps.now()) + if claims is None: + # No valid approved token -> refuse the upgrade. Nothing was written, + # nothing was launched, and the Bedrock-opening path (session mode) + # is unreachable from here. + return {"statusCode": 401} + now = deps.now() + if deps.sessions.count_active(now) >= deps.max_concurrent: + # Over-cap is a refusal, never degraded service (league's posture). + return {"statusCode": 429} + deadline = now + deps.max_session_seconds + deps.sessions.put_session( + connection_id, + {"uid": claims["uid"], "connected_at": now, "deadline": deadline}, + ) + deps.launch_session( + { + "voiceBridge": "session", + "connectionId": connection_id, + "uid": claims["uid"], + "deadline": deadline, + } + ) + return {"statusCode": 200} + + +def _frame(event: dict, connection_id: str, deps: Deps) -> dict: + """Inbound audio frame: forwarded only for a connection that passed the gate.""" + if deps.sessions.get_session(connection_id) is None: + return {"statusCode": 403} + deps.sessions.store_frame(connection_id, event.get("body") or "") + return {"statusCode": 200} + + +# --- production wiring (boto3; built lazily, unused under pytest) ----------- + + +class DynamoSessionStore: + """Sessions + inbound frames in the one PAY_PER_REQUEST table. + + Item shapes: ``PK=SESSION#``, ``SK=METADATA`` for the session + row; ``SK=FRAME##`` for inbound audio frames (ordering + is client-``seq`` authoritative — see relay.order_frames; the SK only + approximates arrival). Every item carries an ``expires_at`` TTL so a + crashed session leaves nothing behind and storage cost stays ~zero. + """ + + def __init__(self, table: Any): + self._table = table + + def count_active(self, now: int) -> int: + # A Scan is O(table), which is fine *because* the concurrency cap + # keeps this table at a handful of rows by construction; a GSI would + # be added capacity for no measurable gain at cap=2. + result = self._table.scan( + Select="COUNT", + FilterExpression="SK = :meta AND expires_at > :now", + ExpressionAttributeValues={":meta": "METADATA", ":now": now}, + ) + return int(result.get("Count", 0)) + + def put_session(self, connection_id: str, record: dict) -> None: + item = { + "PK": f"SESSION#{connection_id}", + "SK": "METADATA", + "expires_at": record["deadline"] + _TTL_GRACE_SECONDS, + **record, + } + self._table.put_item(Item=item) + + def get_session(self, connection_id: str) -> dict | None: + result = self._table.get_item(Key={"PK": f"SESSION#{connection_id}", "SK": "METADATA"}) + return result.get("Item") + + def delete_session(self, connection_id: str) -> None: + self._table.delete_item(Key={"PK": f"SESSION#{connection_id}", "SK": "METADATA"}) + + def store_frame(self, connection_id: str, body: str) -> None: + self._table.put_item( + Item={ + "PK": f"SESSION#{connection_id}", + "SK": f"FRAME#{int(time.time() * 1000):013d}#{uuid.uuid4().hex[:8]}", + "body": body, + "expires_at": int(time.time()) + 120, # audio staler than 2min is useless + } + ) + + def consume_frames(self, connection_id: str) -> list[str]: + """Read-and-delete pending frames (the session holder's inbound poll).""" + result = self._table.query( + KeyConditionExpression="PK = :pk AND begins_with(SK, :prefix)", + ExpressionAttributeValues={ + ":pk": f"SESSION#{connection_id}", + ":prefix": "FRAME#", + }, + ConsistentRead=True, + ) + items = result.get("Items", []) + for item in items: + self._table.delete_item(Key={"PK": item["PK"], "SK": item["SK"]}) + return [item.get("body", "") for item in items] + + +def _store(): + import boto3 + + table = boto3.resource("dynamodb").Table(os.environ[config.SESSIONS_TABLE_ENV]) + return DynamoSessionStore(table) + + +def _launch_session(payload: dict) -> None: + """Fire-and-forget self-invoke: the holder outlives this router invocation.""" + import boto3 + + boto3.client("lambda").invoke( + FunctionName=os.environ[config.SELF_FUNCTION_NAME_ENV], + InvocationType="Event", + Payload=json.dumps(payload).encode("utf-8"), + ) + + +def _run_session(payload: dict) -> dict: + """Session-holder mode: hold the Bedrock stream, pump both directions.""" + import asyncio + + import boto3 + + from . import relay + + connection_id = payload["connectionId"] + store = _store() + gateway = boto3.client( + "apigatewaymanagementapi", endpoint_url=os.environ[config.CALLBACK_URL_ENV] + ) + + async def _session() -> dict: + bedrock = await relay.open_stream( + os.environ.get(config.MODEL_ID_ENV, config.NOVA_SONIC_MODEL_ID), + os.environ.get("AWS_REGION", "us-east-1"), + ) + + async def next_frames() -> list[str]: + return relay.order_frames(store.consume_frames(connection_id)) + + def send_downstream(message: dict) -> None: + gateway.post_to_connection( + ConnectionId=connection_id, Data=json.dumps(message).encode("utf-8") + ) + + return await relay.pump( + bedrock, + next_frames, + send_downstream, + now=lambda: int(time.time()), + deadline=int(payload["deadline"]), + prompt_name=f"learn-voice-{connection_id}", + system_prompt=( + "You are the learn platform's voice tutor. Be brief, warm, and " + "correct the learner gently." + ), + ) + + try: + summary = asyncio.run(_session()) + finally: + store.delete_session(connection_id) + try: + gateway.delete_connection(ConnectionId=connection_id) + except Exception: # noqa: BLE001 - client may already be gone; that's fine + pass + return summary + + +def _default_deps() -> Deps: + return Deps( + secret=os.environ.get(config.VOICE_TOKEN_SECRET_ENV, ""), + max_concurrent=int( + os.environ.get( + config.MAX_CONCURRENT_SESSIONS_ENV, + config.DEFAULT_MAX_CONCURRENT_VOICE_SESSIONS, + ) + ), + max_session_seconds=int( + os.environ.get(config.MAX_SESSION_SECONDS_ENV, config.DEFAULT_MAX_SESSION_SECONDS) + ), + sessions=_store(), + launch_session=_launch_session, + run_session=_run_session, + now=lambda: int(time.time()), + ) diff --git a/infra/voice_bridge/relay.py b/infra/voice_bridge/relay.py new file mode 100644 index 0000000..c02a581 --- /dev/null +++ b/infra/voice_bridge/relay.py @@ -0,0 +1,266 @@ +"""The bidirectional pump: learner audio up to Nova Sonic 2, model audio back. + +This is the code path the spike proved live end-to-end (infra/SPIKE.md): a +plain Python 3.12 process holding Bedrock's InvokeModelWithBidirectionalStream +over HTTP/2 via the experimental ``aws_sdk_bedrock_runtime`` SDK, real speech +in, recognized transcript + spoken answer back. + +``pump`` is written against three injected seams so the whole loop runs under +pytest with zero AWS: + +- a *bedrock* object (``send_event``/``receive_event``/``close``), +- ``next_frames`` — an async callable draining inbound base64 audio chunks + (production: the DynamoDB frame rows the $default route wrote), +- ``send_downstream`` — a callable receiving ``{"kind", "content"}`` dicts + (production: PostToConnection back through the WebSocket). + +``open_stream`` is the only untested seam: a direct transcription of the +working spike script, including the two traps it found (the credentials +resolver MUST be set explicitly or the SDK's auth failure is swallowed into a +forever-pending await; ``EnvironmentCredentialsResolver`` is exactly how a +Lambda execution role's credentials surface). +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, Awaitable, Callable + +# 16 kHz, 16-bit mono LPCM in; 24 kHz out — the exact shapes the spike used. +AUDIO_INPUT_CONFIGURATION = { + "mediaType": "audio/lpcm", + "sampleRateHertz": 16000, + "sampleSizeBits": 16, + "channelCount": 1, + "audioType": "SPEECH", + "encoding": "base64", +} +AUDIO_OUTPUT_CONFIGURATION = { + "mediaType": "audio/lpcm", + "sampleRateHertz": 24000, + "sampleSizeBits": 16, + "channelCount": 1, + "voiceId": "matthew", + "encoding": "base64", + "audioType": "SPEECH", +} +AUDIO_CONTENT_NAME = "learner-audio" + + +def setup_events(prompt_name: str, *, system_prompt: str) -> list[dict]: + """The session-opening event sequence, exactly as the spike sent it.""" + return [ + { + "event": { + "sessionStart": { + "inferenceConfiguration": {"maxTokens": 1024, "topP": 0.9, "temperature": 0.7} + } + } + }, + { + "event": { + "promptStart": { + "promptName": prompt_name, + "textOutputConfiguration": {"mediaType": "text/plain"}, + "audioOutputConfiguration": AUDIO_OUTPUT_CONFIGURATION, + } + } + }, + { + "event": { + "contentStart": { + "promptName": prompt_name, + "contentName": "system", + "type": "TEXT", + "interactive": True, + "role": "SYSTEM", + "textInputConfiguration": {"mediaType": "text/plain"}, + } + } + }, + { + "event": { + "textInput": { + "promptName": prompt_name, + "contentName": "system", + "content": system_prompt, + } + } + }, + {"event": {"contentEnd": {"promptName": prompt_name, "contentName": "system"}}}, + { + "event": { + "contentStart": { + "promptName": prompt_name, + "contentName": AUDIO_CONTENT_NAME, + "type": "AUDIO", + "interactive": True, + "role": "USER", + "audioInputConfiguration": AUDIO_INPUT_CONFIGURATION, + } + } + }, + ] + + +def audio_input_event(prompt_name: str, b64: str) -> dict: + return { + "event": { + "audioInput": { + "promptName": prompt_name, + "contentName": AUDIO_CONTENT_NAME, + "content": b64, + } + } + } + + +def order_frames(bodies: list[str]) -> list[str]: + """Order inbound WS frame bodies by their client-assigned ``seq``. + + $default invocations can land concurrently, so arrival order is only + approximate — the browser numbers each frame (``{"seq": n, "audio": + base64}``) and this puts them back in order. Malformed bodies are dropped, + never fatal: one bad frame must not kill a live conversation. + """ + parsed: list[tuple[int, str]] = [] + for body in bodies: + try: + frame = json.loads(body) + parsed.append((int(frame["seq"]), str(frame["audio"]))) + except (ValueError, KeyError, TypeError): + continue + return [audio for _seq, audio in sorted(parsed)] + + +async def pump( + bedrock: Any, + next_frames: Callable[[], Awaitable[list[str]]], + send_downstream: Callable[[dict], Any], + *, + now: Callable[[], int], + deadline: int, + prompt_name: str, + system_prompt: str, + idle_sleep: float = 0.05, +) -> dict: + """Relay until the deadline, the stream closing, or the caller's cancel. + + The deadline is the per-session cap (MaxSessionSeconds, enforced again by + the Lambda timeout as a backstop) — the session ends *by construction*, + it does not rely on the client hanging up. + """ + for event in setup_events(prompt_name, system_prompt=system_prompt): + await bedrock.send_event(event) + + queue: asyncio.Queue = asyncio.Queue() + + async def _receiver() -> None: + while True: + event = await bedrock.receive_event() + await queue.put(event) + if event is None: + return + + receiver = asyncio.create_task(_receiver()) + frames_sent = 0 + events_received = 0 + ended = "deadline" + try: + while True: + if now() >= deadline: + ended = "deadline" + break + for b64 in await next_frames(): + await bedrock.send_event(audio_input_event(prompt_name, b64)) + frames_sent += 1 + try: + event = await asyncio.wait_for( + queue.get(), timeout=idle_sleep if idle_sleep > 0 else 0.01 + ) + except asyncio.TimeoutError: + continue + if event is None: + ended = "stream-closed" + break + events_received += 1 + body = event.get("event", {}) + name = next(iter(body), "") + if name == "audioOutput": + send_downstream( + {"kind": "audio", "content": body["audioOutput"].get("content", "")} + ) + elif name == "textOutput": + send_downstream({"kind": "text", "content": body["textOutput"].get("content", "")}) + finally: + receiver.cancel() + try: + await bedrock.send_event({"event": {"promptEnd": {"promptName": prompt_name}}}) + await bedrock.send_event({"event": {"sessionEnd": {}}}) + except Exception: # noqa: BLE001 - a closed stream must not mask the summary + pass + await bedrock.close() + return {"ended": ended, "frames_sent": frames_sent, "events_received": events_received} + + +class BedrockStream: + """Thin adapter from the experimental SDK's stream to pump's three seams.""" + + def __init__(self, stream: Any, receiver: Any): + self._stream = stream + self._receiver = receiver + + async def send_event(self, payload: dict) -> None: + from aws_sdk_bedrock_runtime.models import ( + BidirectionalInputPayloadPart, + InvokeModelWithBidirectionalStreamInputChunk, + ) + + chunk = InvokeModelWithBidirectionalStreamInputChunk( + value=BidirectionalInputPayloadPart(bytes_=json.dumps(payload).encode("utf-8")) + ) + await self._stream.input_stream.send(chunk) + + async def receive_event(self): + result = await self._receiver.receive() + if result is None: + return None + part = getattr(result, "value", None) + raw = getattr(part, "bytes_", None) if part else None + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + async def close(self) -> None: + await self._stream.input_stream.close() + + +async def open_stream(model_id: str, region: str) -> BedrockStream: + """Open the live bidirectional stream — the spike script, verbatim. + + Lazy imports: the experimental SDK exists only in the deployed Lambda + bundle (infra/voice_bridge/requirements.txt), never in learn-cli's venv. + """ + from aws_sdk_bedrock_runtime.client import ( + BedrockRuntimeClient, + InvokeModelWithBidirectionalStreamOperationInput, + ) + from aws_sdk_bedrock_runtime.config import Config + from smithy_aws_core.identity import EnvironmentCredentialsResolver + + config = Config( + endpoint_uri=f"https://bedrock-runtime.{region}.amazonaws.com", + region=region, + # Spike finding: omit this and the SDK swallows its auth error into a + # never-resolving await. The env resolver reads the standard AWS_* + # variables — exactly how the Lambda execution role's SigV4 + # credentials arrive (API keys are NOT accepted by this operation). + aws_credentials_identity_resolver=EnvironmentCredentialsResolver(), + ) + client = BedrockRuntimeClient(config=config) + stream = await client.invoke_model_with_bidirectional_stream( + InvokeModelWithBidirectionalStreamOperationInput(model_id=model_id) + ) + output = await stream.await_output() + return BedrockStream(stream, output[1]) diff --git a/infra/voice_bridge/tokens.py b/infra/voice_bridge/tokens.py new file mode 100644 index 0000000..7755276 --- /dev/null +++ b/infra/voice_bridge/tokens.py @@ -0,0 +1,115 @@ +"""Short-lived voice tokens — the verify side of learn-api's mint (task t16). + +Byte-for-byte symmetric with ``workers/learn-api/src/session.js``: + + token = base64url(JSON payload) + "." + base64url(signature) + signature = HMAC-SHA256(secret, base64url(JSON payload)) + +both base64url segments unpadded, the HMAC computed over the *encoded body +string*. The Worker mints with the same shared secret (a template Parameter +here, a Worker secret there); this module only ever verifies. + +The payload carries ``{v, scope, uid, approved, iat, exp, sid}``. Three claims +distinguish a voice token from the session tokens whose format it shares: + +- ``scope: "voice"`` — a learn-api *session* token must never open the + bridge (no session-cookie reuse; the plan's t4 acceptance verbatim); +- ``approved: true`` — minted only for learners the admin approved for the + Bedrock tier, making the bridge entry the approval gate (h7 groundwork); +- a short ``exp`` — minutes, not the session's hour. + +``verify_voice_token`` returns the payload dict when every check passes and +``None`` otherwise — the session.js contract. It never raises on bad input. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +import uuid + +TOKEN_VERSION = 1 +TOKEN_SCOPE = "voice" + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64url_decode(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _sign(secret: str, body: str) -> str: + digest = hmac.new(secret.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest() + return _b64url(digest) + + +def mint_voice_token( + secret: str, + uid: str | int, + *, + approved: bool, + ttl_seconds: int, + now: int | None = None, + scope: str = TOKEN_SCOPE, +) -> str: + """Mint a voice token — the Python parity twin of the Worker's minter. + + Production minting happens in learn-api (t16); this helper exists so the + verify side can be tested against the exact format the Worker produces, + and doubles as an operator tool for local smoke tests. + """ + if not secret: + raise ValueError("voice-token secret is not configured") + if approved is not True: + raise ValueError("voice tokens are minted for approved learners only") + iat = int(time.time()) if now is None else int(now) + payload = { + "v": TOKEN_VERSION, + "scope": scope, + "uid": str(uid), + "approved": True, + "iat": iat, + "exp": iat + int(ttl_seconds), + "sid": str(uuid.uuid4()), + } + body = _b64url(json.dumps(payload).encode("utf-8")) + return f"{body}.{_sign(secret, body)}" + + +def verify_voice_token(secret: str | None, token: object, *, now: int | None = None): + """Return the payload if the token is valid, current, and approved — else ``None``.""" + if not secret or not isinstance(secret, str): + return None + if not token or not isinstance(token, str): + return None + dot = token.rfind(".") + if dot <= 0: + return None + body, signature = token[:dot], token[dot + 1 :] + if not hmac.compare_digest(signature, _sign(secret, body)): + return None + try: + payload = json.loads(_b64url_decode(body)) + except (ValueError, UnicodeDecodeError): + return None + if not isinstance(payload, dict): + return None + if payload.get("v") != TOKEN_VERSION: + return None + if payload.get("scope") != TOKEN_SCOPE: + return None + if payload.get("approved") is not True: + return None + uid = payload.get("uid") + if not uid or not isinstance(uid, str): + return None + exp = payload.get("exp") + current = int(time.time()) if now is None else int(now) + if not isinstance(exp, (int, float)) or exp <= current: + return None + return payload diff --git a/pyproject.toml b/pyproject.toml index e6de587..bcc5c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ dev = [ "isort>=5.12.0", "black>=23.7.0", "teken>=0.8", + # tests/test_voice_bridge_template.py parses infra/template.yaml as plain + # YAML (the long-form-intrinsics rule) — test-only, not a runtime dep. + "pyyaml>=6.0", ] [tool.coverage.run] diff --git a/tests/conftest.py b/tests/conftest.py index 20ee4cf..2e297c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ import json import os import stat +import sys import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -21,6 +22,12 @@ import pytest +# The voice-bridge Lambda package lives under infra/ (outside the learn/ +# package on purpose — see infra/template.yaml), so tests import it by path. +_INFRA_DIR = Path(__file__).resolve().parent.parent / "infra" +if str(_INFRA_DIR) not in sys.path: + sys.path.insert(0, str(_INFRA_DIR)) + FIXTURE_SUBJECTS = Path(__file__).parent / "fixtures" / "subjects" CONFORMANT_SCRIPT = FIXTURE_SUBJECTS / "conformant_subject.py" DRIFTED_SCRIPT = FIXTURE_SUBJECTS / "drifted_subject.py" diff --git a/tests/test_voice_bridge_handler.py b/tests/test_voice_bridge_handler.py new file mode 100644 index 0000000..74f54b8 --- /dev/null +++ b/tests/test_voice_bridge_handler.py @@ -0,0 +1,184 @@ +"""Tests for ``infra/voice_bridge/handler.py`` — approval enforced at the bridge entry. + +Spec honesty condition h7's groundwork, verbatim: *no token, no upstream +Bedrock connection*. Every refusal path below asserts two things — the HTTP +status the WebSocket client sees, and that the injected session launcher (the +only code path that ever opens a Bedrock stream) was never called. All +dependencies are injected fakes; no AWS, no network. +""" + +from __future__ import annotations + +from voice_bridge import handler, tokens + +SECRET = "spike-shared-secret" +NOW = 1_800_000_000 + + +class FakeStore: + """In-memory stand-in for the DynamoDB voice-sessions table.""" + + def __init__(self, active: int = 0): + self.active = active + self.sessions: dict[str, dict] = {} + self.frames: list[tuple[str, str]] = [] + + def count_active(self, now: int) -> int: + return self.active + len(self.sessions) + + def put_session(self, connection_id: str, record: dict) -> None: + self.sessions[connection_id] = record + + def get_session(self, connection_id: str): + return self.sessions.get(connection_id) + + def delete_session(self, connection_id: str) -> None: + self.sessions.pop(connection_id, None) + + def store_frame(self, connection_id: str, body: str) -> None: + self.frames.append((connection_id, body)) + + +def make_deps(*, secret: str = SECRET, active: int = 0, max_concurrent: int = 2): + store = FakeStore(active=active) + launched: list[dict] = [] + ran: list[dict] = [] + deps = handler.Deps( + secret=secret, + max_concurrent=max_concurrent, + max_session_seconds=300, + sessions=store, + launch_session=launched.append, + run_session=lambda payload: ran.append(payload) or {"ok": True}, + now=lambda: NOW, + ) + return deps, store, launched, ran + + +def connect_event(token: str | None) -> dict: + query = {"token": token} if token is not None else None + return { + "requestContext": {"routeKey": "$connect", "connectionId": "conn-1"}, + "queryStringParameters": query, + } + + +def valid_token() -> str: + return tokens.mint_voice_token(SECRET, uid="20955789", approved=True, ttl_seconds=120, now=NOW) + + +def test_connect_without_token_is_refused_with_zero_upstream() -> None: + deps, store, launched, _ = make_deps() + response = handler.lambda_handler(connect_event(None), None, deps=deps) + assert response["statusCode"] == 401 + assert launched == [] + assert store.sessions == {} + + +def test_connect_with_garbage_token_is_refused_with_zero_upstream() -> None: + deps, store, launched, _ = make_deps() + response = handler.lambda_handler(connect_event("not.a.token"), None, deps=deps) + assert response["statusCode"] == 401 + assert launched == [] + assert store.sessions == {} + + +def test_connect_with_expired_token_is_refused() -> None: + expired = tokens.mint_voice_token(SECRET, uid="1", approved=True, ttl_seconds=60, now=NOW - 61) + deps, _store, launched, _ = make_deps() + response = handler.lambda_handler(connect_event(expired), None, deps=deps) + assert response["statusCode"] == 401 + assert launched == [] + + +def test_connect_with_session_scope_token_is_refused() -> None: + """A learn-api session cookie/token must not open the bridge (no cookie reuse).""" + session_scoped = tokens.mint_voice_token( + SECRET, uid="1", approved=True, ttl_seconds=120, now=NOW, scope="session" + ) + deps, _store, launched, _ = make_deps() + response = handler.lambda_handler(connect_event(session_scoped), None, deps=deps) + assert response["statusCode"] == 401 + assert launched == [] + + +def test_connect_with_empty_secret_is_refused_even_with_valid_token() -> None: + """VoiceTokenSecretValue defaults to '' — the deployed-but-unwired bridge stays shut.""" + deps, _store, launched, _ = make_deps(secret="") + response = handler.lambda_handler(connect_event(valid_token()), None, deps=deps) + assert response["statusCode"] == 401 + assert launched == [] + + +def test_connect_over_the_concurrency_cap_is_refused_not_degraded() -> None: + """League's capacity posture: over-cap is a refusal, never degraded service.""" + deps, _store, launched, _ = make_deps(active=2, max_concurrent=2) + response = handler.lambda_handler(connect_event(valid_token()), None, deps=deps) + assert response["statusCode"] == 429 + assert launched == [] + + +def test_connect_with_valid_token_registers_and_launches_the_session() -> None: + deps, store, launched, _ = make_deps() + response = handler.lambda_handler(connect_event(valid_token()), None, deps=deps) + assert response["statusCode"] == 200 + assert "conn-1" in store.sessions + record = store.sessions["conn-1"] + assert record["uid"] == "20955789" + assert record["deadline"] == NOW + 300 + assert len(launched) == 1 + payload = launched[0] + assert payload["voiceBridge"] == "session" + assert payload["connectionId"] == "conn-1" + assert payload["uid"] == "20955789" + assert payload["deadline"] == NOW + 300 + + +def test_default_route_without_a_registered_session_drops_the_frame() -> None: + deps, store, launched, _ = make_deps() + event = { + "requestContext": {"routeKey": "$default", "connectionId": "conn-9"}, + "body": "frame-bytes", + } + response = handler.lambda_handler(event, None, deps=deps) + assert response["statusCode"] == 403 + assert store.frames == [] + assert launched == [] + + +def test_default_route_stores_frames_for_a_registered_session() -> None: + deps, store, _launched, _ = make_deps() + handler.lambda_handler(connect_event(valid_token()), None, deps=deps) + event = { + "requestContext": {"routeKey": "$default", "connectionId": "conn-1"}, + "body": '{"seq": 1, "audio": "AAAA"}', + } + response = handler.lambda_handler(event, None, deps=deps) + assert response["statusCode"] == 200 + assert store.frames == [("conn-1", '{"seq": 1, "audio": "AAAA"}')] + + +def test_disconnect_deletes_the_session() -> None: + deps, store, _launched, _ = make_deps() + handler.lambda_handler(connect_event(valid_token()), None, deps=deps) + event = {"requestContext": {"routeKey": "$disconnect", "connectionId": "conn-1"}} + response = handler.lambda_handler(event, None, deps=deps) + assert response["statusCode"] == 200 + assert store.sessions == {} + + +def test_unknown_route_is_a_client_error() -> None: + deps, _store, _launched, _ = make_deps() + event = {"requestContext": {"routeKey": "$weird", "connectionId": "conn-1"}} + response = handler.lambda_handler(event, None, deps=deps) + assert response["statusCode"] == 400 + + +def test_session_mode_event_routes_to_the_session_runner() -> None: + """The async self-invoke payload re-enters the same function in holder mode.""" + deps, _store, launched, ran = make_deps() + payload = {"voiceBridge": "session", "connectionId": "conn-1", "uid": "1", "deadline": NOW} + response = handler.lambda_handler(payload, None, deps=deps) + assert response == {"ok": True} + assert ran == [payload] + assert launched == [] diff --git a/tests/test_voice_bridge_relay.py b/tests/test_voice_bridge_relay.py new file mode 100644 index 0000000..905e987 --- /dev/null +++ b/tests/test_voice_bridge_relay.py @@ -0,0 +1,156 @@ +"""Tests for ``infra/voice_bridge/relay.py`` — the bidirectional pump, on fakes. + +The pump is the code the spike proved live (see infra/SPIKE.md): Nova Sonic 2's +event protocol over InvokeModelWithBidirectionalStream. Here it runs against +injected fakes — the SDK wiring (``open_stream``) is the only untested seam, +and it is a direct transcription of the working spike script. +""" + +from __future__ import annotations + +import asyncio +import json + +from voice_bridge import relay + +NOW = 1_800_000_000 + + +def run(coro): + return asyncio.run(coro) + + +class FakeBedrock: + """Duck-typed stand-in for the opened bidirectional stream seams.""" + + def __init__(self, responses): + self.sent: list[dict] = [] + self.responses = list(responses) + self.closed = False + + async def send_event(self, payload: dict) -> None: + self.sent.append(payload) + + async def receive_event(self): + if self.responses: + return self.responses.pop(0) + await asyncio.sleep(3600) # simulate an idle stream: nothing more to say + + async def close(self) -> None: + self.closed = True + + +def audio_output_event(b64: str) -> dict: + return {"event": {"audioOutput": {"content": b64}}} + + +def test_setup_events_carry_the_session_prompt_and_audio_config() -> None: + events = relay.setup_events("prompt-1", system_prompt="Be terse.") + names = [next(iter(event["event"])) for event in events] + assert names == [ + "sessionStart", + "promptStart", + "contentStart", + "textInput", + "contentEnd", + "contentStart", + ] + audio_start = events[5]["event"]["contentStart"] + assert audio_start["type"] == "AUDIO" + assert audio_start["audioInputConfiguration"]["sampleRateHertz"] == 16000 + + +def test_pump_relays_frames_up_and_audio_down_until_deadline() -> None: + bedrock = FakeBedrock([audio_output_event("UExDTQ==")]) + frames = [["QUJD", "REVG"], []] + downstream: list[dict] = [] + + async def next_frames(): + return frames.pop(0) if frames else [] + + clock = iter([NOW, NOW, NOW + 1, NOW + 400, NOW + 400, NOW + 400]) + summary = run( + relay.pump( + bedrock, + next_frames, + downstream.append, + now=lambda: next(clock), + deadline=NOW + 300, + prompt_name="prompt-1", + system_prompt="Be terse.", + idle_sleep=0, + ) + ) + sent_names = [next(iter(event["event"])) for event in bedrock.sent] + assert sent_names.count("audioInput") == 2 + audio_in = [e["event"]["audioInput"] for e in bedrock.sent if "audioInput" in e["event"]] + assert [chunk["content"] for chunk in audio_in] == ["QUJD", "REVG"] + assert sent_names[-2:] == ["promptEnd", "sessionEnd"] + assert bedrock.closed is True + assert summary["ended"] == "deadline" + assert summary["frames_sent"] == 2 + assert downstream == [{"kind": "audio", "content": "UExDTQ=="}] + + +def test_pump_stops_when_the_stream_closes() -> None: + bedrock = FakeBedrock([None]) + + async def no_frames(): + return [] + + summary = run( + relay.pump( + bedrock, + no_frames, + lambda _e: None, + now=lambda: NOW, + deadline=NOW + 300, + prompt_name="p", + system_prompt="s", + idle_sleep=0, + ) + ) + assert summary["ended"] == "stream-closed" + assert bedrock.closed is True + + +def test_pump_forwards_text_output_as_transcript_events() -> None: + bedrock = FakeBedrock( + [ + {"event": {"textOutput": {"content": "two plus two equals four."}}}, + None, + ] + ) + downstream: list[dict] = [] + + async def no_frames(): + return [] + + run( + relay.pump( + bedrock, + no_frames, + downstream.append, + now=lambda: NOW, + deadline=NOW + 300, + prompt_name="p", + system_prompt="s", + idle_sleep=0, + ) + ) + assert downstream == [{"kind": "text", "content": "two plus two equals four."}] + + +def test_frame_bodies_decode_client_seq_ordering() -> None: + """Inbound WS frame bodies are client-sequenced JSON; the relay orders by seq.""" + bodies = [ + json.dumps({"seq": 2, "audio": "Qg=="}), + json.dumps({"seq": 1, "audio": "QQ=="}), + ] + ordered = relay.order_frames(bodies) + assert ordered == ["QQ==", "Qg=="] + + +def test_order_frames_drops_malformed_bodies() -> None: + ordered = relay.order_frames(["not-json", json.dumps({"seq": 1, "audio": "QQ=="}), "{}"]) + assert ordered == ["QQ=="] diff --git a/tests/test_voice_bridge_template.py b/tests/test_voice_bridge_template.py new file mode 100644 index 0000000..00b7205 --- /dev/null +++ b/tests/test_voice_bridge_template.py @@ -0,0 +1,297 @@ +"""Tests for ``infra/template.yaml`` — the Nova Sonic 2 voice-bridge SAM stack. + +Mirrors league-of-agents-platform's ``tests/test_lambda_template.py`` approach: +no AWS is touched — the template is parsed as *plain YAML* (long-form +``Fn::Sub``/``Ref``/``Fn::GetAtt`` intrinsics only, never ``!Sub`` tags, so any +standard loader can inspect it) and its shape is asserted: the WebSocket API, +the single arm64 Lambda, the capacity-cap Parameters cross-checked against the +Python source constants, and the Budgets alarm pinned to the monthly ceiling. +``sam validate`` runs only when the CLI happens to be installed. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml +from voice_bridge import config as bridge_config + +_TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "infra" / "template.yaml" + +_EXPECTED_RESOURCES: dict[str, str] = { + "WebSocketApi": "AWS::ApiGatewayV2::Api", + "WebSocketStage": "AWS::ApiGatewayV2::Stage", + "ConnectRoute": "AWS::ApiGatewayV2::Route", + "DisconnectRoute": "AWS::ApiGatewayV2::Route", + "DefaultRoute": "AWS::ApiGatewayV2::Route", + "BridgeIntegration": "AWS::ApiGatewayV2::Integration", + "WebSocketInvokePermission": "AWS::Lambda::Permission", + "BridgeFunction": "AWS::Serverless::Function", + "BridgeFunctionLogGroup": "AWS::Logs::LogGroup", + "VoiceSessionsTable": "AWS::DynamoDB::Table", + "MonthlyBudget": "AWS::Budgets::Budget", +} + + +@pytest.fixture(scope="module") +def template() -> dict[str, Any]: + """Parse the template with a plain YAML loader (the long-form-intrinsics proof).""" + with _TEMPLATE_PATH.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def test_template_file_exists() -> None: + assert _TEMPLATE_PATH.is_file() + + +def test_template_parses_as_plain_yaml(template: dict[str, Any]) -> None: + assert isinstance(template, dict) + assert template["AWSTemplateFormatVersion"] == "2010-09-09" + assert template["Transform"] == "AWS::Serverless-2016-10-31" + + +def test_template_contains_no_shortform_intrinsics() -> None: + """Belt and braces on top of the safe_load fixture: no ``!Sub``-style tags at all.""" + text = _TEMPLATE_PATH.read_text(encoding="utf-8") + for tag in ("!Sub", "!Ref", "!GetAtt", "!Join", "!If"): + assert tag not in text + + +def test_template_declares_exactly_the_expected_resources(template: dict[str, Any]) -> None: + resources = template["Resources"] + assert set(resources.keys()) == set(_EXPECTED_RESOURCES.keys()) + for logical_id, expected_type in _EXPECTED_RESOURCES.items(): + assert resources[logical_id]["Type"] == expected_type + + +def test_lambda_is_python312_arm64(template: dict[str, Any]) -> None: + """arm64 + python3.12, set once in Globals — league's convention, ~20% cheaper GB-s.""" + function_defaults = template["Globals"]["Function"] + assert function_defaults["Runtime"] == "python3.12" + assert function_defaults["Architectures"] == ["arm64"] + assert function_defaults["Tracing"] == "Disabled" + + +def test_lambda_timeout_exceeds_the_default_session_cap(template: dict[str, Any]) -> None: + """The Lambda timeout is the backstop circuit breaker over MaxSessionSeconds. + + The session holder self-terminates at MAX_SESSION_SECONDS; the function + timeout must sit above the default cap (with grace for stream teardown) + or every max-length session would be killed mid-teardown. + """ + timeout = template["Globals"]["Function"]["Timeout"] + default_cap = template["Parameters"]["MaxSessionSeconds"]["Default"] + assert timeout > default_cap + assert timeout <= 900 # Lambda's own hard maximum + + +def test_zero_idle_cost(template: dict[str, Any]) -> None: + """No provisioned concurrency, no always-on resource anywhere in the stack.""" + text = _TEMPLATE_PATH.read_text(encoding="utf-8") + assert "ProvisionedConcurrency" not in text + for resource in template["Resources"].values(): + assert not resource["Type"].startswith("AWS::EC2::") + assert not resource["Type"].startswith("AWS::ECS::") + + +def test_monthly_budget_parameter_matches_the_python_ceiling(template: dict[str, Any]) -> None: + parameter = template["Parameters"]["MonthlyBudgetUsd"] + assert parameter["Default"] == bridge_config.DEFAULT_MONTHLY_BUDGET_USD == 20 + + +def test_budget_alert_email_has_no_default(template: dict[str, Any]) -> None: + """The operator must supply a real address at deploy time — league's posture.""" + parameter = template["Parameters"]["BudgetAlertEmail"] + assert parameter["Type"] == "String" + assert "Default" not in parameter + + +def test_budget_resource_is_wired_to_the_ceiling_parameter(template: dict[str, Any]) -> None: + budget = template["Resources"]["MonthlyBudget"]["Properties"]["Budget"] + assert budget["BudgetLimit"]["Amount"] == {"Ref": "MonthlyBudgetUsd"} + assert budget["BudgetLimit"]["Unit"] == "USD" + assert budget["BudgetType"] == "COST" + assert budget["TimeUnit"] == "MONTHLY" + + +def test_budget_notifies_at_80_actual_and_100_forecast(template: dict[str, Any]) -> None: + subscriptions = template["Resources"]["MonthlyBudget"]["Properties"][ + "NotificationsWithSubscribers" + ] + seen = { + (entry["Notification"]["NotificationType"], entry["Notification"]["Threshold"]) + for entry in subscriptions + } + assert seen == {("ACTUAL", 80), ("FORECASTED", 100)} + for entry in subscriptions: + addresses = [sub["Address"] for sub in entry["Subscribers"]] + assert {"Ref": "BudgetAlertEmail"} in addresses + + +def test_voice_token_secret_is_noecho_defaulting_empty(template: dict[str, Any]) -> None: + """Empty default means the bridge refuses every connection — disabled until t16 mints.""" + parameter = template["Parameters"]["VoiceTokenSecretValue"] + assert parameter["Type"] == "String" + assert parameter["NoEcho"] is True + assert parameter["Default"] == "" + + +def test_capacity_parameter_defaults_match_the_python_source(template: dict[str, Any]) -> None: + """The template's caps and voice_bridge.config must never drift apart (league's guard).""" + parameters = template["Parameters"] + assert parameters["MaxSessionSeconds"]["Default"] == bridge_config.DEFAULT_MAX_SESSION_SECONDS + assert ( + parameters["MaxConcurrentVoiceSessions"]["Default"] + == bridge_config.DEFAULT_MAX_CONCURRENT_VOICE_SESSIONS + ) + assert parameters["NovaSonicModelId"]["Default"] == bridge_config.NOVA_SONIC_MODEL_ID + + +def test_websocket_api_is_a_websocket_api(template: dict[str, Any]) -> None: + api = template["Resources"]["WebSocketApi"]["Properties"] + assert api["ProtocolType"] == "WEBSOCKET" + assert api["RouteSelectionExpression"] == "$request.body.action" + + +def test_all_three_routes_target_the_single_integration(template: dict[str, Any]) -> None: + route_keys = set() + for logical_id in ("ConnectRoute", "DisconnectRoute", "DefaultRoute"): + route = template["Resources"][logical_id]["Properties"] + route_keys.add(route["RouteKey"]) + assert route["ApiId"] == {"Ref": "WebSocketApi"} + assert route["AuthorizationType"] == "NONE" + assert route["Target"] == {"Fn::Sub": "integrations/${BridgeIntegration}"} + assert route_keys == {"$connect", "$disconnect", "$default"} + + +def test_stage_throttling_is_a_cost_circuit_breaker(template: dict[str, Any]) -> None: + stage = template["Resources"]["WebSocketStage"]["Properties"] + assert stage["ApiId"] == {"Ref": "WebSocketApi"} + assert stage["AutoDeploy"] is True + settings = stage["DefaultRouteSettings"] + assert settings["ThrottlingBurstLimit"] > 0 + assert settings["ThrottlingRateLimit"] > 0 + + +def test_bridge_function_env_names_match_python_source_constants( + template: dict[str, Any], +) -> None: + """Env-var *names* come from voice_bridge.config so template and code cannot drift.""" + env = template["Resources"]["BridgeFunction"]["Properties"]["Environment"]["Variables"] + assert env[bridge_config.VOICE_TOKEN_SECRET_ENV] == {"Ref": "VoiceTokenSecretValue"} + assert env[bridge_config.SESSIONS_TABLE_ENV] == {"Ref": "VoiceSessionsTable"} + assert env[bridge_config.MAX_SESSION_SECONDS_ENV] == {"Ref": "MaxSessionSeconds"} + assert env[bridge_config.MAX_CONCURRENT_SESSIONS_ENV] == {"Ref": "MaxConcurrentVoiceSessions"} + assert env[bridge_config.MODEL_ID_ENV] == {"Ref": "NovaSonicModelId"} + assert bridge_config.SELF_FUNCTION_NAME_ENV in env + assert bridge_config.CALLBACK_URL_ENV in env + + +def _statements(policies: list[Any]) -> list[dict[str, Any]]: + statements: list[dict[str, Any]] = [] + for policy in policies: + if isinstance(policy, dict) and "Statement" in policy: + statements.extend(policy["Statement"]) + return statements + + +def _actions(statement: dict[str, Any]) -> list[str]: + actions = statement["Action"] + return [actions] if isinstance(actions, str) else list(actions) + + +def test_bridge_function_may_open_the_bidirectional_stream(template: dict[str, Any]) -> None: + """Least privilege: exactly InvokeModelWithBidirectionalStream on the Sonic model.""" + policies = template["Resources"]["BridgeFunction"]["Properties"]["Policies"] + bedrock = [ + statement + for statement in _statements(policies) + if any(action.startswith("bedrock:") for action in _actions(statement)) + ] + assert len(bedrock) == 1 + assert _actions(bedrock[0]) == ["bedrock:InvokeModelWithBidirectionalStream"] + resource = bedrock[0]["Resource"] + resource_str = resource.get("Fn::Sub", "") if isinstance(resource, dict) else str(resource) + assert "NovaSonicModelId" in resource_str + + +def test_bridge_function_may_post_to_connections(template: dict[str, Any]) -> None: + policies = template["Resources"]["BridgeFunction"]["Properties"]["Policies"] + manage = [ + statement + for statement in _statements(policies) + if "execute-api:ManageConnections" in _actions(statement) + ] + assert len(manage) == 1 + resource_str = manage[0]["Resource"]["Fn::Sub"] + assert "@connections" in resource_str + + +def test_bridge_function_may_async_invoke_itself(template: dict[str, Any]) -> None: + """The session holder is the same function self-invoked async (one-Lambda design).""" + policies = template["Resources"]["BridgeFunction"]["Properties"]["Policies"] + invoke = [ + statement + for statement in _statements(policies) + if "lambda:InvokeFunction" in _actions(statement) + ] + assert len(invoke) == 1 + resource_str = invoke[0]["Resource"]["Fn::Sub"] + assert "learn-voice-${StageName}-bridge" in resource_str + function_name = template["Resources"]["BridgeFunction"]["Properties"]["FunctionName"] + assert function_name == {"Fn::Sub": "learn-voice-${StageName}-bridge"} + + +def test_bridge_function_has_crud_on_the_sessions_table(template: dict[str, Any]) -> None: + policies = template["Resources"]["BridgeFunction"]["Properties"]["Policies"] + crud_refs = [ + policy["DynamoDBCrudPolicy"]["TableName"] + for policy in policies + if isinstance(policy, dict) and "DynamoDBCrudPolicy" in policy + ] + assert crud_refs == [{"Ref": "VoiceSessionsTable"}] + + +def test_sessions_table_uses_pk_sk_on_demand_and_ttl(template: dict[str, Any]) -> None: + """League's single-table PK/SK convention; TTL garbage-collects stale rows for free.""" + table = template["Resources"]["VoiceSessionsTable"]["Properties"] + assert table["BillingMode"] == "PAY_PER_REQUEST" + key_schema = {entry["AttributeName"]: entry["KeyType"] for entry in table["KeySchema"]} + assert key_schema == {"PK": "HASH", "SK": "RANGE"} + ttl = table["TimeToLiveSpecification"] + assert ttl == {"AttributeName": "expires_at", "Enabled": True} + + +def test_log_group_has_bounded_retention(template: dict[str, Any]) -> None: + log_group = template["Resources"]["BridgeFunctionLogGroup"]["Properties"] + assert isinstance(log_group["RetentionInDays"], int) + assert 0 < log_group["RetentionInDays"] <= 30 + + +def test_invoke_permission_covers_the_websocket_api(template: dict[str, Any]) -> None: + permission = template["Resources"]["WebSocketInvokePermission"]["Properties"] + assert permission["Action"] == "lambda:InvokeFunction" + assert permission["Principal"] == "apigateway.amazonaws.com" + assert "WebSocketApi" in permission["SourceArn"]["Fn::Sub"] + + +def test_outputs_expose_the_wss_url(template: dict[str, Any]) -> None: + outputs = template["Outputs"] + assert outputs["WebSocketUrl"]["Value"]["Fn::Sub"].startswith("wss://") + assert outputs["VoiceSessionsTableName"]["Value"] == {"Ref": "VoiceSessionsTable"} + + +@pytest.mark.skipif(shutil.which("sam") is None, reason="AWS SAM CLI not installed") +def test_sam_validate_if_sam_cli_present() -> None: + """Bonus sanity check only — deploying is a later, supervised task.""" + result = subprocess.run( + ["sam", "validate", "--region", "us-east-1", "--template-file", str(_TEMPLATE_PATH)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_voice_bridge_tokens.py b/tests/test_voice_bridge_tokens.py new file mode 100644 index 0000000..570cad8 --- /dev/null +++ b/tests/test_voice_bridge_tokens.py @@ -0,0 +1,180 @@ +"""Tests for ``infra/voice_bridge/tokens.py`` — short-lived voice-token verification. + +The token format is byte-for-byte symmetric with learn-api's session tokens +(``workers/learn-api/src/session.js``): ``base64url(JSON payload)`` + ``.`` + +``base64url(HMAC-SHA256(secret, payload))``, unpadded. t16 mints the JS +counterpart in the Worker with the same shared secret; this suite pins the +Python verify side (and the parity ``mint`` helper used by tests) so the two +implementations cannot drift. + +Spec honesty condition h7 groundwork: everything that is not a valid, current, +*approved* voice token verifies to ``None`` — and ``None`` means the bridge +never opens an upstream Bedrock connection (see test_voice_bridge_handler.py). +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json + +import pytest +from voice_bridge import tokens + +SECRET = "spike-shared-secret" +NOW = 1_800_000_000 + + +def mint(**overrides): + """A valid approved token, with per-test payload overrides applied post-mint.""" + token = tokens.mint_voice_token(SECRET, uid="20955789", approved=True, ttl_seconds=120, now=NOW) + if not overrides: + return token + body, _sig = token.rsplit(".", 1) + payload = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))) + payload.update(overrides) + new_body = ( + base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("ascii").rstrip("=") + ) + new_sig = ( + base64.urlsafe_b64encode( + hmac.new(SECRET.encode("utf-8"), new_body.encode("ascii"), hashlib.sha256).digest() + ) + .decode("ascii") + .rstrip("=") + ) + return f"{new_body}.{new_sig}" + + +def test_mint_then_verify_round_trip() -> None: + payload = tokens.verify_voice_token(SECRET, mint(), now=NOW) + assert payload is not None + assert payload["uid"] == "20955789" + assert payload["approved"] is True + assert payload["scope"] == tokens.TOKEN_SCOPE == "voice" + assert payload["v"] == 1 + assert payload["exp"] == NOW + 120 + + +def test_token_format_is_symmetric_with_session_js() -> None: + """Two unpadded base64url segments; the signature is HMAC-SHA256 of the body string. + + This is exactly what ``session.js``'s ``issueSession``/``hmacSign`` produce, + so a Worker-minted token with the same secret verifies here unchanged. + """ + token = mint() + body, sig = token.rsplit(".", 1) + assert "=" not in token + expected = ( + base64.urlsafe_b64encode( + hmac.new(SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest() + ) + .decode("ascii") + .rstrip("=") + ) + assert sig == expected + payload = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))) + assert set(payload) == {"v", "scope", "uid", "approved", "iat", "exp", "sid"} + + +def test_uid_is_coerced_to_string_like_session_js() -> None: + token = tokens.mint_voice_token(SECRET, uid=20955789, approved=True, ttl_seconds=60, now=NOW) + payload = tokens.verify_voice_token(SECRET, token, now=NOW) + assert payload is not None + assert payload["uid"] == "20955789" + + +def test_mint_refuses_an_empty_secret() -> None: + """Same posture as session.js: no secret configured -> throw, never sign with ''.""" + with pytest.raises(ValueError): + tokens.mint_voice_token("", uid="1", approved=True, ttl_seconds=60, now=NOW) + + +def test_mint_refuses_unapproved() -> None: + """Minting is for approved learners only — the bridge is the approved tier's door.""" + with pytest.raises(ValueError): + tokens.mint_voice_token(SECRET, uid="1", approved=False, ttl_seconds=60, now=NOW) + + +@pytest.mark.parametrize( + "bad_token", + [ + None, + "", + "no-dot-here", + ".leading-dot", + "not-base64.!!!", + "AAAA.BBBB", + ], +) +def test_malformed_tokens_verify_to_none(bad_token) -> None: + assert tokens.verify_voice_token(SECRET, bad_token, now=NOW) is None + + +def test_expired_token_is_rejected() -> None: + token = mint() + assert tokens.verify_voice_token(SECRET, token, now=NOW + 121) is None + + +def test_exp_boundary_is_exclusive_like_session_js() -> None: + """session.js rejects exp <= now; the Python side must agree exactly.""" + token = mint() + assert tokens.verify_voice_token(SECRET, token, now=NOW + 120) is None + assert tokens.verify_voice_token(SECRET, token, now=NOW + 119) is not None + + +def test_unapproved_payload_is_rejected() -> None: + assert tokens.verify_voice_token(SECRET, mint(approved=False), now=NOW) is None + + +def test_missing_approved_claim_is_rejected() -> None: + token = mint() + body, _sig = token.rsplit(".", 1) + payload = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))) + del payload["approved"] + new_body = ( + base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("ascii").rstrip("=") + ) + new_sig = ( + base64.urlsafe_b64encode( + hmac.new(SECRET.encode("utf-8"), new_body.encode("ascii"), hashlib.sha256).digest() + ) + .decode("ascii") + .rstrip("=") + ) + assert tokens.verify_voice_token(SECRET, f"{new_body}.{new_sig}", now=NOW) is None + + +def test_wrong_scope_is_rejected() -> None: + """A learn-api *session* token must not open the voice bridge (no cookie reuse).""" + assert tokens.verify_voice_token(SECRET, mint(scope="session"), now=NOW) is None + + +def test_missing_uid_is_rejected() -> None: + assert tokens.verify_voice_token(SECRET, mint(uid=""), now=NOW) is None + + +def test_wrong_version_is_rejected() -> None: + assert tokens.verify_voice_token(SECRET, mint(v=2), now=NOW) is None + + +def test_tampered_payload_is_rejected() -> None: + token = mint() + body, sig = token.rsplit(".", 1) + payload = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))) + payload["uid"] = "999" + forged_body = ( + base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("ascii").rstrip("=") + ) + assert tokens.verify_voice_token(SECRET, f"{forged_body}.{sig}", now=NOW) is None + + +def test_wrong_secret_is_rejected() -> None: + assert tokens.verify_voice_token("other-secret", mint(), now=NOW) is None + + +def test_empty_secret_verifies_nothing() -> None: + """The template's VoiceTokenSecretValue defaults to '' — the bridge stays shut.""" + assert tokens.verify_voice_token("", mint(), now=NOW) is None + assert tokens.verify_voice_token(None, mint(), now=NOW) is None diff --git a/uv.lock b/uv.lock index 90fd72d..bb7e564 100644 --- a/uv.lock +++ b/uv.lock @@ -489,6 +489,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "pyyaml" }, { name = "teken" }, ] @@ -504,6 +505,7 @@ dev = [ { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=4.1" }, { name = "pytest-xdist", specifier = ">=3.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "teken", specifier = ">=0.8" }, ] From b384ec0c4186edad8ac125296a1e6e3319e5bd77 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 10:54:50 +0300 Subject: [PATCH 06/24] =?UTF-8?q?feat(worker):=20consent=20gate=20on=20bot?= =?UTF-8?q?h=20sign-in=20paths=20=E2=80=94=20pending-consent=20session,=20?= =?UTF-8?q?accept/decline=20endpoints,=20zero=20D1=20writes=20pre-consent?= =?UTF-8?q?=20(t5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements spec c9/h1 + decision c19: an unconsented sign-in (web /api/auth/callback AND device-flow poll) no longer writes anything to D1. It mints a short-lived (10 min) pending-consent session — the marker lives in the stateless signed token, so nothing is persisted — and lands the user on the consent surface: the web callback 302s to /learn/consent/ with a pending cookie; the device poll returns status "consent_required" plus a pending Bearer token and the consent requirement so the CLI can prompt. Pending sessions can only GET /api/me (reports pending_consent + consent_required incl. TERMS_VERSION, never sliding-refreshes), use the consent endpoints, and log out; progress/record/tutor reject them with a structured 403 consent_required before touching D1 or inference — extending the resource-gate invariant one level (signed-out AND pending-consent traffic can never trigger a model call). New endpoints: - GET /api/consent public: terms_version, effective_date, policy links - POST /api/consent/accept records consent (exact shared TERMS_VERSION) FIRST, THEN upserts the learner (the consents table's missing FK makes that order possible), revokes the pending token, upgrades to a full session (Set-Cookie + body Bearer, symmetric across faces) - POST /api/consent/decline pending only: revokes the session, clears the cookie, zero rows ever written; a full session gets 409 already_consented (withdrawal = deletion is t7) The consent-satisfaction check is factored into consent.js#consentSatisfiesCurrentTerms for t6 to extend with the version-mismatch (re-consent) rule; re-consent itself is deliberately NOT implemented here. TDD: test/consent.test.js was written red-first — the h1 pair (fresh sign-in on BOTH paths asserts zero D1 writes) failed against the previous Worker with a logged learners insert, then the gate made it green. The D1 test stub now logs every write statement so "zero writes" and write ORDER (consents before learners before records) are asserted literally. The two pre-existing sign-in tests now seed a consented user, preserving their intent (post-consent sign-in still upserts + issues a full session). Tests: workers/learn-api 74/74 (was 58); pytest 458 passed, 12 skipped; markdownlint clean on the README (route table + t10/t12 endpoint shapes documented). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- workers/learn-api/README.md | 109 ++++++-- workers/learn-api/src/auth.js | 27 +- workers/learn-api/src/consent.js | 38 +++ workers/learn-api/src/index.js | 206 +++++++++++++-- workers/learn-api/src/session.js | 24 +- workers/learn-api/src/terms.js | 8 +- workers/learn-api/test/consent.test.js | 343 +++++++++++++++++++++++++ workers/learn-api/test/helpers.js | 33 ++- workers/learn-api/test/worker.test.js | 12 +- 9 files changed, 752 insertions(+), 48 deletions(-) create mode 100644 workers/learn-api/src/consent.js create mode 100644 workers/learn-api/test/consent.test.js diff --git a/workers/learn-api/README.md b/workers/learn-api/README.md index acd6ca1..e3766a4 100644 --- a/workers/learn-api/README.md +++ b/workers/learn-api/README.md @@ -13,6 +13,20 @@ spends inference tokens — is unreachable without a valid session. This is proven by `test/worker.test.js` ("signed-out `/api/tutor` never calls inference"), not merely asserted. +The consent-gate invariant (spec c9/h1, decision c19) sits directly on top: +**neither sign-in path writes anything to D1 until the learner has accepted +the current Terms/Privacy version.** An unconsented sign-in (web callback or +device poll) mints a short-lived **pending-consent session** — a stateless +signed token marked `pending_consent: true`, TTL 10 minutes — whose only +capabilities are `GET /api/me`, the consent endpoints, and logout. Every other +authed route rejects it with a structured `403 consent_required` before +touching D1 or inference. Accept records the consent row **first**, then +creates the learner row (`consents` deliberately has no FK to `learners` so +that order is possible), and upgrades to a full session. Decline revokes the +pending session — nothing was ever written. Proven by `test/consent.test.js`, +which asserts **zero D1 write statements** on both paths via a write log in +the D1 stub. + No third-party runtime deps: the Worker uses only Web-standard APIs (`fetch`, `Request`/`Response`, `crypto.subtle`, `btoa`/`atob`), so the same code runs in `wrangler dev`, in production, and under `node --test`. @@ -23,16 +37,22 @@ No third-party runtime deps: the Worker uses only Web-standard APIs (`fetch`, | --- | --- | --- | --- | | GET | `/api/health` | public | Liveness. | | GET | `/api/auth/login` | public | Web OAuth: 302 to GitHub, sets a `oauth_state` cookie. | -| GET | `/api/auth/callback` | public | Web OAuth: exchange code, upsert learner, set `session` cookie, redirect to the site. | -| POST | `/api/auth/device` | public | Device flow `start` / `poll` for the CLI + MCP (wave-3 t12). | -| POST | `/api/auth/logout` | session | Revoke the current session (KV tombstone) and clear the cookie. | -| GET | `/api/me` | session | Identity + session expiry; auto-refreshes a near-stale session. | -| GET | `/api/progress/:subject` | session | Ledger-derived `progress.json`-shaped payload. | -| POST | `/api/record` | session | Validate the `recorded` shape and append it to the ledger. | -| POST | `/api/tutor` | session | Broker: forward to `INFERENCE_URL` (a served inference endpoint). | - -Sessions are stateless HMAC-signed tokens (short TTL, ~1h) accepted either as an -`Authorization: Bearer ` header (CLI/MCP) or a `session` cookie (web). +| GET | `/api/auth/callback` | public | Web OAuth: exchange code. Consented user: upsert learner, set full `session` cookie, redirect to the site. Unconsented: set a pending-consent cookie, redirect to `/learn/consent/`, **zero D1 writes**. | +| POST | `/api/auth/device` | public | Device flow `start` / `poll` for the CLI + MCP (wave-3 t12). Unconsented poll returns `status: "consent_required"` + a pending Bearer token, zero D1 writes. | +| GET | `/api/consent` | public | What consent is currently required: `terms_version`, `effective_date`, policy links. | +| POST | `/api/consent/accept` | session or pending | Record consent for the exact current `TERMS_VERSION`, **then** upsert the learner, upgrade to a full session (cookie + body token). | +| POST | `/api/consent/decline` | pending only | Revoke the pending session, clear the cookie, nothing ever written. Full session gets `409 already_consented`. | +| POST | `/api/auth/logout` | session or pending | Revoke the current session (KV tombstone) and clear the cookie. | +| GET | `/api/me` | session or pending | Identity + session expiry; auto-refreshes a near-stale full session. Pending session: reports `pending_consent: true` + `consent_required`, never refreshes. | +| GET | `/api/progress/:subject` | consented | Ledger-derived `progress.json`-shaped payload. Pending session: `403 consent_required`. | +| POST | `/api/record` | consented | Validate the `recorded` shape and append it to the ledger. Pending session: `403 consent_required`. | +| POST | `/api/tutor` | consented | Broker: forward to `INFERENCE_URL` (a served inference endpoint). Pending session: `403 consent_required`, zero inference calls. | + +Sessions are stateless HMAC-signed tokens (short TTL, ~1h; pending-consent +tokens 10 min) accepted either as an `Authorization: Bearer ` header +(CLI/MCP) or a `session` cookie (web). Pending-consent tokens carry +`pending_consent: true` in their signed payload — no server-side row backs +them, which is how sign-in stays write-free until consent. ## Storage @@ -46,6 +66,10 @@ Sessions are stateless HMAC-signed tokens (short TTL, ~1h) accepted either as an - `records (...)` — append-only ledger. Every `POST /api/record` inserts one row; rows are never updated or deleted. Derived numbers (`score`/`grade`/`points`) are rejected before insert. + - `consents (github_user_id, terms_version, granted_at)` — one row per + accepted terms version. Deliberately **no FK to `learners`**: the consent + row is recorded *before* the learner row exists (accept-order contract). + For any learner, this is the first row that ever exists for them. ## Local development @@ -188,8 +212,35 @@ POST /api/auth/device { "action": "poll", "device_code": "" } -> 200 { "status": "pending", "slow_down": false } -> 200 { "status": "complete", "token": "", "token_type": "Bearer", "expires_at": , "learner": { github_user_id, display_name } } + -> 200 { "status": "consent_required", "token": "", + "token_type": "Bearer", "expires_at": , + "consent_required": { terms_version, effective_date, + terms_url, privacy_url }, + "learner": { github_user_id, display_name } } +``` + +On `consent_required` (a first sign-in, or no consent recorded yet): nothing is +stored server-side; the CLI shows the notice (render `consent_required`, link +both policy URLs) and prompts. The returned `token` is a **pending-consent** +Bearer token (10 min TTL) valid only for `GET /api/me`, the consent endpoints, +and logout — use it to call: + +```text +POST /api/consent/accept Authorization: Bearer + -> 200 { ok: true, status: "consented", + consent: { terms_version, granted_at }, + token: "", token_type: "Bearer", expires_at: , + learner: { github_user_id, display_name } } + +POST /api/consent/decline Authorization: Bearer + -> 200 { ok: true, status: "declined", stored: false } // token revoked, zero rows written + -> 409 { error: "already_consented", ... } // on a full session ``` +After accept, replace the stored token with the returned full `token` (the +pending one is revoked). Do **not** re-poll the device code — GitHub codes are +one-shot; the pending token is the continuation. + The CLI stores `token` and sends it as `Authorization: Bearer ` on every subsequent call. Poll at `interval` seconds; back off on `slow_down`. Refresh by calling `GET /api/me` (which re-issues when near expiry) or re-running the device @@ -222,23 +273,49 @@ Signed-in panels hydrate client-side by calling this API with credentials ```text GET /api/me - -> 200 { authenticated: true, learner: { github_user_id, display_name }, + -> 200 { authenticated: true, pending_consent: false, + learner: { github_user_id, display_name }, session: { expires_at, refreshed } } + -> 200 { authenticated: true, pending_consent: true, + consent_required: { terms_version, effective_date, + terms_url, privacy_url }, + learner: { github_user_id, display_name }, + session: { expires_at, refreshed: false } } -> 401 (not signed in) — render the signed-out state, make NO further calls ``` Sign-in button: link to `GET /api/auth/login`. Sign-out: `POST /api/auth/logout`. Progress/record shapes are identical to the CLI's above. +### For t10 (the consent page, `/learn/consent/`) + +An unconsented web sign-in 302s from the OAuth callback to `/learn/consent/` +carrying a pending-consent `session` cookie (10 min TTL). The page: + +1. Calls `GET /api/me` with credentials. `pending_consent: true` → render the + notice from `consent_required` (version, effective date, `terms_url`, + `privacy_url`). A `401` here means the pending session expired — offer the + sign-in link again. (`GET /api/consent` serves the same requirement shape + without a session, e.g. for pre-rendering.) +2. **Accept** → `POST /api/consent/accept` with credentials. The response sets + the upgraded full-session cookie itself (the body token can be ignored on + the web) — then navigate into `/learn/` signed in. +3. **Decline** → `POST /api/consent/decline` with credentials. The cookie is + cleared and the session revoked; confirm to the user that nothing was + stored (`stored: false` in the response is that guarantee, test-proven). + ## Testing ```bash node --test ``` -34 tests cover session sign/verify/expiry, `recorded` validation (including the +74 tests cover session sign/verify/expiry, `recorded` validation (including the `score`/`grade`/`points` rejection), the full record round-trip, per-learner -ledger isolation, web + device OAuth flows, and — critically — that a signed-out -`/api/tutor` request returns `401` with **zero** inference calls. Tests invoke -the Worker's `fetch` handler directly with in-memory KV/D1 stubs; no network and -no wrangler are needed. +ledger isolation, web + device OAuth flows, the consent gate (zero D1 writes on +both unconsented sign-in paths, the pending-session 403 wall, accept ordering — +consent row before learner row — and write-free decline), and — critically — +that a signed-out `/api/tutor` request returns `401` with **zero** inference +calls. Tests invoke the Worker's `fetch` handler directly with in-memory KV/D1 +stubs (the D1 stub logs every write statement, making "zero writes" literal); +no network and no wrangler are needed. diff --git a/workers/learn-api/src/auth.js b/workers/learn-api/src/auth.js index 42f0be8..d8609f9 100644 --- a/workers/learn-api/src/auth.js +++ b/workers/learn-api/src/auth.js @@ -8,7 +8,7 @@ // by test (worker.test.js: "signed-out /api/tutor never calls inference"). import { HttpError, parseCookies } from "./util.js"; -import { verifySession } from "./session.js"; +import { verifySession, isPendingConsent } from "./session.js"; /** Extract a session token from a Bearer header (CLI/MCP) or cookie (web). */ export function extractToken(request) { @@ -48,3 +48,28 @@ export async function requireAuth(request, env) { } return payload; } + +/** + * Require a valid, CONSENTED session — requireAuth plus the pending-consent + * gate (spec decision c19). A pending-consent token authenticates the learner + * but grants nothing beyond /api/me, the consent endpoints, and logout; every + * learner-scoped route (progress, record, tutor, ...) passes through here and + * rejects it with a structured 403 BEFORE touching D1 or inference. + * + * t5 gates on the token's own marker only: a full session was necessarily + * issued via consent accept (or a consented sign-in), so no D1 read is needed + * here. t6 (re-consent on version bump) adds the stored-version check. + * @returns {object} the verified, consented session payload. + */ +export async function requireConsented(request, env) { + const payload = await requireAuth(request, env); + if (isPendingConsent(payload)) { + throw new HttpError( + 403, + "consent_required", + "Consent to the current Terms of Use and Privacy Policy is required first.", + "Review GET /api/consent, then POST /api/consent/accept — or /api/consent/decline to leave with nothing stored.", + ); + } + return payload; +} diff --git a/workers/learn-api/src/consent.js b/workers/learn-api/src/consent.js new file mode 100644 index 0000000..c87eef2 --- /dev/null +++ b/workers/learn-api/src/consent.js @@ -0,0 +1,38 @@ +// Consent policy — the one place that decides whether a learner's recorded +// consent satisfies the currently published Terms/Privacy version. +// +// Spec c9/c19 (task t5): sign-in consults this BEFORE writing anything to D1. +// No satisfying consent -> a pending-consent session is issued and zero rows +// are written; the learner row is only upserted after POST /api/consent/accept +// records a consent row (in that order — the consents table deliberately has +// no FK to learners so consent can exist first). + +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "./terms.js"; + +/** + * Does this consent row (from db.js getConsent — the learner's most recent + * consent, or null) satisfy the currently published terms? + * + * t5 scope: ANY recorded consent satisfies. Task t6 extends this single + * predicate to require `consent.terms_version === TERMS_VERSION` exactly, so + * a published version bump routes the learner back to the consent screen + * (re-consent, spec h2). Extend it HERE — sign-in paths and t6's re-consent + * check must share one definition of "consented". + */ +export function consentSatisfiesCurrentTerms(consent) { + return consent != null; +} + +/** + * What consent is currently required — the shape clients render the notice + * from. Embedded in /api/me (pending sessions), the device-poll + * `consent_required` response, and GET /api/consent. + */ +export function consentRequirement() { + return { + terms_version: TERMS_VERSION, + effective_date: TERMS_EFFECTIVE_DATE, + terms_url: "/learn/terms/", + privacy_url: "/learn/privacy/", + }; +} diff --git a/workers/learn-api/src/index.js b/workers/learn-api/src/index.js index 75a18ba..8597db4 100644 --- a/workers/learn-api/src/index.js +++ b/workers/learn-api/src/index.js @@ -7,16 +7,33 @@ // GET /api/auth/login public web OAuth: redirect to GitHub // GET /api/auth/callback public web OAuth: exchange code, set cookie // POST /api/auth/device public device flow start/poll (CLI/MCP, t12) -// POST /api/auth/logout auth revoke the current session -// GET /api/me auth who am I + session expiry (auto-refresh) -// GET /api/progress/:subject auth ledger-derived progress payload -// POST /api/record auth append a recorded result to the ledger -// POST /api/tutor auth broker -> env.INFERENCE_URL (model call) +// GET /api/consent public what consent is currently required +// POST /api/consent/accept auth* record consent, THEN create the learner, +// upgrade pending -> full session +// POST /api/consent/decline auth* drop a pending session; zero rows written +// POST /api/auth/logout auth* revoke the current session +// GET /api/me auth* who am I + session expiry (auto-refresh) +// GET /api/progress/:subject consent ledger-derived progress payload +// POST /api/record consent append a recorded result to the ledger +// POST /api/tutor consent broker -> env.INFERENCE_URL (model call) // -// Resource-gate invariant: requireAuth() runs before the body of every auth -// route. POST /api/tutor is the only route that spends model tokens, and it is -// unreachable without a valid session — signed-out traffic can NEVER trigger a -// model call. Proven in worker.test.js. +// auth* = any valid session, INCLUDING pending-consent (requireAuth). +// consent = full session only; pending-consent tokens get a structured 403 +// (requireConsented) before the route body runs. +// +// Resource-gate invariant: requireAuth()/requireConsented() runs before the +// body of every auth route. POST /api/tutor is the only route that spends +// model tokens, and it is unreachable without a valid CONSENTED session — +// signed-out AND pending-consent traffic can NEVER trigger a model call. +// Proven in worker.test.js + consent.test.js. +// +// Consent-gate invariant (spec c9/h1, decision c19): NEITHER sign-in path +// (web callback, device poll) writes to D1 unless a recorded consent already +// satisfies the current terms. An unconsented sign-in gets a short-lived +// pending-consent session (see session.js) whose only capabilities are +// viewing the consent requirement and accepting/declining it. Accept records +// the consent row FIRST, then upserts the learner. Decline revokes the +// session with nothing ever written. Proven in consent.test.js. import { HttpError, @@ -26,8 +43,16 @@ import { cookie, outboundFetch, } from "./util.js"; -import { requireAuth } from "./auth.js"; -import { issueSession, needsRefresh, DEFAULT_TTL_SECONDS } from "./session.js"; +import { requireAuth, requireConsented } from "./auth.js"; +import { + issueSession, + needsRefresh, + isPendingConsent, + DEFAULT_TTL_SECONDS, + PENDING_CONSENT_TTL_SECONDS, +} from "./session.js"; +import { consentSatisfiesCurrentTerms, consentRequirement } from "./consent.js"; +import { TERMS_VERSION } from "./terms.js"; import { authorizeUrl, exchangeCode, @@ -35,7 +60,14 @@ import { startDevice, pollDevice, } from "./github.js"; -import { upsertLearner, getLearner, insertRecord, listRecords } from "./db.js"; +import { + upsertLearner, + getLearner, + insertRecord, + listRecords, + getConsent, + recordConsent, +} from "./db.js"; import { deriveProgress } from "./progress.js"; import { CONTRACT_VERSION, @@ -82,6 +114,9 @@ async function route(request, env, ctx) { if (method === "GET" && path === "/api/auth/login") return handleLogin(request, env); if (method === "GET" && path === "/api/auth/callback") return handleCallback(request, env); if (method === "POST" && path === "/api/auth/device") return handleDevice(request, env); + if (method === "GET" && path === "/api/consent") return handleConsentGet(env); + if (method === "POST" && path === "/api/consent/accept") return handleConsentAccept(request, env); + if (method === "POST" && path === "/api/consent/decline") return handleConsentDecline(request, env); if (method === "POST" && path === "/api/auth/logout") return handleLogout(request, env); if (method === "GET" && path === "/api/me") return handleMe(request, env); if (method === "POST" && path === "/api/record") return handleRecord(request, env); @@ -145,6 +180,22 @@ async function handleCallback(request, env) { } const accessToken = await exchangeCode(env, code); const user = await fetchUser(env, accessToken); + + // CONSENT GATE (spec c9/c19): no satisfying consent -> NO D1 write. The + // OAuth redirect flow stays intact — the user lands signed-in-PENDING on + // the consent page with a short-lived pending-consent cookie; the learner + // row is only created by POST /api/consent/accept. + const consent = await getConsent(env, user.uid); + if (!consentSatisfiesCurrentTerms(consent)) { + const { token } = await issueSession(env, user, PENDING_CONSENT_TTL_SECONDS, { + pendingConsent: true, + }); + const headers = new Headers({ Location: consentPageUrl(env, url) }); + headers.append("Set-Cookie", cookie("session", token, { maxAge: PENDING_CONSENT_TTL_SECONDS })); + headers.append("Set-Cookie", cookie("oauth_state", "", { maxAge: 0 })); + return new Response(null, { status: 302, headers }); + } + await upsertLearner(env, user); const { token } = await issueSession(env, user); const dest = env.APP_URL || `${publicOrigin(env, url)}/learn/`; @@ -174,6 +225,26 @@ async function handleDevice(request, env) { const r = await pollDevice(env, body.device_code); if (r.pending) return jsonResponse(200, { status: "pending", slow_down: !!r.slow_down }); const user = await fetchUser(env, r.access_token); + + // CONSENT GATE (spec c9/c19), device face: same rule as the web callback — + // no satisfying consent, no D1 write. The CLI gets a pending-consent + // Bearer token plus the consent requirement so it can prompt; it then + // drives POST /api/consent/accept (returns the full token) or /decline. + const consent = await getConsent(env, user.uid); + if (!consentSatisfiesCurrentTerms(consent)) { + const { token, payload } = await issueSession(env, user, PENDING_CONSENT_TTL_SECONDS, { + pendingConsent: true, + }); + return jsonResponse(200, { + status: "consent_required", + token, + token_type: "Bearer", + expires_at: payload.exp, + consent_required: consentRequirement(), + learner: { github_user_id: user.uid, display_name: user.name }, + }); + } + await upsertLearner(env, user); const { token, payload } = await issueSession(env, user); return jsonResponse(200, { @@ -187,19 +258,91 @@ async function handleDevice(request, env) { throw new HttpError(400, "bad_action", "action must be 'start' or 'poll'."); } -// --- auth routes ----------------------------------------------------------- +// --- consent routes (spec c9/c19, task t5) ---------------------------------- -async function handleLogout(request, env) { +// What consent is currently required. Public: the consent page (and any CLI) +// can render the notice — version, effective date, policy links — without a +// session; a signed-out reader learns nothing personal here. +function handleConsentGet(env) { + return jsonResponse(200, consentRequirement()); +} + +// Accept the current terms. Order is the contract (spec c9): the consent row +// is recorded FIRST (the consents table has no FK to learners precisely so it +// can exist alone), and only THEN is the learner row created. The pending +// session is revoked and a full session issued — returned BOTH as a +// `Set-Cookie` (web) and in the JSON body as a Bearer token (device/CLI), so +// the two faces stay symmetric with the sign-in paths. +// Idempotent for an already-consented session: same-version re-accept just +// refreshes granted_at (db.js recordConsent upserts). +async function handleConsentAccept(request, env) { + const session = await requireAuth(request, env); // pending-consent allowed — that's the point. + const consent = await recordConsent(env, session.uid, TERMS_VERSION); + await upsertLearner(env, { uid: session.uid, name: session.name }); + await revokeSession(env, session); // the pending (or prior) token dies with the upgrade + const { token, payload } = await issueSession(env, { uid: session.uid, name: session.name }); + return jsonResponse( + 200, + { + ok: true, + status: "consented", + consent: { terms_version: consent.terms_version, granted_at: consent.granted_at }, + token, + token_type: "Bearer", + expires_at: payload.exp, + learner: { github_user_id: session.uid, display_name: session.name }, + }, + { "Set-Cookie": cookie("session", token, { maxAge: DEFAULT_TTL_SECONDS }) }, + ); +} + +// Decline the current terms. Pending sessions only: the session is revoked, +// the cookie cleared, and — because the sign-in paths wrote nothing — there is +// nothing to erase. A FULL session declining is a different act (consent +// withdrawal = data deletion, spec c11/t7), so it is refused here rather than +// silently half-handled. +async function handleConsentDecline(request, env) { const session = await requireAuth(request, env); - if (env.SESSIONS && session.sid) { - // Tombstone until well past the token's own expiry. - await env.SESSIONS.put(`revoked:${session.sid}`, "1", { expirationTtl: DEFAULT_TTL_SECONDS * 2 }); + if (!isPendingConsent(session)) { + throw new HttpError( + 409, + "already_consented", + "This session already carries recorded consent; decline applies only before accepting.", + "To withdraw consent (which deletes your stored data), use the self-serve delete flow.", + ); } + await revokeSession(env, session); + return jsonResponse( + 200, + { ok: true, status: "declined", stored: false }, + { "Set-Cookie": cookie("session", "", { maxAge: 0 }) }, + ); +} + +// --- auth routes ----------------------------------------------------------- + +async function handleLogout(request, env) { + const session = await requireAuth(request, env); // pending-consent sessions may log out too + await revokeSession(env, session); return jsonResponse(200, { ok: true }, { "Set-Cookie": cookie("session", "", { maxAge: 0 }) }); } async function handleMe(request, env) { const session = await requireAuth(request, env); + + // Pending-consent session (c19): report the state + what must be consented + // to. No D1 read (no learner row exists), no sliding refresh — a pending + // session stays short-lived and either upgrades via accept or expires. + if (isPendingConsent(session)) { + return jsonResponse(200, { + authenticated: true, + pending_consent: true, + consent_required: consentRequirement(), + learner: { github_user_id: session.uid, display_name: session.name }, + session: { expires_at: session.exp, refreshed: false }, + }); + } + const learner = (await getLearner(env, session.uid)) || { github_user_id: session.uid, display_name: session.name, @@ -214,6 +357,7 @@ async function handleMe(request, env) { 200, { authenticated: true, + pending_consent: false, learner: { github_user_id: learner.github_user_id, display_name: learner.display_name, @@ -225,13 +369,13 @@ async function handleMe(request, env) { } async function handleProgress(request, env, subject) { - const session = await requireAuth(request, env); + const session = await requireConsented(request, env); const rows = await listRecords(env, session.uid, subject); return jsonResponse(200, deriveProgress(subject, session.uid, rows)); } async function handleRecord(request, env) { - const session = await requireAuth(request, env); + const session = await requireConsented(request, env); const body = await readJson(request); const subject = body.subject; const recorded = body.recorded; @@ -273,8 +417,10 @@ async function handleRecord(request, env) { } async function handleTutor(request, env, ctx) { - // AUTH FIRST — before any inference call. This ordering is the guarantee. - const session = await requireAuth(request, env); + // AUTH + CONSENT FIRST — before any inference call. This ordering is the + // guarantee: neither signed-out nor pending-consent traffic reaches the + // model endpoint. + const session = await requireConsented(request, env); if (!env.INFERENCE_URL) { throw new HttpError( 503, @@ -307,6 +453,24 @@ function requireConfig(env, key) { } } +// Tombstone a session id in KV until well past the token's own expiry. +// Shared by logout, consent decline (drop the pending session), and consent +// accept (the superseded pending token must not outlive its upgrade). +async function revokeSession(env, session) { + if (env.SESSIONS && session.sid) { + await env.SESSIONS.put(`revoked:${session.sid}`, "1", { + expirationTtl: DEFAULT_TTL_SECONDS * 2, + }); + } +} + +// Where an unconsented web sign-in lands: the consent notice page (t10), +// served by the static site under the /learn mount. +function consentPageUrl(env, url) { + const base = (env.APP_URL || `${publicOrigin(env, url)}/learn/`).replace(/\/+$/, ""); + return `${base}/consent/`; +} + function publicOrigin(env, url) { return env.PUBLIC_URL ? env.PUBLIC_URL.replace(/\/+$/, "") : url.origin; } diff --git a/workers/learn-api/src/session.js b/workers/learn-api/src/session.js index d13d98d..f7073bc 100644 --- a/workers/learn-api/src/session.js +++ b/workers/learn-api/src/session.js @@ -2,24 +2,38 @@ // // A session is a short-lived signed token — NOT a server-stored session row. // Shape: base64url(JSON payload) + "." + base64url(HMAC-SHA256(payload)) -// Payload: { v, uid, name, iat, exp, sid } +// Payload: { v, uid, name, iat, exp, sid [, pending_consent] } // // Only the GitHub user id (`uid`), the display name (`name`), and timestamps // live in the token. No password, no email — ever. `sid` is an opaque id used // only for revocation tombstones in KV (see auth.js). The same token works as // an `Authorization: Bearer` header for the CLI/MCP and as a `session` cookie // for the web, so one issuer serves every face. +// +// Pending-consent sessions (spec decision c19, task t5): a sign-in with no +// recorded consent mints a token with `pending_consent: true` and an extra +// short TTL. Because sessions are stateless, the marker lives IN the signed +// payload — no D1/KV row backs it, which is the whole point: nothing is +// persisted for the learner until they accept. A pending token's only +// capabilities are /api/me, the consent endpoints, and logout (auth.js +// requireConsented rejects it everywhere else), and it is never +// sliding-refreshed — it expires unless upgraded via consent accept. import { b64urlStr, b64urlToStr, hmacSign, constantTimeEqual, nowSeconds } from "./util.js"; export const DEFAULT_TTL_SECONDS = 3600; // 1 hour — short-lived by design. export const REFRESH_WINDOW_SECONDS = 900; // re-issue when <15 min remain. +// Enough to read the consent notice and both policy pages; not enough to +// linger. Decline or expiry leaves zero trace (nothing was ever written). +export const PENDING_CONSENT_TTL_SECONDS = 600; // 10 minutes. /** * Mint a signed session token for a learner. + * @param {object} [opts] + * @param {boolean} [opts.pendingConsent] mark the token pending-consent (c19). * @returns {{ token: string, payload: object }} */ -export async function issueSession(env, learner, ttlSeconds = DEFAULT_TTL_SECONDS) { +export async function issueSession(env, learner, ttlSeconds = DEFAULT_TTL_SECONDS, opts = {}) { if (!env || !env.SESSION_SECRET) { throw new Error("SESSION_SECRET is not configured"); } @@ -31,12 +45,18 @@ export async function issueSession(env, learner, ttlSeconds = DEFAULT_TTL_SECOND iat, exp: iat + ttlSeconds, sid: crypto.randomUUID(), + ...(opts.pendingConsent ? { pending_consent: true } : {}), }; const body = b64urlStr(JSON.stringify(payload)); const sig = await hmacSign(env.SESSION_SECRET, body); return { token: `${body}.${sig}`, payload }; } +/** True when the (verified) payload is a pending-consent session. */ +export function isPendingConsent(payload) { + return !!(payload && payload.pending_consent === true); +} + /** * Verify a token's signature and expiry. * @returns {object|null} the payload if valid & unexpired, else null. diff --git a/workers/learn-api/src/terms.js b/workers/learn-api/src/terms.js index 0bb0f11..54bef1d 100644 --- a/workers/learn-api/src/terms.js +++ b/workers/learn-api/src/terms.js @@ -7,7 +7,9 @@ // // See ../../../shared/terms-version.mjs for why that file is a plain ES // module and not JSON, and for the re-consent contract TERMS_VERSION backs -// (plan task t6, spec honesty condition h2). Not wired into any route yet — -// this task (t1) only establishes the shared source; recording/consulting -// `consent.terms_version` is t5/t6's job. +// (plan task t6, spec honesty condition h2). Wired in by t5: consent.js +// renders the requirement from it and POST /api/consent/accept stamps the +// consent row's `terms_version` with this exact value. Consulting it for +// version-mismatch (re-consent) is t6's job — extend +// consent.js#consentSatisfiesCurrentTerms. export { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../../shared/terms-version.mjs"; diff --git a/workers/learn-api/test/consent.test.js b/workers/learn-api/test/consent.test.js new file mode 100644 index 0000000..4c21641 --- /dev/null +++ b/workers/learn-api/test/consent.test.js @@ -0,0 +1,343 @@ +// Consent gate (spec c9/h1, decision c19; plan task t5). +// +// The load-bearing test here is the h1 pair: a FRESH sign-in — a GitHub user +// with no recorded consent — must cause ZERO D1 writes on BOTH sign-in paths +// (web /api/auth/callback and device-flow poll) until consent is accepted. +// The D1Stub logs every write statement into `db.writes`, so "zero writes" +// is asserted literally, not inferred from table sizes. +// +// Written red-first: against the pre-t5 Worker both h1 tests FAIL because +// handleCallback and handleDevice(poll) call upsertLearner unconditionally. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import worker from "../src/index.js"; +import { GH } from "../src/github.js"; +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../src/terms.js"; +import { + makeEnv, + makeFetchStub, + mintToken, + authedRequest, + jsonResp, + seedConsent, +} from "./helpers.js"; + +const BASE = "https://learn-api.example"; +const INFERENCE_URL = "https://inference.example/v1/messages"; + +function call(env, request) { + return worker.fetch(request, env, { waitUntil() {} }); +} + +/** env whose GitHub endpoints authenticate `user` (fresh, unconsented). */ +function envWithGitHubUser(user = { id: 900, login: "fresh", name: "Fresh" }, overrides = {}) { + const fetchStub = makeFetchStub({ + [GH.token]: () => jsonResp({ access_token: "gho_x", token_type: "bearer" }), + [GH.user]: () => jsonResp(user), + }); + return makeEnv({ FETCH: fetchStub, APP_URL: "https://agentculture.org/learn/", ...overrides }); +} + +function webCallbackRequest() { + return new Request(`${BASE}/api/auth/callback?code=abc&state=xyz`, { + headers: { Cookie: "oauth_state=xyz" }, + }); +} + +function devicePollRequest() { + return new Request(`${BASE}/api/auth/device`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "poll", device_code: "dc_123" }), + }); +} + +// --- h1 verbatim: zero D1 writes before consent acceptance, BOTH paths ------- + +test("h1 web: fresh sign-in via /api/auth/callback writes ZERO D1 rows before consent", async () => { + const env = envWithGitHubUser(); + const res = await call(env, webCallbackRequest()); + + // The redirect flow stays intact — the user lands signed-in-pending. + assert.equal(res.status, 302); + + // THE point: nothing was persisted. No learner row, no consent row, no + // write statement of any kind. + assert.equal(env.DB.writes.length, 0, `expected zero D1 writes, saw: ${JSON.stringify(env.DB.writes)}`); + assert.equal(env.DB.learners.size, 0, "no learners row before consent"); + assert.equal(env.DB.consents.length, 0, "no consent row before acceptance"); + assert.equal(env.DB.records.length, 0); +}); + +test("h1 device: fresh sign-in via device poll writes ZERO D1 rows before consent", async () => { + const env = envWithGitHubUser(); + const res = await call(env, devicePollRequest()); + assert.equal(res.status, 200); + + assert.equal(env.DB.writes.length, 0, `expected zero D1 writes, saw: ${JSON.stringify(env.DB.writes)}`); + assert.equal(env.DB.learners.size, 0, "no learners row before consent"); + assert.equal(env.DB.consents.length, 0, "no consent row before acceptance"); + assert.equal(env.DB.records.length, 0); +}); + +// --- pending-consent session mechanics (c19) --------------------------------- + +/** Extract the `session` cookie value from a response, or null. */ +function sessionCookie(res) { + const c = res.headers.getSetCookie().find((v) => v.startsWith("session=")); + return c ? decodeURIComponent(c.split(";")[0].slice("session=".length)) : null; +} + +test("web: unconsented callback redirects to the consent page with a pending cookie", async () => { + const env = envWithGitHubUser(); + const res = await call(env, webCallbackRequest()); + + assert.equal(res.status, 302); + assert.equal(res.headers.get("Location"), "https://agentculture.org/learn/consent/"); + const token = sessionCookie(res); + assert.ok(token, "a pending session cookie is set"); + + // The pending token authenticates against /api/me and reports the state. + const me = await (await call(env, authedRequest(`${BASE}/api/me`, token))).json(); + assert.equal(me.authenticated, true); + assert.equal(me.pending_consent, true); + assert.equal(me.consent_required.terms_version, TERMS_VERSION); + assert.equal(me.consent_required.effective_date, TERMS_EFFECTIVE_DATE); + assert.equal(me.learner.github_user_id, "900"); +}); + +test("device: unconsented poll returns status consent_required with a usable pending token", async () => { + const env = envWithGitHubUser(); + const body = await (await call(env, devicePollRequest())).json(); + + assert.equal(body.status, "consent_required"); + assert.ok(body.token, "the CLI gets a token to drive the consent endpoints"); + assert.equal(body.token_type, "Bearer"); + assert.ok(typeof body.expires_at === "number"); + assert.equal(body.consent_required.terms_version, TERMS_VERSION); + assert.equal(body.learner.github_user_id, "900"); + assert.equal(env.DB.writes.length, 0, "still zero writes after the poll response"); +}); + +test("pending session: /api/me reports pending_consent and never sliding-refreshes", async () => { + const env = makeEnv(); + // ttl 60 — inside the refresh window, where a FULL session would re-issue. + const { token } = await mintToken(env, { uid: "42", name: "Ada" }, 60, { pendingConsent: true }); + const res = await call(env, authedRequest(`${BASE}/api/me`, token)); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.pending_consent, true); + assert.equal(body.session.refreshed, false); + assert.equal(res.headers.getSetCookie().length, 0, "pending sessions are never refreshed"); +}); + +test("pending session: progress, record, and tutor all 403 with consent_required", async () => { + const fetchStub = makeFetchStub({ [INFERENCE_URL]: () => jsonResp({ reply: "never" }) }); + const env = makeEnv({ INFERENCE_URL, INFERENCE_TOKEN: "secret", FETCH: fetchStub }); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }, 600, { pendingConsent: true }); + + const progress = await call(env, authedRequest(`${BASE}/api/progress/french`, token)); + assert.equal(progress.status, 403); + assert.equal((await progress.json()).error, "consent_required"); + + const record = await call( + env, + authedRequest(`${BASE}/api/record`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subject: "french", + recorded: { item_id: "x", activity: "practice", result: "pass", at: "2026-07-11T10:00:00Z" }, + }), + }), + ); + assert.equal(record.status, 403); + assert.equal((await record.json()).error, "consent_required"); + + const tutor = await call( + env, + authedRequest(`${BASE}/api/tutor`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subject: "french", messages: [] }), + }), + ); + assert.equal(tutor.status, 403); + assert.equal((await tutor.json()).error, "consent_required"); + + // The resource-gate invariant, extended one level: pending-consent traffic + // never reaches inference and never writes. + assert.equal(fetchStub.calls.length, 0, "inference endpoint must not be touched"); + assert.equal(env.DB.writes.length, 0, "no D1 write from a pending session"); +}); + +test("pending session: logout is allowed and revokes the token", async () => { + const env = makeEnv(); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }, 600, { pendingConsent: true }); + const out = await call(env, authedRequest(`${BASE}/api/auth/logout`, token, { method: "POST" })); + assert.equal(out.status, 200); + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, token))).status, 401); + assert.equal(env.DB.writes.length, 0); +}); + +// --- GET /api/consent (the notice source) ------------------------------------ + +test("GET /api/consent is public and states the current requirement", async () => { + const env = makeEnv(); + const res = await call(env, new Request(`${BASE}/api/consent`)); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.terms_version, TERMS_VERSION); + assert.equal(body.effective_date, TERMS_EFFECTIVE_DATE); + assert.equal(body.terms_url, "/learn/terms/"); + assert.equal(body.privacy_url, "/learn/privacy/"); +}); + +// --- POST /api/consent/accept ------------------------------------------------- + +test("accept: records consent (exact TERMS_VERSION) THEN the learner, in that order", async () => { + const env = makeEnv(); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }, 600, { pendingConsent: true }); + + const res = await call(env, authedRequest(`${BASE}/api/consent/accept`, token, { method: "POST" })); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.status, "consented"); + assert.equal(body.consent.terms_version, TERMS_VERSION); + assert.ok(body.consent.granted_at); + assert.equal(body.learner.github_user_id, "42"); + + // Write ORDER is the contract: the consent row exists before the learner row. + assert.deepEqual(env.DB.writes, [ + { table: "consents", op: "insert" }, + { table: "learners", op: "insert" }, + ]); + assert.equal(env.DB.consents[0].terms_version, TERMS_VERSION); + assert.equal(env.DB.learners.get("42").display_name, "Ada"); +}); + +test("accept: upgrades to a full session (body token + cookie) and revokes the pending one", async () => { + const env = makeEnv(); + const { token: pendingTok } = await mintToken(env, { uid: "42", name: "Ada" }, 600, { + pendingConsent: true, + }); + + const res = await call( + env, + authedRequest(`${BASE}/api/consent/accept`, pendingTok, { method: "POST" }), + ); + const body = await res.json(); + assert.equal(body.token_type, "Bearer"); + assert.equal(sessionCookie(res), body.token, "cookie and body carry the same full token"); + + // The full token opens the previously-403'd routes... + const progress = await call(env, authedRequest(`${BASE}/api/progress/french`, body.token)); + assert.equal(progress.status, 200); + const me = await (await call(env, authedRequest(`${BASE}/api/me`, body.token))).json(); + assert.equal(me.pending_consent, false); + + // ...and the superseded pending token is dead (KV tombstone). + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, pendingTok))).status, 401); +}); + +test("accept via web cookie works too (one issuer, two faces)", async () => { + const env = makeEnv(); + const { token } = await mintToken(env, { uid: "7", name: "Web" }, 600, { pendingConsent: true }); + const res = await call( + env, + new Request(`${BASE}/api/consent/accept`, { + method: "POST", + headers: { Cookie: `session=${encodeURIComponent(token)}` }, + }), + ); + assert.equal(res.status, 200); + assert.ok(sessionCookie(res), "the upgraded session is set as a cookie"); + assert.equal(env.DB.learners.get("7").display_name, "Web"); +}); + +test("accept is idempotent for an already-consented session (same version re-grant)", async () => { + const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION, "2026-07-01T00:00:00Z"); + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }); // full session + + const res = await call(env, authedRequest(`${BASE}/api/consent/accept`, token, { method: "POST" })); + assert.equal(res.status, 200); + assert.equal(env.DB.consents.length, 1, "no duplicate consent row"); + assert.notEqual(env.DB.consents[0].granted_at, "2026-07-01T00:00:00Z", "granted_at refreshed"); +}); + +test("accept requires a session (signed-out -> 401)", async () => { + const env = makeEnv(); + const res = await call(env, new Request(`${BASE}/api/consent/accept`, { method: "POST" })); + assert.equal(res.status, 401); + assert.equal(env.DB.writes.length, 0); +}); + +// --- POST /api/consent/decline -------------------------------------------------- + +test("decline: drops the pending session with ZERO rows ever written", async () => { + const env = makeEnv(); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }, 600, { pendingConsent: true }); + + const res = await call(env, authedRequest(`${BASE}/api/consent/decline`, token, { method: "POST" })); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.status, "declined"); + assert.equal(body.stored, false); + // The web cookie is cleared (empty value, Max-Age=0). + assert.ok( + res.headers.getSetCookie().some((c) => c.startsWith("session=;") && c.includes("Max-Age=0")), + "session cookie cleared", + ); + + // Nothing was ever written, and the session is gone. + assert.equal(env.DB.writes.length, 0, "decline leaves zero D1 writes"); + assert.equal(env.DB.learners.size, 0); + assert.equal(env.DB.consents.length, 0); + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, token))).status, 401); +}); + +test("decline on a consented (full) session -> 409 already_consented", async () => { + const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }); // full session + const res = await call(env, authedRequest(`${BASE}/api/consent/decline`, token, { method: "POST" })); + assert.equal(res.status, 409); + assert.equal((await res.json()).error, "already_consented"); +}); + +// --- the full device round-trip ------------------------------------------------ + +test("device round-trip: poll -> consent_required -> accept -> full token records progress", async () => { + const env = envWithGitHubUser({ id: 901, login: "roundtrip", name: "Round Trip" }); + + const poll = await (await call(env, devicePollRequest())).json(); + assert.equal(poll.status, "consent_required"); + assert.equal(env.DB.writes.length, 0, "no write until the pending token accepts"); + + const accept = await ( + await call(env, authedRequest(`${BASE}/api/consent/accept`, poll.token, { method: "POST" })) + ).json(); + assert.equal(accept.status, "consented"); + + const rec = await call( + env, + authedRequest(`${BASE}/api/record`, accept.token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subject: "french", + recorded: { item_id: "n1", activity: "practice", result: "pass", at: "2026-07-11T10:00:00Z" }, + }), + }), + ); + assert.equal(rec.status, 201); + assert.deepEqual( + env.DB.writes.map((w) => w.table), + ["consents", "learners", "records"], + "consent is the first row that ever exists for the learner", + ); +}); diff --git a/workers/learn-api/test/helpers.js b/workers/learn-api/test/helpers.js index 119ad79..dbe6c35 100644 --- a/workers/learn-api/test/helpers.js +++ b/workers/learn-api/test/helpers.js @@ -43,6 +43,11 @@ export class D1Stub { this.learners = new Map(); this.records = []; this.consents = []; + // Every write statement (insert/delete, any table) appends one entry here, + // in execution order. This is what makes "zero D1 writes before consent" + // (spec h1) literal: tests assert on `db.writes`, not just on table sizes, + // and can also assert ORDER (consent row recorded before the learner row). + this.writes = []; this._id = 0; } @@ -79,8 +84,13 @@ class D1Prepared { return this; } + _logWrite(table, op) { + this.db.writes.push({ table, op }); + } + async run() { if (this.sql.includes("insert into learners")) { + this._logWrite("learners", "insert"); // args: uid, name, now, now, name(update), now(update) const [uid, name] = this.args; const existing = this.db.learners.get(String(uid)); @@ -92,6 +102,7 @@ class D1Prepared { return { success: true, meta: { changes: 1, last_row_id: 0 } }; } if (this.sql.includes("insert into records")) { + this._logWrite("records", "insert"); const [uid, subject, item_id, recorded, mastery_level, activity, result, at] = this.args; const id = ++this.db._id; this.db.records.push({ @@ -108,6 +119,7 @@ class D1Prepared { return { success: true, meta: { changes: 1, last_row_id: id } }; } if (this.sql.includes("insert into consents")) { + this._logWrite("consents", "insert"); // args: uid, terms_version, granted_at, granted_at(on-conflict update) const [uid, termsVersion, grantedAt] = this.args; const id = String(uid); @@ -122,18 +134,21 @@ class D1Prepared { return { success: true, meta: { changes: 1 } }; } if (this.sql.includes("delete from records")) { + this._logWrite("records", "delete"); const [uid] = this.args; const before = this.db.records.length; this.db.records = this.db.records.filter((r) => r.github_user_id !== String(uid)); return { success: true, meta: { changes: before - this.db.records.length } }; } if (this.sql.includes("delete from consents")) { + this._logWrite("consents", "delete"); const [uid] = this.args; const before = this.db.consents.length; this.db.consents = this.db.consents.filter((c) => c.github_user_id !== String(uid)); return { success: true, meta: { changes: before - this.db.consents.length } }; } if (this.sql.includes("delete from learners")) { + this._logWrite("learners", "delete"); const [uid] = this.args; const existed = this.db.learners.delete(String(uid)); return { success: true, meta: { changes: existed ? 1 : 0 } }; @@ -196,12 +211,24 @@ export function makeEnv(overrides = {}) { }; } -/** Mint a valid (or, with ttl<0, expired) session token for a learner. */ -export async function mintToken(env, learner = { uid: "42", name: "Ada" }, ttl = 3600) { - const { token, payload } = await issueSession(env, learner, ttl); +/** Mint a valid (or, with ttl<0, expired) session token for a learner. + * Pass `opts = { pendingConsent: true }` for a pending-consent token. */ +export async function mintToken(env, learner = { uid: "42", name: "Ada" }, ttl = 3600, opts = {}) { + const { token, payload } = await issueSession(env, learner, ttl, opts); return { token, payload }; } +/** Seed a consent row directly into the D1 stub (an already-consented + * learner), bypassing the write log — tests that assert "zero writes during + * the flow under test" must not count their own fixtures. */ +export function seedConsent(env, uid, termsVersion, grantedAt = "2026-07-11T00:00:00Z") { + env.DB.consents.push({ + github_user_id: String(uid), + terms_version: termsVersion, + granted_at: grantedAt, + }); +} + /** JSON Response helper for stubs. */ export function jsonResp(obj, status = 200) { return new Response(JSON.stringify(obj), { diff --git a/workers/learn-api/test/worker.test.js b/workers/learn-api/test/worker.test.js index 92ae10a..b82ee78 100644 --- a/workers/learn-api/test/worker.test.js +++ b/workers/learn-api/test/worker.test.js @@ -3,12 +3,14 @@ import assert from "node:assert/strict"; import worker from "../src/index.js"; import { GH } from "../src/github.js"; +import { TERMS_VERSION } from "../src/terms.js"; import { makeEnv, makeFetchStub, mintToken, authedRequest, jsonResp, + seedConsent, } from "./helpers.js"; const BASE = "https://learn-api.example"; @@ -292,12 +294,15 @@ test("login redirect_uri carries the /learn mount prefix (matches the GitHub app assert.equal(redirectUri, "https://agentculture.org/learn/api/auth/callback"); }); -test("GET /api/auth/callback exchanges code, upserts learner, sets session", async () => { +test("GET /api/auth/callback (consented user) exchanges code, upserts learner, sets session", async () => { const fetchStub = makeFetchStub({ [GH.token]: () => jsonResp({ access_token: "gho_web", token_type: "bearer" }), [GH.user]: () => jsonResp({ id: 555, login: "trinity", name: "Trinity" }), }); const env = makeEnv({ FETCH: fetchStub, APP_URL: "https://agentculture.org/learn/" }); + // A returning learner with recorded consent — the unconsented (pending) + // first sign-in is covered in consent.test.js. + seedConsent(env, "555", TERMS_VERSION); const req = new Request(`${BASE}/api/auth/callback?code=abc&state=xyz`, { headers: { Cookie: "oauth_state=xyz" }, @@ -352,7 +357,7 @@ test("device flow: start returns the user code", async () => { assert.equal(body.device_code, "dc_123"); }); -test("device flow: poll pending, then complete issues a Bearer token", async () => { +test("device flow (consented user): poll pending, then complete issues a Bearer token", async () => { let phase = "pending"; const fetchStub = makeFetchStub({ [GH.token]: () => @@ -362,6 +367,9 @@ test("device flow: poll pending, then complete issues a Bearer token", async () [GH.user]: () => jsonResp({ id: 777, login: "neo", name: "Neo" }), }); const env = makeEnv({ FETCH: fetchStub }); + // A returning learner with recorded consent — the unconsented poll + // (status: consent_required) is covered in consent.test.js. + seedConsent(env, "777", TERMS_VERSION); const poll = () => call( From 3e64d7b03189cb90c4c51301d2ef5f32e1807e18 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 11:09:41 +0300 Subject: [PATCH 07/24] =?UTF-8?q?feat(t14):=20re-export=20content=20from?= =?UTF-8?q?=20released=20subjects=20=E2=80=94=20french=200.6.0,=20spanish?= =?UTF-8?q?=200.7.0,=20culture-guide=200.10.0=20(cloze=20in,=20dev-=20excl?= =?UTF-8?q?uded)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- .../content-export/stories-culture-guide.json | 141 ++++++++++ .../src/content-export/stories-french.json | 265 ++++++++++++++++++ .../src/content-export/stories-spanish.json | 217 ++++++++++++++ 3 files changed, 623 insertions(+) diff --git a/site-astro/src/content-export/stories-culture-guide.json b/site-astro/src/content-export/stories-culture-guide.json index e410782..876a40b 100644 --- a/site-astro/src/content-export/stories-culture-guide.json +++ b/site-astro/src/content-export/stories-culture-guide.json @@ -67,6 +67,59 @@ "agent-fleet" ], "type": "multiple_choice" + }, + { + "blanks": [ + { + "answer": "system prompt", + "id": "leak_source", + "options": [ + "system prompt", + "secret manager", + "changelog" + ] + }, + { + "answer": "secret manager", + "id": "fix", + "options": [ + "secret manager", + "public door", + "debug log" + ] + } + ], + "id": "cg.story.cg-scenario-before-the-public-door.6", + "item_id": "secrets", + "prompt": "Fill in each blank with the term that best completes the postmortem.", + "related_concepts": [ + "one-or-many", + "public-access" + ], + "text": "The billing key had been pasted straight into the agent's {{leak_source}}; federation carried that context onto a machine with a debug log that captured it in cleartext. The team's fix was to pull every credential out of prompts and repos and place it behind a {{fix}}, so each agent held only a scoped, rotatable secret.", + "type": "cloze" + }, + { + "blanks": [ + { + "answer": "Level 3 agent-management tier", + "id": "tier", + "options": [ + "Level 3 agent-management tier", + "Level 1 project agent", + "Level 4 orchestrator" + ] + } + ], + "id": "cg.story.cg-scenario-before-the-public-door.7", + "item_id": "agent-levels", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "agent-fleet", + "company-scale" + ], + "text": "Six agents and no one whose job was to manage agents — the fleet was missing its {{tier}}, the tier that becomes worth adding once a fleet passes roughly three-to-five agents, owning onboarding, skills, and credential rotation across the fleet.", + "type": "cloze" } ], "glossary": [ @@ -169,6 +222,50 @@ ], "rubric": "Passes if the learner names a concrete question, a specific grounding source the agent can actually reach, and an explicit 'say you do not know / cite the source' boundary. Partial if any of the three is missing or left vague.", "type": "open" + }, + { + "blanks": [ + { + "answer": "hallucination", + "id": "term", + "options": [ + "hallucination", + "deception", + "refusal" + ] + } + ], + "id": "cg.story.cg-scenario-the-confident-intern.5", + "item_id": "hallucinations", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "prediction", + "prompt-engineering" + ], + "text": "With nothing in its context grounding the real client flags, the model produced a fluent, confident-sounding answer that simply wasn't true — not a lie, but a {{term}}: the default failure mode whenever an answer is ungrounded.", + "type": "cloze" + }, + { + "blanks": [ + { + "answer": "system prompt", + "id": "term", + "options": [ + "system prompt", + "changelog", + "error log" + ] + } + ], + "id": "cg.story.cg-scenario-the-confident-intern.6", + "item_id": "system-prompt", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "prompt-engineering", + "hallucinations" + ], + "text": "Ravi stopped treating the {{term}} as a place to scold the model and started treating it as a program — its phrasing and examples were what actually steered which answers became likely.", + "type": "cloze" } ], "glossary": [ @@ -261,6 +358,50 @@ ], "rubric": "Passes if the learner sees that 'forgetting under overload' looks human but the mechanism is finite context and attention dilution (not tiredness), notes that the agent will not push back or flag its own confusion, and ties the fix to context engineering rather than persuasion. Partial if it labels the behaviours without connecting them to the fix.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "definitions", + "id": "term", + "options": [ + "definitions", + "credentials", + "transcripts" + ] + } + ], + "id": "cg.story.cg-scenario-the-over-tooled-agent.5", + "item_id": "mcps", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "tool-use", + "limitations" + ], + "text": "Every MCP the agent connected to injected its tool {{term}} — names, descriptions, parameter schemas — into the context before the agent did anything at all; five broad servers filled a large fraction of the window with these alone.", + "type": "cloze" + }, + { + "blanks": [ + { + "answer": "context engineering", + "id": "term", + "options": [ + "context engineering", + "prompt engineering", + "fine-tuning" + ] + } + ], + "id": "cg.story.cg-scenario-the-over-tooled-agent.6", + "item_id": "context-engineering", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "mcps", + "agent-definition" + ], + "text": "The fix was not a bigger model or a longer prompt — it was {{term}}: deciding what belongs in the context window for this specific job, then cutting the agent's default MCPs down to two, tightly scoped.", + "type": "cloze" } ], "glossary": [ diff --git a/site-astro/src/content-export/stories-french.json b/site-astro/src/content-export/stories-french.json index 58958e6..8f346f7 100644 --- a/site-astro/src/content-export/stories-french.json +++ b/site-astro/src/content-export/stories-french.json @@ -41,6 +41,25 @@ "prompt": "Pourquoi Nina a-t-elle un peu peur le matin de la rentrée ?", "rubric": "Passes if the answer mentions the new teacher or general first-day nerves; partial if it just says 'she is scared' with no reason.", "type": "open" + }, + { + "answer": "maîtresse", + "blanks": [ + { + "answer": "maîtresse", + "id": "enseignante", + "options": [ + "maîtresse", + "maître", + "professeur" + ] + } + ], + "id": "fr-a1-la-rentree-q5", + "item_id": "fr.story.fr-a1-la-rentree.2", + "prompt": "Fill in each blank with the right word.", + "text": "« Il y a une nouvelle {{enseignante}} cette année, » dit Nina.", + "type": "cloze" } ], "glossary": [ @@ -130,6 +149,25 @@ "prompt": "Pourquoi Léo est-il fier à la fin de l'histoire ?", "rubric": "Passes if the answer names that he spoke French with the vendors by himself/alone; partial if it only says he went to the market.", "type": "open" + }, + { + "answer": "kilo", + "blanks": [ + { + "answer": "kilo", + "id": "unit", + "options": [ + "kilo", + "litre", + "mètre" + ] + } + ], + "id": "fr-a1-le-marche-q5", + "item_id": "fr.story.fr-a1-le-marche.3", + "prompt": "Fill in each blank with the right word.", + "text": "Léo dit au vendeur : « Bonjour, monsieur. Je voudrais un {{unit}} de carottes, s'il vous plaît. »", + "type": "cloze" } ], "glossary": [ @@ -221,6 +259,25 @@ "prompt": "Amina dit que retrouver une vieille amie lui a fait du bien. Raconte une fois où tu as retrouvé un ami après une longue absence.", "rubric": "Passes if the learner produces a coherent short personal narrative in French connected to the theme of reunion; partial if the answer is a single word or off-topic.", "type": "discussion" + }, + { + "answer": "menthe", + "blanks": [ + { + "answer": "menthe", + "id": "gout", + "options": [ + "menthe", + "citron", + "vanille" + ] + } + ], + "id": "fr-a2-le-cafe-du-coin-q5", + "item_id": "fr.story.fr-a2-le-cafe-du-coin.2", + "prompt": "Fill in each blank with the right word.", + "text": "Chloé choisit un thé à la {{gout}}.", + "type": "cloze" } ], "glossary": [ @@ -313,6 +370,25 @@ "prompt": "Décris ce que la famille fait le soir dans le chalet.", "rubric": "Passes if the answer mentions warming up by the fireplace, the father telling stories, and the mother making hot chocolate; partial if only one detail is given.", "type": "open" + }, + { + "answer": "bonhomme", + "blanks": [ + { + "answer": "bonhomme", + "id": "construction", + "options": [ + "bonhomme", + "château", + "bateau" + ] + } + ], + "id": "fr-a2-le-weekend-a-la-montagne-q5", + "item_id": "fr.story.fr-a2-le-weekend-a-la-montagne.1", + "prompt": "Fill in each blank with the right word.", + "text": "Ils sortent et construisent un grand {{construction}} de neige avec une carotte pour le nez.", + "type": "cloze" } ], "glossary": [ @@ -405,6 +481,33 @@ "prompt": "Que ferais-tu si on te posait une question à laquelle tu ne t'attends pas pendant un entretien ?", "rubric": "Passes if the learner gives a coherent personal strategy in French (e.g. pausing to think, asking for clarification, giving a concrete example); partial if the answer is a single word.", "type": "open" + }, + { + "blanks": [ + { + "answer": "retenus", + "id": "participe", + "options": [ + "retenus", + "retenues", + "retenu" + ] + }, + { + "answer": "peine", + "id": "mot", + "options": [ + "peine", + "douleur", + "peur" + ] + } + ], + "id": "fr-b1-entretien-embauche-q5", + "item_id": "fr.story.fr-b1-entretien-embauche.3", + "prompt": "Fill in each blank with the right word.", + "text": "L'entreprise contactera les candidats {{participe}} dans les deux semaines. Toute cette nervosité, finalement, en valait la {{mot}}.", + "type": "cloze" } ], "glossary": [ @@ -505,6 +608,33 @@ "prompt": "Selon le texte, pourquoi la fête des voisins est-elle importante pour une communauté ? Ton quartier organise-t-il un événement similaire ?", "rubric": "Passes if the response connects the event to building community between strangers and offers at least a brief reflection on the learner's own context; partial if it only summarizes the story.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "détend", + "id": "verbe", + "options": [ + "détend", + "détendent", + "détends" + ] + }, + { + "answer": "à peine", + "id": "adverbe", + "options": [ + "à peine", + "déjà", + "toujours" + ] + } + ], + "id": "fr-b1-la-fete-des-voisins-q5", + "item_id": "fr.story.fr-b1-la-fete-des-voisins.4", + "prompt": "Fill in each blank with the right word.", + "text": "Après quelques verres de vin et beaucoup de rires, l'ambiance se {{verbe}}. Avant cette soirée, elle connaissait {{adverbe}} le nom de ses voisins.", + "type": "cloze" } ], "glossary": [ @@ -606,6 +736,33 @@ "prompt": "Camille hésite entre l'espace et la proximité du travail. Si tu devais choisir, que privilégierais-tu, et pourquoi ?", "rubric": "Passes if the learner gives a reasoned personal preference in French that engages with the space-vs-commute trade-off; partial if it only picks one side without a reason.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "charges", + "id": "mot", + "options": [ + "charges", + "frais", + "taxes" + ] + }, + { + "answer": "spacieux", + "id": "adj", + "options": [ + "spacieux", + "spacieuse", + "spacieuses" + ] + } + ], + "id": "fr-b1-la-recherche-appartement-q5", + "item_id": "fr.story.fr-b1-la-recherche-appartement.2", + "prompt": "Fill in each blank with the right word.", + "text": "Le propriétaire explique que le loyer comprend les {{mot}}, mais pas internet. Bien qu'il soit {{adj}} et lumineux, le trajet jusqu'au travail prendrait presque quarante minutes.", + "type": "cloze" } ], "glossary": [ @@ -706,6 +863,33 @@ "prompt": "Selon Julien, qu'est-ce que le covoiturage lui a apporté en plus des économies d'essence ?", "rubric": "Passes if the answer names new friendships / social connection with the other passengers; partial if it only restates the fuel savings.", "type": "open" + }, + { + "blanks": [ + { + "answer": "panne", + "id": "mot", + "options": [ + "panne", + "accident", + "retard" + ] + }, + { + "answer": "dépanneuse", + "id": "mot2", + "options": [ + "dépanneuse", + "ambulance", + "police" + ] + } + ], + "id": "fr-b1-le-covoiturage-q5", + "item_id": "fr.story.fr-b1-le-covoiturage.2", + "prompt": "Fill in each blank with the right word.", + "text": "Un matin, la voiture tombe en {{mot}} sur l'autoroute. Ines appelle une {{mot2}} pendant que Marc plaisante pour détendre l'atmosphère.", + "type": "cloze" } ], "glossary": [ @@ -806,6 +990,33 @@ "prompt": "L'article insiste sur l'absence de « discours idéologique » dans la démarche du village. Penses-tu que ce soit une force ou une limite pour la transition écologique en général ?", "rubric": "Passes if the learner engages with the tension between pragmatic local action and broader ideological/political framing, and takes a defensible position; partial if it only summarizes the village's approach without answering the question.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "sceptique", + "id": "attitude", + "options": [ + "sceptique", + "optimiste", + "confiant" + ] + }, + { + "answer": "dépassé", + "id": "verbe", + "options": [ + "dépassé", + "respecté", + "déçu" + ] + } + ], + "id": "fr-b2-la-transition-ecologique-q5", + "item_id": "fr.story.fr-b2-la-transition-ecologique.2", + "prompt": "Fill in each blank with the right word.", + "text": "Le maire, initialement {{attitude}} quant à la rentabilité du projet, admet aujourd'hui que les économies réalisées sur la facture d'électricité ont {{verbe}} ses attentes les plus optimistes.", + "type": "cloze" } ], "glossary": [ @@ -910,6 +1121,33 @@ "prompt": "L'auteur termine par une question sur une remise en cause plus vaste de l'organisation du travail salarié. Qu'en penses-tu : le télétravail est-il une simple adaptation, ou le début d'un changement plus profond ?", "rubric": "Passes if the learner takes and defends a position with at least one supporting reason, engaging with the article's closing question; partial if it restates the question without taking a position.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "affaiblisse", + "id": "verbe", + "options": [ + "affaiblisse", + "affaiblit", + "affaiblira" + ] + }, + { + "answer": "s'efface", + "id": "verbe2", + "options": [ + "s'efface", + "s'effacent", + "s'effaçait" + ] + } + ], + "id": "fr-b2-le-teletravail-q5", + "item_id": "fr.story.fr-b2-le-teletravail.2", + "prompt": "Fill in each blank with the right word.", + "text": "Plusieurs managers s'inquiètent que le télétravail n'{{verbe}} la cohésion des équipes, et que la frontière entre vie privée et vie professionnelle ne {{verbe2}} complètement.", + "type": "cloze" } ], "glossary": [ @@ -1014,6 +1252,33 @@ "prompt": "Le texte ne révèle jamais ce que contenait le livre offert par Monsieur Aubert. Pourquoi, selon toi, l'auteur fait-il ce choix ? Qu'est-ce que cela ajoute à l'histoire ?", "rubric": "Passes if the learner reflects on the literary effect of withholding the title (mystery, focus on the human moment rather than the object, room for the reader's imagination) rather than simply guessing a specific title; partial if the answer only guesses a title without reflecting on the choice.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "deviner", + "id": "verbe", + "options": [ + "deviner", + "décider", + "devenir" + ] + }, + { + "answer": "cherchait", + "id": "verbe2", + "options": [ + "cherchait", + "demandait", + "voulait" + ] + } + ], + "id": "fr-c1-le-vieux-libraire-q5", + "item_id": "fr.story.fr-c1-le-vieux-libraire.2", + "prompt": "Fill in each blank with the right word.", + "text": "Il avait ce don, rare, de {{verbe}} en quelques mots à peine échangés ce qu'une personne {{verbe2}} vraiment à lire.", + "type": "cloze" } ], "glossary": [ diff --git a/site-astro/src/content-export/stories-spanish.json b/site-astro/src/content-export/stories-spanish.json index 8a81e31..a9f9a6c 100644 --- a/site-astro/src/content-export/stories-spanish.json +++ b/site-astro/src/content-export/stories-spanish.json @@ -40,6 +40,33 @@ "prompt": "¿Por qué le gustan los plátanos a Ana?", "rubric": "Passes if the answer mentions that they are sweet and easy to eat (son dulces y fáciles de comer); naming only one of the two reasons is partial.", "type": "short_answer" + }, + { + "blanks": [ + { + "answer": "rojas", + "id": "adj1", + "options": [ + "rojas", + "rojo", + "rojos" + ] + }, + { + "answer": "amarillos", + "id": "adj2", + "options": [ + "amarillos", + "amarilla", + "amarillas" + ] + } + ], + "id": "es-a1-el-mercado-de-frutas-q5", + "item_id": "es.story.es-a1-el-mercado-de-frutas.1", + "prompt": "Completa cada espacio con la palabra correcta (concordancia de género y número).", + "text": "En el mercado hay frutas de muchos colores: manzanas {{adj1}} y plátanos {{adj2}}.", + "type": "cloze" } ], "glossary": [ @@ -128,6 +155,25 @@ "prompt": "Describe a Rex, el perro de la familia.", "rubric": "Passes if the answer mentions his colors (negro y blanco) and that he likes to run in the garden; naming only the colors is partial.", "type": "short_answer" + }, + { + "answer": "es", + "blanks": [ + { + "answer": "es", + "id": "ser_estar", + "options": [ + "es", + "está", + "son" + ] + } + ], + "id": "es-a1-mi-familia-q5", + "item_id": "es.story.es-a1-mi-familia.1", + "prompt": "Completa el espacio con la palabra correcta (ser vs. estar).", + "text": "Mi papá {{ser_estar}} alto y trabaja en un hospital.", + "type": "cloze" } ], "glossary": [ @@ -211,6 +257,25 @@ "prompt": "¿Cuántas personas van a venir a la fiesta y quiénes son?", "rubric": "Passes if the answer says twelve people, including classmates and cousins (compañeros de clase y primos); naming only the number is partial.", "type": "short_answer" + }, + { + "answer": "va", + "blanks": [ + { + "answer": "va", + "id": "verbo", + "options": [ + "va", + "van", + "vamos" + ] + } + ], + "id": "es-a2-el-cumpleanos-de-lucia-q5", + "item_id": "es.story.es-a2-el-cumpleanos-de-lucia.1", + "prompt": "Completa el espacio con la forma correcta de 'ir a + infinitivo'.", + "text": "Tomás {{verbo}} a decorar el jardín con globos de colores.", + "type": "cloze" } ], "glossary": [ @@ -298,6 +363,25 @@ "prompt": "¿Qué hicieron los amigos que no nadaron?", "rubric": "Passes if the answer mentions they played beach volleyball on the sand (jugaron vóley playa en la arena).", "type": "short_answer" + }, + { + "answer": "fueron", + "blanks": [ + { + "answer": "fueron", + "id": "verbo", + "options": [ + "fueron", + "fue", + "van" + ] + } + ], + "id": "es-a2-un-dia-en-la-playa-q5", + "item_id": "es.story.es-a2-un-dia-en-la-playa.1", + "prompt": "Completa el espacio con la forma correcta del pretérito de 'ir'.", + "text": "Camila y sus amigos {{verbo}} a la playa por primera vez del verano.", + "type": "cloze" } ], "glossary": [ @@ -385,6 +469,25 @@ "prompt": "¿Qué tres lugares descubrió Diego en el barrio nuevo?", "rubric": "Passes if the answer names the bakery, the basketball court, and the library (la panadería, la cancha de básquetbol, la biblioteca); two of three is partial.", "type": "short_answer" + }, + { + "answer": "para", + "blanks": [ + { + "answer": "para", + "id": "prep", + "options": [ + "para", + "por", + "porque" + ] + } + ], + "id": "es-b1-el-barrio-nuevo-q5", + "item_id": "es.story.es-b1-el-barrio-nuevo.1", + "prompt": "Completa el espacio con la preposición correcta (por vs. para).", + "text": "Diego decidió aceptar la invitación {{prep}} conocer a los chicos del barrio nuevo.", + "type": "cloze" } ], "glossary": [ @@ -487,6 +590,25 @@ "item_id": "es.story.es-b1-el-tren-perdido.5", "prompt": "Traduce al español: \"I realized I only had twenty minutes.\"", "type": "translation" + }, + { + "answer": "llegó", + "blanks": [ + { + "answer": "llegó", + "id": "verbo", + "options": [ + "llegó", + "llegaba", + "llega" + ] + } + ], + "id": "es-b1-el-tren-perdido-q6", + "item_id": "es.story.es-b1-el-tren-perdido.1", + "prompt": "Completa el espacio con la forma correcta (pretérito vs. imperfecto).", + "text": "Cuando por fin Martín {{verbo}} al andén, el tren ya estaba cerrando las puertas.", + "type": "cloze" } ], "glossary": [ @@ -589,6 +711,25 @@ "prompt": "¿Qué habrías respondido tú si te hubieran hecho la misma pregunta sobre un proyecto del que te sientas orgulloso/a?", "rubric": "Open-ended: passes if the learner describes a concrete project and connects it to a skill, in Spanish, using at least one past tense correctly.", "type": "discussion" + }, + { + "answer": "estaba", + "blanks": [ + { + "answer": "estaba", + "id": "verbo", + "options": [ + "estaba", + "era", + "fue" + ] + } + ], + "id": "es-b1-la-entrevista-de-trabajo-q6", + "item_id": "es.story.es-b1-la-entrevista-de-trabajo.1", + "prompt": "Completa el espacio con la forma correcta (ser vs. estar).", + "text": "Antes de la entrevista, Valentina {{verbo}} un poco nerviosa en la sala de espera.", + "type": "cloze" } ], "glossary": [ @@ -684,6 +825,25 @@ "prompt": "¿Qué noticia sobre la salud de la abuela le contó su hermano?", "rubric": "Passes if the answer mentions the grandmother went to the doctor and everything was fine (fue al médico y todo salió bien).", "type": "short_answer" + }, + { + "answer": "me", + "blanks": [ + { + "answer": "me", + "id": "pron", + "options": [ + "me", + "te", + "le" + ] + } + ], + "id": "es-b1-una-carta-a-mi-abuela-q5", + "item_id": "es.story.es-b1-una-carta-a-mi-abuela.1", + "prompt": "Completa el espacio con el pronombre de objeto indirecto correcto.", + "text": "Olvido las cosas simples que tú siempre {{pron}} enseñaste, abuela.", + "type": "cloze" } ], "glossary": [ @@ -786,6 +946,25 @@ "prompt": "¿Estás de acuerdo con Beatriz en que es difícil construir relaciones profesionales sin verse en persona? Explica tu opinión usando el subjuntivo al menos una vez (por ejemplo, con \"dudo que\" o \"no creo que\").", "rubric": "Open-ended: passes if the learner states a clear opinion and correctly uses a subjunctive trigger phrase; a plausible opinion without the subjunctive is partial.", "type": "discussion" + }, + { + "answer": "sea", + "blanks": [ + { + "answer": "sea", + "id": "verbo", + "options": [ + "sea", + "es", + "será" + ] + } + ], + "id": "es-b2-el-dilema-del-teletrabajo-q6", + "item_id": "es.story.es-b2-el-dilema-del-teletrabajo.3", + "prompt": "Completa el espacio con la forma correcta del subjuntivo (no creo que + subjuntivo).", + "text": "No creo que la oficina {{verbo}} la única solución al problema de Renata.", + "type": "cloze" } ], "glossary": [ @@ -897,6 +1076,25 @@ "prompt": "El texto usa vocabulario y platos regionales de México (las posadas, los romeritos, la cuadra). ¿Qué tradición navideña de tu propia cultura podrías comparar con las posadas?", "rubric": "Open-ended: passes if the learner names a comparable tradition and draws at least one concrete point of comparison, in Spanish.", "type": "discussion" + }, + { + "answer": "recorría", + "blanks": [ + { + "answer": "recorría", + "id": "verbo", + "options": [ + "recorría", + "recorrió", + "recorre" + ] + } + ], + "id": "es-b2-las-fiestas-de-mi-pueblo-q6", + "item_id": "es.story.es-b2-las-fiestas-de-mi-pueblo.1", + "prompt": "Completa el espacio con la forma correcta (pretérito vs. imperfecto para acciones habituales).", + "text": "Cada noche, un grupo de vecinos {{verbo}} las calles cantando villancicos.", + "type": "cloze" } ], "glossary": [ @@ -1014,6 +1212,25 @@ "prompt": "El cuento usa voseo rioplatense (\"vos sos\", \"encontrás\", \"dejate\") en vez de \"tú eres\", \"encuentras\", \"déjate\". Reescribe la frase de Renata \"Dejate sorprender por la que encontrás ahora\" usando la forma de tú.", "rubric": "Passes if the learner correctly converts both verbs: 'Déjate sorprender por la que encuentras ahora.'", "type": "discussion" + }, + { + "answer": "sos", + "blanks": [ + { + "answer": "sos", + "id": "verbo", + "options": [ + "sos", + "eres", + "sois" + ] + } + ], + "id": "es-c1-el-ultimo-tren-a-buenos-aires-q6", + "item_id": "es.story.es-c1-el-ultimo-tren-a-buenos-aires.5", + "prompt": "Completa el espacio con la forma correcta de 'ser' en voseo rioplatense.", + "text": "—¿Vos {{verbo}} de acá? —le preguntó la mujer a Julián.", + "type": "cloze" } ], "glossary": [ From 760d660ea61b9875a47a7ec772f6463d0dde57bf Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 11:09:55 +0300 Subject: [PATCH 08/24] =?UTF-8?q?feat(worker):=20re-consent=20on=20TERMS?= =?UTF-8?q?=5FVERSION=20bump=20=E2=80=94=20stale-consent=20D1=20check,=20s?= =?UTF-8?q?tructured=20403,=20/api/me=20additive=20field=20(t6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends t5's consentSatisfiesCurrentTerms (t5 scope: consent != null) to require an exact terms_version match against the currently published version (consent.js#currentTermsVersion, an env.TERMS_VERSION_OVERRIDE test seam mirroring util.js#outboundFetch — production never sets it). - requireConsented (auth.js) adds a getConsent D1 read for full sessions and rejects a stale match with 403 consent_required { reason: "stale_version", consent_required }, alongside the existing pending-session 403 (now { reason: "pending", ... } for the same shape). HttpError gained an additive `extra` body-fields param to carry this. - Both sign-in paths (handleCallback, handleDevice) and handleConsentAccept now resolve the version through currentTermsVersion(env) instead of a bare TERMS_VERSION import, so a bump reroutes a previously-consented learner's next sign-in to pending-consent (zero new D1 writes) and re-accept always stamps the current version. - /api/me never 403s on stale consent — it reports an additive reconsent_required field (+ consent_required when true), keeping identity checks reachable while other routes reject. Design: D1-read-per-request over a claims-based (session-payload) cache — simple and always correct now; documented tradeoff in README "Storage". Tests: new test/reconsent.test.js (10 tests: predicate/seam, h2 stamping, both sign-in paths re-routing on a simulated bump, the live-token 403 wall with zero inference calls, the full v1->bump->403->re-accept->200 cycle, /api/me's additive shape). 5 existing worker.test.js tests updated to seed a matching consent row now that requireConsented checks D1, not just the token's pending marker. 74 baseline + 10 new = 84/84 green; 458 Python tests green; README route table + endpoint shapes updated additively. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- workers/learn-api/README.md | 115 +++++++++- workers/learn-api/src/auth.js | 44 +++- workers/learn-api/src/consent.js | 44 +++- workers/learn-api/src/index.js | 60 +++-- workers/learn-api/src/util.js | 14 +- workers/learn-api/test/reconsent.test.js | 269 +++++++++++++++++++++++ workers/learn-api/test/worker.test.js | 6 + 7 files changed, 507 insertions(+), 45 deletions(-) create mode 100644 workers/learn-api/test/reconsent.test.js diff --git a/workers/learn-api/README.md b/workers/learn-api/README.md index e3766a4..bcc9376 100644 --- a/workers/learn-api/README.md +++ b/workers/learn-api/README.md @@ -27,6 +27,32 @@ pending session — nothing was ever written. Proven by `test/consent.test.js`, which asserts **zero D1 write statements** on both paths via a write log in the D1 stub. +The re-consent invariant (spec c10/h2, task t6) extends the SAME gate to a +**published version bump**: "consented" means `consent.terms_version` equals +the *currently* published `TERMS_VERSION` exactly +(`src/consent.js#consentSatisfiesCurrentTerms`), not merely "a consent row +exists." Bumping the published version therefore: + +- routes a previously-consented learner's **next sign-in** (either path) back + to a pending-consent session, with the same zero-new-D1-writes guarantee as + a first-time sign-in; +- walls off an **existing, still-unexpired full-session token** at every + `requireConsented`-gated route (`/api/progress/:subject`, `/api/record`, + `/api/tutor`) with a structured `403 consent_required` — the token doesn't + need to expire or be re-issued for the gate to take effect; +- leaves `POST /api/consent/accept` reachable throughout (it runs on + `requireAuth`, not `requireConsented`), so re-accepting is always possible, + and re-accepting records a **new** consent row for the new version + (`consents` keeps one row per accepted version — see "Storage" below) and + restores access immediately. + +`GET /api/me` never 403s for a stale-consent full session — it *reports* the +requirement via an additive `reconsent_required` field (see "Endpoint shapes" +below) so a client can explain why other routes started rejecting, without +losing the ability to check who's signed in. Proven by `test/reconsent.test.js` +(the version-bump paths, the live-token wall, the accept-restores-access full +cycle, and the `/api/me` additive shape). + No third-party runtime deps: the Worker uses only Web-standard APIs (`fetch`, `Request`/`Response`, `crypto.subtle`, `btoa`/`atob`), so the same code runs in `wrangler dev`, in production, and under `node --test`. @@ -40,13 +66,13 @@ No third-party runtime deps: the Worker uses only Web-standard APIs (`fetch`, | GET | `/api/auth/callback` | public | Web OAuth: exchange code. Consented user: upsert learner, set full `session` cookie, redirect to the site. Unconsented: set a pending-consent cookie, redirect to `/learn/consent/`, **zero D1 writes**. | | POST | `/api/auth/device` | public | Device flow `start` / `poll` for the CLI + MCP (wave-3 t12). Unconsented poll returns `status: "consent_required"` + a pending Bearer token, zero D1 writes. | | GET | `/api/consent` | public | What consent is currently required: `terms_version`, `effective_date`, policy links. | -| POST | `/api/consent/accept` | session or pending | Record consent for the exact current `TERMS_VERSION`, **then** upsert the learner, upgrade to a full session (cookie + body token). | +| POST | `/api/consent/accept` | session or pending | Record consent for the exact **currently published** `TERMS_VERSION` (t6: not necessarily the version last consented to), **then** upsert the learner, upgrade to a full session (cookie + body token). Reachable even from a stale-consent full session — this is the re-consent route. | | POST | `/api/consent/decline` | pending only | Revoke the pending session, clear the cookie, nothing ever written. Full session gets `409 already_consented`. | | POST | `/api/auth/logout` | session or pending | Revoke the current session (KV tombstone) and clear the cookie. | -| GET | `/api/me` | session or pending | Identity + session expiry; auto-refreshes a near-stale full session. Pending session: reports `pending_consent: true` + `consent_required`, never refreshes. | -| GET | `/api/progress/:subject` | consented | Ledger-derived `progress.json`-shaped payload. Pending session: `403 consent_required`. | -| POST | `/api/record` | consented | Validate the `recorded` shape and append it to the ledger. Pending session: `403 consent_required`. | -| POST | `/api/tutor` | consented | Broker: forward to `INFERENCE_URL` (a served inference endpoint). Pending session: `403 consent_required`, zero inference calls. | +| GET | `/api/me` | session or pending | Identity + session expiry; auto-refreshes a near-stale full session. Pending session: reports `pending_consent: true` + `consent_required`. Full session (t6): reports `reconsent_required` (+ `consent_required` when true) instead of 403ing. Never refreshes a pending session. | +| GET | `/api/progress/:subject` | consented | Ledger-derived `progress.json`-shaped payload. Pending OR stale-version session: `403 consent_required` (t6 adds `reason` + `consent_required` to the body — see "Endpoint shapes"). | +| POST | `/api/record` | consented | Validate the `recorded` shape and append it to the ledger. Pending OR stale-version session: `403 consent_required`. | +| POST | `/api/tutor` | consented | Broker: forward to `INFERENCE_URL` (a served inference endpoint). Pending OR stale-version session: `403 consent_required`, zero inference calls. | Sessions are stateless HMAC-signed tokens (short TTL, ~1h; pending-consent tokens 10 min) accepted either as an `Authorization: Bearer ` header @@ -71,6 +97,26 @@ them, which is how sign-in stays write-free until consent. row is recorded *before* the learner row exists (accept-order contract). For any learner, this is the first row that ever exists for them. +**Stale-consent detection cost (t6, spec c10/h2):** `requireConsented` +(`src/auth.js`) does one extra `getConsent` D1 read per request to +`/api/progress/:subject`, `/api/record`, and `/api/tutor`, to re-check the +learner's *stored* consent against the *currently published* `TERMS_VERSION` +on every call — not just at token-issue time. `GET /api/me` does the same for +a full session (on top of its existing `getLearner` read) so it can report +`reconsent_required`. This was chosen over a claims-based design (stamp the +accepted version into the session token's signed payload at issue time, +compare claim-to-current with **zero** D1 reads) for one reason: correctness +now, at the cost of one indexed `SELECT ... WHERE github_user_id = ?` read per +protected request. The claims alternative would need every session-issuing +call site (web callback, device poll, consent accept, **and** the +sliding-refresh re-issue inside `handleMe`) to correctly propagate the +version claim — a single missed call site would silently under- or +over-grant access, and that failure mode is worse than a few extra reads on +D1 (Cloudflare's globally-replicated SQLite, not a cross-region round trip). +Revisit only if this becomes a measured hot spot; a claims-based cache would +still need a D1 fallback path to catch tokens issued before the cache was +introduced. + ## Local development Requires Node >= 18 (the tests) and, for the running Worker, `wrangler`. @@ -273,7 +319,12 @@ Signed-in panels hydrate client-side by calling this API with credentials ```text GET /api/me - -> 200 { authenticated: true, pending_consent: false, + -> 200 { authenticated: true, pending_consent: false, reconsent_required: false, + learner: { github_user_id, display_name }, + session: { expires_at, refreshed } } + -> 200 { authenticated: true, pending_consent: false, reconsent_required: true, + consent_required: { terms_version, effective_date, + terms_url, privacy_url }, learner: { github_user_id, display_name }, session: { expires_at, refreshed } } -> 200 { authenticated: true, pending_consent: true, @@ -284,9 +335,41 @@ GET /api/me -> 401 (not signed in) — render the signed-out state, make NO further calls ``` +`reconsent_required` (t6, spec c10/h2) is **additive**: it is always present +on a full session (`pending_consent: false`), defaulting to `false`; the +sibling `consent_required` field appears **only** when it's `true` — same +shape as the pending-session `consent_required`, so one notice component +renders both "first consent" and "re-consent" cases. Unlike the +`requireConsented`-gated routes below, `/api/me` never 403s for stale +consent — it keeps reporting identity/session so the client can explain +*why* everything else just started rejecting. + Sign-in button: link to `GET /api/auth/login`. Sign-out: `POST /api/auth/logout`. Progress/record shapes are identical to the CLI's above. +### For t6 (re-consent on a published version bump) + +`GET /api/progress/:subject`, `POST /api/record`, and `POST /api/tutor` all +go through `requireConsented`, which now rejects TWO distinct situations with +the same `403 consent_required` status but a distinguishing `reason`: + +```text +403 { error: "consent_required", message, hint, + reason: "pending", // never consented (or declined) yet + consent_required: { terms_version, effective_date, terms_url, privacy_url } } + +403 { error: "consent_required", message, hint, + reason: "stale_version", // consented, but to a superseded version + consent_required: { terms_version, effective_date, terms_url, privacy_url } } +``` + +Either way, the client's recovery is identical: send the learner through the +consent notice (or straight to `POST /api/consent/accept` if it already has +their acknowledgement) — `consent_required.terms_version` is always the +version to accept. `POST /api/consent/accept` itself never 403s this way: it +runs on `requireAuth` (any valid, unexpired session, pending or full), so it +stays reachable specifically to recover from `reason: "stale_version"`. + ### For t10 (the consent page, `/learn/consent/`) An unconsented web sign-in 302s from the OAuth callback to `/learn/consent/` @@ -310,12 +393,20 @@ carrying a pending-consent `session` cookie (10 min TTL). The page: node --test ``` -74 tests cover session sign/verify/expiry, `recorded` validation (including the +84 tests cover session sign/verify/expiry, `recorded` validation (including the `score`/`grade`/`points` rejection), the full record round-trip, per-learner ledger isolation, web + device OAuth flows, the consent gate (zero D1 writes on both unconsented sign-in paths, the pending-session 403 wall, accept ordering — -consent row before learner row — and write-free decline), and — critically — -that a signed-out `/api/tutor` request returns `401` with **zero** inference -calls. Tests invoke the Worker's `fetch` handler directly with in-memory KV/D1 -stubs (the D1 stub logs every write statement, making "zero writes" literal); -no network and no wrangler are needed. +consent row before learner row — and write-free decline), the re-consent gate +(`test/reconsent.test.js`: a simulated `TERMS_VERSION` bump re-routes a +previously-consented learner's next sign-in to pending-consent with zero new +writes, walls off a live full-session token at every `requireConsented` +route with `reason: "stale_version"`, the full consent-v1 → bump → 403 → +re-accept → 200 cycle, and `/api/me`'s additive `reconsent_required` +reporting), and — critically — that a signed-out `/api/tutor` request returns +`401` with **zero** inference calls. Tests invoke the Worker's `fetch` handler +directly with in-memory KV/D1 stubs (the D1 stub logs every write statement, +making "zero writes" literal); no network and no wrangler are needed. A +published-version bump is simulated with `env.TERMS_VERSION_OVERRIDE` (see +`src/consent.js#currentTermsVersion`) — the real published version in +`shared/terms-version.mjs` is never edited by a test. diff --git a/workers/learn-api/src/auth.js b/workers/learn-api/src/auth.js index d8609f9..cf46015 100644 --- a/workers/learn-api/src/auth.js +++ b/workers/learn-api/src/auth.js @@ -9,6 +9,8 @@ import { HttpError, parseCookies } from "./util.js"; import { verifySession, isPendingConsent } from "./session.js"; +import { getConsent } from "./db.js"; +import { consentSatisfiesCurrentTerms, consentRequirement } from "./consent.js"; /** Extract a session token from a Bearer header (CLI/MCP) or cookie (web). */ export function extractToken(request) { @@ -51,14 +53,31 @@ export async function requireAuth(request, env) { /** * Require a valid, CONSENTED session — requireAuth plus the pending-consent - * gate (spec decision c19). A pending-consent token authenticates the learner - * but grants nothing beyond /api/me, the consent endpoints, and logout; every - * learner-scoped route (progress, record, tutor, ...) passes through here and - * rejects it with a structured 403 BEFORE touching D1 or inference. + * gate (spec decision c19) plus the re-consent gate (spec c10/h2, task t6). + * A pending-consent token authenticates the learner but grants nothing + * beyond /api/me, the consent endpoints, and logout; every learner-scoped + * route (progress, record, tutor, ...) passes through here and rejects it + * with a structured 403 BEFORE touching D1 or inference. * - * t5 gates on the token's own marker only: a full session was necessarily - * issued via consent accept (or a consented sign-in), so no D1 read is needed - * here. t6 (re-consent on version bump) adds the stored-version check. + * t5 gated on the token's own marker only: "a full session was necessarily + * issued via consent accept (or a consented sign-in), so no D1 read is + * needed here." That assumption only holds AT ISSUE TIME — it says nothing + * about whether the published terms have moved on since. t6 closes that gap: + * a full session additionally gets its STORED consent re-checked against the + * currently published version on every protected request, so a + * TERMS_VERSION bump walls off even a live, unexpired full-session token + * immediately (no waiting for it to expire or refresh). + * + * Design/cost tradeoff (see README "Storage" for the write-up): this is one + * extra D1 read (`getConsent`) per requireConsented-gated request. The + * alternative — stamp the consented version into the session token's signed + * claims at issue time and compare claim-to-current with zero D1 reads — was + * rejected for v1: it would require every session-issuing call site + * (callback, device poll, consent accept, AND the sliding-refresh re-issue in + * handleMe) to correctly propagate the claim, and a bug in any one of them + * would silently under- or over-grant access. A D1 read is the simple, + * obviously-correct baseline; revisit only if this route's read volume + * becomes a measured hot spot. * @returns {object} the verified, consented session payload. */ export async function requireConsented(request, env) { @@ -69,6 +88,17 @@ export async function requireConsented(request, env) { "consent_required", "Consent to the current Terms of Use and Privacy Policy is required first.", "Review GET /api/consent, then POST /api/consent/accept — or /api/consent/decline to leave with nothing stored.", + { reason: "pending", consent_required: consentRequirement(env) }, + ); + } + const consent = await getConsent(env, payload.uid); + if (!consentSatisfiesCurrentTerms(consent, env)) { + throw new HttpError( + 403, + "consent_required", + "The Terms of Use / Privacy Policy have changed since you last consented — please review and re-accept.", + "Review GET /api/consent, then POST /api/consent/accept to restore access.", + { reason: "stale_version", consent_required: consentRequirement(env) }, ); } return payload; diff --git a/workers/learn-api/src/consent.js b/workers/learn-api/src/consent.js index c87eef2..aa2239b 100644 --- a/workers/learn-api/src/consent.js +++ b/workers/learn-api/src/consent.js @@ -6,31 +6,53 @@ // are written; the learner row is only upserted after POST /api/consent/accept // records a consent row (in that order — the consents table deliberately has // no FK to learners so consent can exist first). +// +// Spec c10/h2 (task t6): re-consent on a published version bump. Extends the +// SAME predicate to require an exact version match — sign-in (t5's +// territory) and requireConsented's stored-version check (auth.js, t6) both +// consult `consentSatisfiesCurrentTerms`, so "consented" means one thing +// everywhere. import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "./terms.js"; +/** + * The terms version a request should be evaluated against. Test seam only — + * mirrors util.js#outboundFetch's `env.FETCH` pattern: production never + * declares `TERMS_VERSION_OVERRIDE` (it is not in wrangler.toml or + * .dev.vars), so `wrangler dev` and prod always resolve the real + * shared/terms-version.mjs value. Tests simulate a published-version bump + * with `makeEnv({ TERMS_VERSION_OVERRIDE: "2.0.0" })` — never by editing the + * shared source, which stays the actual published version ("1.0.0"). + * Never hardcode TERMS_VERSION elsewhere; always resolve it through here. + */ +export function currentTermsVersion(env) { + return (env && env.TERMS_VERSION_OVERRIDE) || TERMS_VERSION; +} + /** * Does this consent row (from db.js getConsent — the learner's most recent * consent, or null) satisfy the currently published terms? * - * t5 scope: ANY recorded consent satisfies. Task t6 extends this single - * predicate to require `consent.terms_version === TERMS_VERSION` exactly, so - * a published version bump routes the learner back to the consent screen - * (re-consent, spec h2). Extend it HERE — sign-in paths and t6's re-consent - * check must share one definition of "consented". + * t5 scope was "ANY recorded consent satisfies". t6 (spec c10/h2) tightens + * this to an EXACT version match: a consent granted against a + * since-superseded version no longer satisfies, which is what forces + * re-consent — on a fresh sign-in (index.js handleCallback/handleDevice) AND + * on a live full-session token hitting a requireConsented-gated route + * (auth.js). */ -export function consentSatisfiesCurrentTerms(consent) { - return consent != null; +export function consentSatisfiesCurrentTerms(consent, env) { + return consent != null && consent.terms_version === currentTermsVersion(env); } /** * What consent is currently required — the shape clients render the notice - * from. Embedded in /api/me (pending sessions), the device-poll - * `consent_required` response, and GET /api/consent. + * from. Embedded in /api/me (pending sessions, and now — t6 — a full session + * whose consent has gone stale), the device-poll `consent_required` + * response, GET /api/consent, and the requireConsented 403 body. */ -export function consentRequirement() { +export function consentRequirement(env) { return { - terms_version: TERMS_VERSION, + terms_version: currentTermsVersion(env), effective_date: TERMS_EFFECTIVE_DATE, terms_url: "/learn/terms/", privacy_url: "/learn/privacy/", diff --git a/workers/learn-api/src/index.js b/workers/learn-api/src/index.js index 8597db4..0269d71 100644 --- a/workers/learn-api/src/index.js +++ b/workers/learn-api/src/index.js @@ -18,14 +18,17 @@ // POST /api/tutor consent broker -> env.INFERENCE_URL (model call) // // auth* = any valid session, INCLUDING pending-consent (requireAuth). -// consent = full session only; pending-consent tokens get a structured 403 -// (requireConsented) before the route body runs. +// consent = full session AND its stored consent still matches the currently +// published terms version; pending-consent OR stale-version +// sessions get a structured 403 (requireConsented) before the +// route body runs. // // Resource-gate invariant: requireAuth()/requireConsented() runs before the // body of every auth route. POST /api/tutor is the only route that spends -// model tokens, and it is unreachable without a valid CONSENTED session — -// signed-out AND pending-consent traffic can NEVER trigger a model call. -// Proven in worker.test.js + consent.test.js. +// model tokens, and it is unreachable without a valid, CURRENTLY-CONSENTED +// session — signed-out, pending-consent, AND stale-consent traffic can NEVER +// trigger a model call. Proven in worker.test.js + consent.test.js + +// reconsent.test.js. // // Consent-gate invariant (spec c9/h1, decision c19): NEITHER sign-in path // (web callback, device poll) writes to D1 unless a recorded consent already @@ -34,6 +37,15 @@ // viewing the consent requirement and accepting/declining it. Accept records // the consent row FIRST, then upserts the learner. Decline revokes the // session with nothing ever written. Proven in consent.test.js. +// +// Re-consent invariant (spec c10/h2, task t6): "satisfies the current terms" +// means an EXACT terms_version match (consent.js#consentSatisfiesCurrentTerms) +// — a version bump makes a previously-consented learner's NEXT sign-in land +// pending-consent again (zero new D1 writes, same as a first-time sign-in), +// and makes requireConsented reject their EXISTING live full-session token +// with 403 consent_required until they re-accept via POST +// /api/consent/accept (which is reachable throughout, since it runs on +// requireAuth, not requireConsented). Proven in reconsent.test.js. import { HttpError, @@ -51,8 +63,11 @@ import { DEFAULT_TTL_SECONDS, PENDING_CONSENT_TTL_SECONDS, } from "./session.js"; -import { consentSatisfiesCurrentTerms, consentRequirement } from "./consent.js"; -import { TERMS_VERSION } from "./terms.js"; +import { + consentSatisfiesCurrentTerms, + consentRequirement, + currentTermsVersion, +} from "./consent.js"; import { authorizeUrl, exchangeCode, @@ -186,7 +201,7 @@ async function handleCallback(request, env) { // the consent page with a short-lived pending-consent cookie; the learner // row is only created by POST /api/consent/accept. const consent = await getConsent(env, user.uid); - if (!consentSatisfiesCurrentTerms(consent)) { + if (!consentSatisfiesCurrentTerms(consent, env)) { const { token } = await issueSession(env, user, PENDING_CONSENT_TTL_SECONDS, { pendingConsent: true, }); @@ -231,7 +246,7 @@ async function handleDevice(request, env) { // Bearer token plus the consent requirement so it can prompt; it then // drives POST /api/consent/accept (returns the full token) or /decline. const consent = await getConsent(env, user.uid); - if (!consentSatisfiesCurrentTerms(consent)) { + if (!consentSatisfiesCurrentTerms(consent, env)) { const { token, payload } = await issueSession(env, user, PENDING_CONSENT_TTL_SECONDS, { pendingConsent: true, }); @@ -240,7 +255,7 @@ async function handleDevice(request, env) { token, token_type: "Bearer", expires_at: payload.exp, - consent_required: consentRequirement(), + consent_required: consentRequirement(env), learner: { github_user_id: user.uid, display_name: user.name }, }); } @@ -264,7 +279,7 @@ async function handleDevice(request, env) { // can render the notice — version, effective date, policy links — without a // session; a signed-out reader learns nothing personal here. function handleConsentGet(env) { - return jsonResponse(200, consentRequirement()); + return jsonResponse(200, consentRequirement(env)); } // Accept the current terms. Order is the contract (spec c9): the consent row @@ -275,9 +290,15 @@ function handleConsentGet(env) { // the two faces stay symmetric with the sign-in paths. // Idempotent for an already-consented session: same-version re-accept just // refreshes granted_at (db.js recordConsent upserts). +// t6 (spec c10/h2): this is ALSO the re-consent route. It deliberately runs +// through requireAuth, not requireConsented — a full session whose stored +// consent has gone stale must still be able to reach this route to fix +// that, even though every requireConsented-gated route now rejects it. +// currentTermsVersion(env), never a bare TERMS_VERSION import, so a +// published bump is what gets stamped on re-accept. async function handleConsentAccept(request, env) { const session = await requireAuth(request, env); // pending-consent allowed — that's the point. - const consent = await recordConsent(env, session.uid, TERMS_VERSION); + const consent = await recordConsent(env, session.uid, currentTermsVersion(env)); await upsertLearner(env, { uid: session.uid, name: session.name }); await revokeSession(env, session); // the pending (or prior) token dies with the upgrade const { token, payload } = await issueSession(env, { uid: session.uid, name: session.name }); @@ -337,12 +358,23 @@ async function handleMe(request, env) { return jsonResponse(200, { authenticated: true, pending_consent: true, - consent_required: consentRequirement(), + consent_required: consentRequirement(env), learner: { github_user_id: session.uid, display_name: session.name }, session: { expires_at: session.exp, refreshed: false }, }); } + // t6 (spec c10/h2, AC4): a full session never 403s at /api/me — unlike + // requireConsented-gated routes, this one REPORTS the re-consent + // requirement instead of walling the learner out of their own identity + // check. Additive only: `reconsent_required` is a NEW field (false in the + // common case); `consent_required` is included ONLY when it's true, + // mirroring the pending-session shape above so a client renders the same + // notice component either way. One extra D1 read (`getConsent`) alongside + // the existing `getLearner` read — see README "Storage" for the tradeoff. + const consent = await getConsent(env, session.uid); + const reconsentRequired = !consentSatisfiesCurrentTerms(consent, env); + const learner = (await getLearner(env, session.uid)) || { github_user_id: session.uid, display_name: session.name, @@ -358,6 +390,8 @@ async function handleMe(request, env) { { authenticated: true, pending_consent: false, + reconsent_required: reconsentRequired, + ...(reconsentRequired ? { consent_required: consentRequirement(env) } : {}), learner: { github_user_id: learner.github_user_id, display_name: learner.display_name, diff --git a/workers/learn-api/src/util.js b/workers/learn-api/src/util.js index e963360..6201436 100644 --- a/workers/learn-api/src/util.js +++ b/workers/learn-api/src/util.js @@ -63,13 +63,22 @@ export function nowSeconds() { return Math.floor(Date.now() / 1000); } -/** A structured HTTP error that the router turns into a JSON response. */ +/** + * A structured HTTP error that the router turns into a JSON response. + * `extra` (task t6) merges additional fields into the body — e.g. the + * consent-gate 403s attach `{ reason, consent_required }` so a client can + * tell "never consented" apart from "consented to a since-superseded + * version" and render the current requirement without a second round trip. + * Additive only: every pre-existing call site (no 5th argument) is + * unaffected — `extra` defaults to `{}`. + */ export class HttpError extends Error { - constructor(status, code, message, hint = "") { + constructor(status, code, message, hint = "", extra = {}) { super(message); this.status = status; this.code = code; this.hint = hint; + this.extra = extra; } toResponse() { @@ -77,6 +86,7 @@ export class HttpError extends Error { error: this.code, message: this.message, hint: this.hint, + ...this.extra, }); } } diff --git a/workers/learn-api/test/reconsent.test.js b/workers/learn-api/test/reconsent.test.js new file mode 100644 index 0000000..5b5acc2 --- /dev/null +++ b/workers/learn-api/test/reconsent.test.js @@ -0,0 +1,269 @@ +// Re-consent on a published terms-version bump (spec c10/h2; plan task t6). +// +// t5 built consentSatisfiesCurrentTerms() to accept ANY recorded consent. +// t6's whole job is extending that SAME predicate to require an EXACT +// `consent.terms_version` match against the currently published version — +// and then making sure both the sign-in paths (t5's territory, index.js +// handleCallback/handleDevice) and requireConsented (auth.js — the wall in +// front of progress/record/tutor) consult it, so a version bump: +// +// (a) routes a previously-consented learner's NEXT sign-in back to +// pending-consent, with zero NEW D1 writes (same shape as a first-time +// sign-in — h1's invariant, extended one level); +// (b) walls off an EXISTING, still-unexpired full-session token at every +// requireConsented-gated route with a structured 403, until the +// learner re-accepts; +// (c) /api/me reports the requirement (additive field) instead of 403ing, +// since identity/session info must stay reachable to explain WHY +// everything else just started rejecting. +// +// Tests simulate the bump with `env.TERMS_VERSION_OVERRIDE` (see +// consent.js#currentTermsVersion) — shared/terms-version.mjs, the actual +// published source, is never touched and stays "1.0.0" throughout. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import worker from "../src/index.js"; +import { GH } from "../src/github.js"; +import { TERMS_VERSION } from "../src/terms.js"; +import { + consentSatisfiesCurrentTerms, + currentTermsVersion, + consentRequirement, +} from "../src/consent.js"; +import { makeEnv, makeFetchStub, mintToken, authedRequest, jsonResp, seedConsent } from "./helpers.js"; + +const BASE = "https://learn-api.example"; +const INFERENCE_URL = "https://inference.example/v1/messages"; +const BUMPED = "2.0.0"; // a simulated published-version bump; never the real shared value + +function call(env, request) { + return worker.fetch(request, env, { waitUntil() {} }); +} + +function envWithGitHubUser(user = { id: 42, login: "ada", name: "Ada" }, overrides = {}) { + const fetchStub = makeFetchStub({ + [GH.token]: () => jsonResp({ access_token: "gho_x", token_type: "bearer" }), + [GH.user]: () => jsonResp(user), + }); + return makeEnv({ FETCH: fetchStub, APP_URL: "https://agentculture.org/learn/", ...overrides }); +} + +function webCallbackRequest() { + return new Request(`${BASE}/api/auth/callback?code=abc&state=xyz`, { + headers: { Cookie: "oauth_state=xyz" }, + }); +} + +function devicePollRequest() { + return new Request(`${BASE}/api/auth/device`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "poll", device_code: "dc_123" }), + }); +} + +// --- the predicate + version-resolution seam -------------------------------- + +test("currentTermsVersion resolves the real published version by default", () => { + assert.equal(currentTermsVersion(makeEnv()), TERMS_VERSION); + assert.equal(currentTermsVersion(undefined), TERMS_VERSION); +}); + +test("currentTermsVersion honors the test-only env override without touching the shared source", () => { + const env = makeEnv({ TERMS_VERSION_OVERRIDE: BUMPED }); + assert.equal(currentTermsVersion(env), BUMPED); + // The single source of truth is untouched — production never sets this var. + assert.equal(TERMS_VERSION, "1.0.0"); +}); + +test("consentSatisfiesCurrentTerms requires an EXACT version match, not just any consent", () => { + const env = makeEnv({ TERMS_VERSION_OVERRIDE: BUMPED }); + assert.equal(consentSatisfiesCurrentTerms(null, env), false); + assert.equal(consentSatisfiesCurrentTerms({ terms_version: TERMS_VERSION }, env), false); + assert.equal(consentSatisfiesCurrentTerms({ terms_version: BUMPED }, env), true); +}); + +// --- h2 (AC1): accept always stamps the EXACT currently-published version --- + +test("h2: accept stamps the row with whatever version is CURRENTLY published, never a hardcoded one", async () => { + const env = makeEnv({ TERMS_VERSION_OVERRIDE: BUMPED }); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }, 600, { pendingConsent: true }); + + const res = await call(env, authedRequest(`${BASE}/api/consent/accept`, token, { method: "POST" })); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.consent.terms_version, BUMPED); + assert.equal(env.DB.consents[0].terms_version, BUMPED); +}); + +// --- AC2(a): fresh sign-in by a previously-consented learner re-routes ----- + +test("web callback: a learner consented to a NOW-SUPERSEDED version lands pending-consent again, zero NEW D1 writes", async () => { + const env = envWithGitHubUser({ id: 42, login: "ada", name: "Ada" }, { TERMS_VERSION_OVERRIDE: BUMPED }); + seedConsent(env, "42", TERMS_VERSION); // consented to the OLD version only + + const res = await call(env, webCallbackRequest()); + + assert.equal(res.status, 302); + assert.equal(res.headers.get("Location"), "https://agentculture.org/learn/consent/"); + assert.equal(env.DB.writes.length, 0, "no NEW row — a stale consent routes back to pending, same as first sign-in"); + assert.equal(env.DB.learners.size, 0, "learner row still not (re-)created"); + + const me = await (await call(env, authedRequest(`${BASE}/api/me`, sessionCookieFrom(res)))).json(); + assert.equal(me.pending_consent, true); + assert.equal(me.consent_required.terms_version, BUMPED); +}); + +test("device poll: a learner consented to a NOW-SUPERSEDED version gets consent_required again, zero NEW D1 writes", async () => { + const env = envWithGitHubUser({ id: 42, login: "ada", name: "Ada" }, { TERMS_VERSION_OVERRIDE: BUMPED }); + seedConsent(env, "42", TERMS_VERSION); + + const res = await call(env, devicePollRequest()); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.status, "consent_required"); + assert.equal(body.consent_required.terms_version, BUMPED); + assert.equal(env.DB.writes.length, 0, "no NEW row written before re-acceptance"); +}); + +function sessionCookieFrom(res) { + const c = res.headers.getSetCookie().find((v) => v.startsWith("session=")); + return c ? decodeURIComponent(c.split(";")[0].slice("session=".length)) : null; +} + +// --- AC2(b): an existing, live full-session token gets walled off too ------ + +test("requireConsented: a live full-session token with stale consent 403s on progress, record, AND tutor — zero inference calls", async () => { + const fetchStub = makeFetchStub({ [INFERENCE_URL]: () => jsonResp({ reply: "never" }) }); + const env = makeEnv({ + INFERENCE_URL, + INFERENCE_TOKEN: "secret", + FETCH: fetchStub, + TERMS_VERSION_OVERRIDE: BUMPED, + }); + seedConsent(env, "42", TERMS_VERSION); // consented to the version that is now superseded + const { token } = await mintToken(env, { uid: "42", name: "Ada" }); // a LIVE, unexpired full session + + const progress = await call(env, authedRequest(`${BASE}/api/progress/french`, token)); + assert.equal(progress.status, 403); + const progressBody = await progress.json(); + assert.equal(progressBody.error, "consent_required"); + assert.equal(progressBody.reason, "stale_version"); + assert.equal(progressBody.consent_required.terms_version, BUMPED); + + const record = await call( + env, + authedRequest(`${BASE}/api/record`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subject: "french", + recorded: { item_id: "x", activity: "practice", result: "pass", at: "2026-07-11T10:00:00Z" }, + }), + }), + ); + assert.equal(record.status, 403); + assert.equal((await record.json()).reason, "stale_version"); + + const tutor = await call( + env, + authedRequest(`${BASE}/api/tutor`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subject: "french", messages: [] }), + }), + ); + assert.equal(tutor.status, 403); + assert.equal((await tutor.json()).reason, "stale_version"); + + // The resource-gate invariant holds for stale consent too: zero outbound + // inference calls, and rejecting a request writes nothing. + assert.equal(fetchStub.calls.length, 0, "inference endpoint must not be touched"); + assert.equal(env.DB.writes.length, 0, "a rejected stale-consent request writes nothing"); +}); + +// --- AC3: the full cycle — consent v1 -> bump -> 403 -> re-accept -> 200 --- + +test("full cycle: consent v1 -> bump TERMS_VERSION -> 403 -> re-accept -> 200 restores access", async () => { + const env = makeEnv(); + + // Step 1 — consent to v1 via the real accept flow. + const { token: pendingTok } = await mintToken(env, { uid: "42", name: "Ada" }, 600, { + pendingConsent: true, + }); + const acceptV1 = await ( + await call(env, authedRequest(`${BASE}/api/consent/accept`, pendingTok, { method: "POST" })) + ).json(); + assert.equal(acceptV1.consent.terms_version, TERMS_VERSION); + const fullTokenV1 = acceptV1.token; + + // Sanity: access works pre-bump. + assert.equal((await call(env, authedRequest(`${BASE}/api/progress/french`, fullTokenV1))).status, 200); + + // Step 2 — the published version bumps. + env.TERMS_VERSION_OVERRIDE = BUMPED; + + // Step 3 — the SAME still-live token now 403s. + const blocked = await call(env, authedRequest(`${BASE}/api/progress/french`, fullTokenV1)); + assert.equal(blocked.status, 403); + const blockedBody = await blocked.json(); + assert.equal(blockedBody.reason, "stale_version"); + assert.equal(blockedBody.consent_required.terms_version, BUMPED); + + // Step 4 — re-accept. POST /api/consent/accept stays reachable throughout + // (requireAuth, not requireConsented) precisely so this recovery path exists. + const acceptV2 = await ( + await call(env, authedRequest(`${BASE}/api/consent/accept`, fullTokenV1, { method: "POST" })) + ).json(); + assert.equal(acceptV2.consent.terms_version, BUMPED); + const fullTokenV2 = acceptV2.token; + + // Step 5 — access is restored with the newly-issued token. + const restored = await call(env, authedRequest(`${BASE}/api/progress/french`, fullTokenV2)); + assert.equal(restored.status, 200); + + // The old (v1) token was revoked on upgrade, same as t5's pending->full path. + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, fullTokenV1))).status, 401); + + // Two distinct version rows recorded for the same learner — the + // append-only-per-version design from db.js (t2), not an overwrite. + assert.deepEqual( + env.DB.consents.map((c) => c.terms_version).sort(), + [TERMS_VERSION, BUMPED].sort(), + ); +}); + +// --- AC4: /api/me reports, never walls off ---------------------------------- + +test("GET /api/me for a full session with stale consent reports reconsent_required (additive), never 403s", async () => { + const env = makeEnv({ TERMS_VERSION_OVERRIDE: BUMPED }); + seedConsent(env, "42", TERMS_VERSION); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }); + + const res = await call(env, authedRequest(`${BASE}/api/me`, token)); + assert.equal(res.status, 200, "/api/me reports the requirement instead of walling the learner off"); + const body = await res.json(); + assert.equal(body.authenticated, true); + assert.equal(body.pending_consent, false); + assert.equal(body.reconsent_required, true); + assert.deepEqual(body.consent_required, consentRequirement(env)); + assert.equal(body.learner.github_user_id, "42"); +}); + +test("GET /api/me: reconsent_required is false and consent_required is absent when consent is current", async () => { + const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }); + + const res = await call(env, authedRequest(`${BASE}/api/me`, token)); + const body = await res.json(); + assert.equal(body.reconsent_required, false); + assert.equal(body.consent_required, undefined, "additive field must not appear when consent is current"); + // Prior shape (pre-t6 fields) is intact. + assert.equal(body.authenticated, true); + assert.equal(body.pending_consent, false); + assert.equal(body.learner.github_user_id, "42"); + assert.ok(body.session && typeof body.session.expires_at === "number"); +}); diff --git a/workers/learn-api/test/worker.test.js b/workers/learn-api/test/worker.test.js index b82ee78..ae9a599 100644 --- a/workers/learn-api/test/worker.test.js +++ b/workers/learn-api/test/worker.test.js @@ -73,6 +73,7 @@ test("signed-in POST /api/tutor brokers to INFERENCE_URL exactly once", async () [INFERENCE_URL]: () => jsonResp({ reply: "Bonjour !", tokens: 12 }), }); const env = makeEnv({ INFERENCE_URL, INFERENCE_TOKEN: "infer-token", FETCH: fetchStub }); + seedConsent(env, "42", TERMS_VERSION); // a returning, currently-consented learner (t6) const { token } = await mintToken(env, { uid: "42", name: "Ada" }); const res = await call( @@ -99,6 +100,7 @@ test("signed-in POST /api/tutor brokers to INFERENCE_URL exactly once", async () test("broker returns 503 when INFERENCE_URL is unset (still auth-gated first)", async () => { const env = makeEnv(); // no INFERENCE_URL + seedConsent(env, "42", TERMS_VERSION); // default mintToken() learner (t6) const { token } = await mintToken(env); const res = await call( env, @@ -115,6 +117,7 @@ test("broker returns 503 when INFERENCE_URL is unset (still auth-gated first)", test("record round-trip: POST /api/record then GET /api/progress reflects it", async () => { const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); // a returning, currently-consented learner (t6) const { token } = await mintToken(env, { uid: "42", name: "Ada" }); const recorded = { @@ -159,6 +162,8 @@ test("record round-trip: POST /api/record then GET /api/progress reflects it", a test("ledger is append-only and per-learner isolated", async () => { const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); // both are returning, currently-consented learners (t6) + seedConsent(env, "99", TERMS_VERSION); const { token: adaTok } = await mintToken(env, { uid: "42", name: "Ada" }); const { token: linusTok } = await mintToken(env, { uid: "99", name: "Linus" }); @@ -199,6 +204,7 @@ test("ledger is append-only and per-learner isolated", async () => { test("POST /api/record rejects a score field with 400", async () => { const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); // default mintToken() learner (t6) const { token } = await mintToken(env); const res = await call( env, From aefb03ccb1d2936df21f05bec2285e79c4d3a032 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 11:11:51 +0300 Subject: [PATCH 09/24] feat(site): consent UI page wired to the pending-consent API (t10) Add site-astro/src/pages/consent/ (spec c9/c19) presenting the WHAT/WHY/ RETENTION/WHO-CAN-SEE-IT notice and a dedicated src/scripts/consent.js that drives it against workers/learn-api's pending-consent contract: GET /api/me + GET /api/consent to render live state, POST /api/consent/accept to upgrade into the signed-in hub, POST /api/consent/decline to confirm nothing was stored. Handles pending, expired (401, 10-min TTL), already-signed-in, declined, and error states. Extend check-export-pages.mjs's KNOWN_NON_SUBJECT_DIRS and check-static-auth.mjs's NOT_A_LEARNER_PANEL_PAGE for the new /consent/ route, and add a precise (not blanket) CONSENT_ALLOWED_SUFFIX_RE fetch whitelist for consent.js, separate from learner.js's own. Fix the built-bundle whitelist check to collect API_BASE aliases globally across dist/ instead of per-file, since Vite now code-splits src/lib/api.ts into its own shared chunk now that two scripts import it. tests/test_consent_page.py locks the page's key claims (no email/no password, imports TERMS_VERSION rather than hardcoding it, links both policy pages, all five states present) the same way test_policy_pages.py does for Terms/Privacy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- site-astro/scripts/check-export-pages.mjs | 6 +- site-astro/scripts/check-static-auth.mjs | 104 ++++++- site-astro/src/pages/consent/index.astro | 263 ++++++++++++++++++ site-astro/src/scripts/consent.js | 165 +++++++++++ tests/test_consent_page.py | 321 ++++++++++++++++++++++ 5 files changed, 846 insertions(+), 13 deletions(-) create mode 100644 site-astro/src/pages/consent/index.astro create mode 100644 site-astro/src/scripts/consent.js create mode 100644 tests/test_consent_page.py diff --git a/site-astro/scripts/check-export-pages.mjs b/site-astro/scripts/check-export-pages.mjs index c40eae6..c885361 100644 --- a/site-astro/scripts/check-export-pages.mjs +++ b/site-astro/scripts/check-export-pages.mjs @@ -122,8 +122,10 @@ function listDirs(dir) { // "terms"/"privacy" are t1's versioned policy pages (src/pages/terms/, // src/pages/privacy/) — real top-level routes with no backing entry in the -// content-export, since they aren't subject content. -const KNOWN_NON_SUBJECT_DIRS = new Set(["_astro", "terms", "privacy"]); +// content-export, since they aren't subject content. "consent" is t10's +// pending-consent notice (src/pages/consent/) — same reasoning: a real +// top-level route, not subject content. +const KNOWN_NON_SUBJECT_DIRS = new Set(["_astro", "terms", "privacy", "consent"]); for (const dirName of listDirs(distDir)) { if (KNOWN_NON_SUBJECT_DIRS.has(dirName)) continue; if (!subjectNames.has(dirName)) { diff --git a/site-astro/scripts/check-static-auth.mjs b/site-astro/scripts/check-static-auth.mjs index c793898..ea88010 100644 --- a/site-astro/scripts/check-static-auth.mjs +++ b/site-astro/scripts/check-static-auth.mjs @@ -171,8 +171,12 @@ check("the landing page and every subject page render the signed-out invitation // t1's versioned policy pages (src/pages/terms/, src/pages/privacy/) are // plain static prose with no learner panel — they carry no // signed-in/signed-out split at all, so this check (which is about that - // split, not "every top-level page") doesn't apply to them. - const NOT_A_LEARNER_PANEL_PAGE = new Set(["_astro", "terms", "privacy"]); + // split, not "every top-level page") doesn't apply to them. t10's consent + // notice (src/pages/consent/) is the same shape one level further: it has + // its OWN pending/expired/already-in/declined/error states (driven by + // src/scripts/consent.js, checked separately below), not the site-wide + // signed-out/signed-in split this check is about. + const NOT_A_LEARNER_PANEL_PAGE = new Set(["_astro", "terms", "privacy", "consent"]); for (const entry of readdirSync(distDir, { withFileTypes: true })) { if (!entry.isDirectory() || NOT_A_LEARNER_PANEL_PAGE.has(entry.name)) continue; const subjectIndex = path.join(distDir, entry.name, "index.html"); @@ -205,6 +209,15 @@ const learnerJs = readFileSync(learnerJsPath, "utf8"); const ALLOWED_SUFFIX_RE = /^(\/me|\/progress\/|\/record|\/auth\/)/; +// t10's consent.js is a SECOND, separately-loaded script (only on +// src/pages/consent/index.astro, not via Layout.astro) with its own, +// narrower fetch surface — enumerated exactly, not merged into the +// learner.js whitelist above, so a future edit that widens one script can't +// silently widen the other. Used below for consent.js's own source check, +// and folded into the built-bundle whitelist further down (that check scans +// every built .js file regardless of which source script produced it). +const CONSENT_ALLOWED_SUFFIX_RE = /^(\/me|\/consent$|\/consent\/accept$|\/consent\/decline$)/; + check("learner.js imports the single API_BASE constant (no hardcoded alternate host)", () => { assert.match(learnerJs, /import\s*\{\s*API_BASE\s*\}\s*from\s*["']\.\.\/lib\/api\.js["']/); }); @@ -315,7 +328,11 @@ check("the two failure branches return before reaching the happy path", () => { // identifier(s) the build aliased API_BASE's literal value to, then // re-runs the same suffix whitelist against every fetch() call that uses // one of those aliases (or the literal value directly, in case a future -// build inlines it instead of aliasing it). +// build inlines it instead of aliasing it). This scans EVERY built .js +// asset regardless of which source script produced it, so it checks the +// UNION of both per-file whitelists (ALLOWED_SUFFIX_RE for learner.js, +// CONSENT_ALLOWED_SUFFIX_RE for consent.js) — the two source-level checks +// above/below are what keep each script's OWN surface precise. check("the built JS bundle's fetch() calls stay inside the same whitelist", () => { const apiSrc = readFileSync(path.join(srcDir, "lib", "api.ts"), "utf8"); @@ -327,16 +344,29 @@ check("the built JS bundle's fetch() calls stay inside the same whitelist", () = const jsFiles = listFiles(distDir, ".js"); assert.ok(jsFiles.length > 0, "expected at least one built .js asset (learner.js's bundle)"); - let sawApiBaseLiteral = false; + // Two source files now import API_BASE (learner.js, consent.js), so Vite + // code-splits src/lib/api.ts into its OWN shared chunk rather than + // inlining the literal into each consumer — the alias assignment + // (`var e = \`/learn/api\`;`) lives in that one shared chunk, while the + // fetch() calls using it live in the OTHER, importing chunks. A per-file + // alias scan (checking each file only against aliases found in that same + // file) would therefore find zero aliases in the files that actually call + // fetch() and fail every call. Collect aliases GLOBALLY across every + // built .js file first, then check every file's fetch() calls against + // that global set — correct regardless of how many chunks the bundler + // decides to split this into. + const aliasRe = new RegExp(`([A-Za-z_$][\\w$]*)\\s*=\\s*[\`"']${escapedValue}[\`"']`, "g"); + const aliases = new Set(); + for (const file of jsFiles) { + const js = readFileSync(file, "utf8"); + for (const m of js.matchAll(aliasRe)) aliases.add(m[1]); + } + let totalFetchCalls = 0; const offenders = []; for (const file of jsFiles) { const js = readFileSync(file, "utf8"); - const aliasRe = new RegExp(`([A-Za-z_$][\\w$]*)\\s*=\\s*[\`"']${escapedValue}[\`"']`, "g"); - const aliases = new Set([...js.matchAll(aliasRe)].map((m) => m[1])); - if (aliases.size > 0) sawApiBaseLiteral = true; - for (const [, template] of js.matchAll(/fetch\(\s*`([^`]*)`/g)) { totalFetchCalls += 1; let suffix = null; @@ -346,19 +376,71 @@ check("the built JS bundle's fetch() calls stay inside the same whitelist", () = } else if (template.startsWith(apiBaseValue)) { suffix = template.slice(apiBaseValue.length).split("$")[0]; } - if (suffix === null || !ALLOWED_SUFFIX_RE.test(suffix)) offenders.push(template); + const inWhitelist = + suffix !== null && (ALLOWED_SUFFIX_RE.test(suffix) || CONSENT_ALLOWED_SUFFIX_RE.test(suffix)); + if (!inWhitelist) offenders.push(template); } } - assert.ok(sawApiBaseLiteral, "API_BASE's literal value was not found inlined in any built JS asset"); + assert.ok(aliases.size > 0, "API_BASE's literal value was not found inlined in any built JS asset"); assert.ok(totalFetchCalls >= 4, `expected >= 4 fetch() calls across built JS, found ${totalFetchCalls}`); assert.deepEqual(offenders, [], `non-whitelisted fetch target(s) in built JS: ${offenders.join(", ")}`); }); +// --- 4. consent.js: precise fetch whitelist for the pending-consent page (t10) --- +// +// consent.js is loaded only on src/pages/consent/index.astro, not through +// Layout.astro's global learner.js import, so the checks above (which are +// hardcoded to learner.js's own path) never see it. Same technique, a +// narrower, precisely-enumerated whitelist (CONSENT_ALLOWED_SUFFIX_RE, +// defined above): /me, /consent, /consent/accept, /consent/decline — no +// /progress, /record, or open /auth/ prefix, since consent.js has no +// business calling those. + +const consentJsPath = path.join(srcDir, "scripts", "consent.js"); + +check("consent.js exists and imports the single API_BASE constant (no hardcoded alternate host)", () => { + assert.ok(existsSync(consentJsPath), "expected src/scripts/consent.js to exist"); + const consentJs = readFileSync(consentJsPath, "utf8"); + assert.match(consentJs, /import\s*\{\s*API_BASE\s*\}\s*from\s*["']\.\.\/lib\/api\.js["']/); +}); + +check("every fetch() call in consent.js targets ${API_BASE} plus a whitelisted consent suffix", () => { + const consentJs = readFileSync(consentJsPath, "utf8"); + const fetchCalls = [...consentJs.matchAll(/fetch\(\s*`([^`]*)`/g)]; + assert.ok(fetchCalls.length >= 3, `expected >= 3 fetch() calls in consent.js, found ${fetchCalls.length}`); + const offenders = []; + for (const [, template] of fetchCalls) { + if (!template.startsWith("${API_BASE}")) { + offenders.push(template); + continue; + } + const suffix = template.slice("${API_BASE}".length).split("$")[0]; + if (!CONSENT_ALLOWED_SUFFIX_RE.test(suffix)) offenders.push(template); + } + assert.deepEqual(offenders, [], `non-whitelisted fetch target(s) in consent.js: ${offenders.join(", ")}`); +}); + +check("consent.js's bootstrap() redirects out of pending state rather than exposing controls silently", () => { + const consentJs = readFileSync(consentJsPath, "utf8"); + // The five states consent.js is responsible for (see its header comment): + // the notice itself (default markup, no JS-only gate needed) plus these + // four JS-driven outcomes, each reachable from bootstrap(). + for (const state of ["expired", "already-in", "declined", "error"]) { + assert.match( + consentJs, + new RegExp(`showState\\("${state}"\\)`), + `consent.js must handle the "${state}" state`, + ); + } +}); + if (problems.length > 0) fail(); console.log( `check-static-auth: OK — ${htmlFiles.length} built page(s) verified static/no-JS-safe; ` + "learner.js's fetch surface is confined to the /api/me | /api/progress/ | /api/record | " + - "/api/auth/* whitelist and gated behind a confirmed session.", + "/api/auth/* whitelist and gated behind a confirmed session; consent.js's fetch surface " + + "is confined to the /api/me | /api/consent | /api/consent/accept | /api/consent/decline " + + "whitelist.", ); diff --git a/site-astro/src/pages/consent/index.astro b/site-astro/src/pages/consent/index.astro new file mode 100644 index 0000000..06d0e18 --- /dev/null +++ b/site-astro/src/pages/consent/index.astro @@ -0,0 +1,263 @@ +--- +// The consent notice — t10, spec c9/c19 ("the consent UX shape"). An +// unconsented sign-in (web callback) 302s here carrying a short-lived +// pending-consent `session` cookie (10 min TTL); see +// ../../../../workers/learn-api/README.md's "For t10" section for the exact +// contract this page drives (GET /api/me, GET /api/consent, POST +// /api/consent/accept, POST /api/consent/decline). +// +// Same Layout/PageHero shell as every other page here, zero new design +// tokens — the notice content below is static prose (visible with no JS at +// all, like the Terms/Privacy pages), and src/scripts/consent.js only +// toggles which [data-state] block is shown and wires the Accept/Decline +// buttons once it has confirmed a pending session. TERMS_VERSION/ +// TERMS_EFFECTIVE_DATE come from src/lib/terms.ts (the same shared source +// terms/index.astro and privacy/index.astro import) as the build-time +// fallback; consent.js overwrites the [data-field] spans/links with the +// LIVE GET /api/consent response at runtime, which is the authoritative +// value (it's what actually gets recorded on accept). tests/test_consent_page.py +// (repo root) locks this page's key claims and its import-not-hardcode wiring. +import Layout from "../../layouts/Layout.astro"; +import PageHero from "../../components/PageHero.astro"; +import site from "../../data/site"; +import { API_BASE } from "../../lib/api"; +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../lib/terms"; + +const description = `Review what ${site.title} stores, why, for how long, and who can see it — nothing is saved until you accept.`; +--- + + + + + + + + + + + + diff --git a/site-astro/src/scripts/consent.js b/site-astro/src/scripts/consent.js new file mode 100644 index 0000000..5ac1ba4 --- /dev/null +++ b/site-astro/src/scripts/consent.js @@ -0,0 +1,165 @@ +// consent.js — the pending-consent notice, wired to workers/learn-api's +// consent endpoints (t10; spec c9/c19, "the consent UX shape"; see +// workers/learn-api/README.md's "For t10" section for the exact +// request/response contract this file drives). Loaded ONLY on +// src/pages/consent/index.astro's own + + + diff --git a/site-astro/src/scripts/voice.js b/site-astro/src/scripts/voice.js new file mode 100644 index 0000000..454aa64 --- /dev/null +++ b/site-astro/src/scripts/voice.js @@ -0,0 +1,387 @@ +// voice.js — the voice-session page (t16; spec c15/h7). Loaded ONLY on +// src/pages/voice/index.astro's own + + diff --git a/site-astro/src/pages/[subject]/index.astro b/site-astro/src/pages/[subject]/index.astro index fe2cbc6..0046418 100644 --- a/site-astro/src/pages/[subject]/index.astro +++ b/site-astro/src/pages/[subject]/index.astro @@ -6,6 +6,7 @@ import Layout from "../../layouts/Layout.astro"; import PageHero from "../../components/PageHero.astro"; import LearnerPanelSubject from "../../components/LearnerPanelSubject.astro"; +import TutorPanel from "../../components/TutorPanel.astro"; import site from "../../data/site"; import { getSubjects, getStories, levelRank, type Subject, type Story } from "../../lib/content"; @@ -59,6 +60,7 @@ if (!subject.available) {
    +
    diff --git a/site-astro/src/scripts/learner.js b/site-astro/src/scripts/learner.js index d80091f..8228f75 100644 --- a/site-astro/src/scripts/learner.js +++ b/site-astro/src/scripts/learner.js @@ -555,6 +555,11 @@ async function bootstrap() { } setAuthState("in"); + // t15 hook (the ONLY tutor-related line here): publish the /api/me payload + // for sibling scripts — src/scripts/tutor.js gates its approved-only + // surface on this instead of fetching /api/me a second time. + window.__learnMe = me; + document.dispatchEvent(new CustomEvent("learn:me", { detail: me })); const name = (me.learner && me.learner.display_name) || "your account"; document.querySelectorAll("[data-auth-name]").forEach((el) => { el.textContent = name; diff --git a/site-astro/src/scripts/tutor-core.js b/site-astro/src/scripts/tutor-core.js new file mode 100644 index 0000000..f245bd4 --- /dev/null +++ b/site-astro/src/scripts/tutor-core.js @@ -0,0 +1,332 @@ +// tutor-core.js — the PURE half of the tutor surface (t15, spec c24/h11). +// +// Prompt/payload builders, Converse response parsers, the §3.6.1 cloze +// validator, and the record builder. No fetch, no DOM, no globals — this +// module is importable by plain node (scripts/check-tutor-logic.mjs unit- +// tests it, wired into `npm run check`) and by src/scripts/tutor.js alike. +// Keeping every risky transformation here (prompt text, defensive JSON +// parsing, contract validation) is what makes the DOM half thin and the +// logic actually testable without a browser or live inference. +// +// Payloads are Bedrock **Converse**-shaped — the live-verified target +// (workers/learn-api/README.md "For t15": Bedrock's OpenAI-compat surface +// does NOT serve Nova Pro; the native Converse API does): +// request: { system:[{text}], messages:[{role, content:[{text}]}], +// inferenceConfig:{maxTokens, temperature} } +// response: { output:{message:{content:[{text}]}}, stopReason, usage } +// The Worker broker forwards these verbatim (plus its `learner` stamp), so +// what this module builds is exactly what Nova Pro receives. + +export const GRADE_RESULTS = ["pass", "partial", "fail"]; + +// Ledger mastery levels, weakest first (mirrors the worker's MASTERY_LEVELS +// minus "mastered", which never appears in progress.weak). +const WEAKNESS_ORDER = ["unknown", "introduced", "practiced"]; + +// --- system prompts --------------------------------------------------------- +// Each prompt pins two things Nova Pro must not improvise: the RUBRIC (what +// counts as pass — strict) and the RESPONSE FORMAT (one strict JSON object, +// no fences, no prose), because the parsers below only accept that shape. + +export const GRADE_SYSTEM_PROMPT = + "You are the learn tutor grading ONE exercise answer from a learner. " + + "Grade STRICTLY against the exercise's expected answer or rubric: " + + '"pass" ONLY when the answer is fully correct — every required element present, ' + + "no meaning-changing error (a minor typo that cannot be read as a different word " + + "is acceptable; a wrong word, wrong form, missing half, or reversed meaning is not). " + + '"partial" when the answer shows real understanding but has at least one substantive ' + + 'error or omission. "fail" when the answer is wrong, empty, off-topic, or answers a ' + + "different question. When in doubt between two grades, give the LOWER one. " + + "Respond with ONE strict JSON object and NOTHING else — no markdown fences, no prose " + + 'before or after: {"result":"pass|partial|fail","explanation":"at most two short ' + + 'sentences naming what was right and what was wrong"}'; + +export const NEXT_STEP_SYSTEM_PROMPT = + "You are the learn tutor recommending the single best next step for a learner, from " + + "their real progress ledger. You are given the subject, their touched/mastered counts, " + + "their weak items (weakest first — lowest mastery), and the subject's own generic hint. " + + "Pick ONE concrete next action. Prefer shoring up the weakest item over new material; " + + "name the item id you targeted and say in one sentence why, then phrase the action as " + + "something doable right now (which story to reread, what to practice). " + + "Respond with ONE strict JSON object and NOTHING else — no markdown fences: " + + '{"item_id":"the weak item id you target, or null when recommending new material",' + + '"recommendation":"one to three short sentences: what to do next and why"}'; + +export const CLOZE_SYSTEM_PROMPT = + "You are the learn tutor writing ONE personalized pick-the-right-word cloze story for a " + + "learner. You are given the subject and the learner's weak items, weakest first. Target " + + 'the FIRST weak item: the story must practice it, and your "item_id" field must be that ' + + "item's id copied verbatim — it is a stable join key into the learner's ledger; never " + + "invent, translate, or reword it. Requirements: " + + '"text" is a short story (two to four sentences) practicing the weak items, with two to ' + + "four blanks written as {{blank_id}} placeholders (each blank_id lowercase letters/" + + 'digits/hyphens, unique). "blanks" has EXACTLY one entry per placeholder, in order: ' + + '{"id":"blank_id","options":["three plausible options"],"answer":"the correct option"} — ' + + 'every "answer" MUST be copied from its own "options", and distractors must be plausible ' + + 'but clearly wrong in context. "prompt" is one instruction line for the learner and ' + + '"answer" is every blank answer joined with ", " (the legacy fallback fields — always ' + + 'present). "title" is a two-to-six-word title. ' + + "Respond with ONE strict JSON object and NOTHING else — no markdown fences: " + + '{"title":"...","item_id":"...","prompt":"...","answer":"...","text":"...",' + + '"blanks":[{"id":"...","options":["..."],"answer":"..."}]}'; + +// --- Converse payload builders ---------------------------------------------- + +/** The one Converse request constructor every flow goes through. */ +export function converseRequest(systemText, userText, { maxTokens, temperature }) { + return { + system: [{ text: systemText }], + messages: [{ role: "user", content: [{ text: userText }] }], + inferenceConfig: { maxTokens, temperature }, + }; +} + +/** GRADE: the learner answered `exercise` free-form with `answer`. */ +export function buildGradePayload({ subject, exercise, answer }) { + const lines = [ + `Subject: ${subject}`, + `Exercise type: ${exercise.type || "open"}`, + exercise.story ? `From the story: ${exercise.story}` : null, + `Exercise prompt: ${exercise.prompt}`, + exercise.choices && exercise.choices.length > 0 + ? `Choices offered: ${exercise.choices.join(" | ")}` + : null, + exercise.answer ? `Expected answer: ${exercise.answer}` : null, + exercise.rubric ? `Rubric: ${exercise.rubric}` : null, + `Learner's answer: ${answer}`, + ].filter(Boolean); + // temperature 0: grading must be reproducible, never creative. + return converseRequest(GRADE_SYSTEM_PROMPT, lines.join("\n"), { + maxTokens: 300, + temperature: 0, + }); +} + +/** Weak items from a progress payload, weakest mastery first, capped. */ +export function weakestItems(progress, limit = 4) { + const weak = Array.isArray(progress && progress.weak) ? progress.weak : []; + return weak + .slice() + .sort( + (a, b) => WEAKNESS_ORDER.indexOf(String(a.mastery)) - WEAKNESS_ORDER.indexOf(String(b.mastery)), + ) + .slice(0, limit); +} + +function weakLines(weakItems) { + return weakItems.map((w) => `- ${w.item_id} (mastery: ${w.mastery})`).join("\n"); +} + +/** NEXT-STEP: adaptive recommendation from the learner's own progress. */ +export function buildNextStepPayload({ subject, progress }) { + const weak = weakestItems(progress); + const lines = [ + `Subject: ${subject}`, + `Items touched: ${progress.items_touched || 0}`, + `Items mastered: ${progress.items_mastered || 0}`, + weak.length > 0 ? `Weak items, weakest first:\n${weakLines(weak)}` : "Weak items: none", + progress.next && progress.next.text ? `Subject's generic hint: ${progress.next.text}` : null, + ].filter(Boolean); + return converseRequest(NEXT_STEP_SYSTEM_PROMPT, lines.join("\n"), { + maxTokens: 300, + temperature: 0.2, + }); +} + +/** CLOZE-GEN: a new story personalized to the learner's weak items. */ +export function buildClozePayload({ subject, weakItems }) { + const lines = [ + `Subject: ${subject}`, + `Weak items, weakest first (use the FIRST as item_id, verbatim):\n${weakLines(weakItems)}`, + ]; + // Higher temperature: story writing should vary run to run; the validator + // below (not the prompt alone) is what enforces the contract shape. + return converseRequest(CLOZE_SYSTEM_PROMPT, lines.join("\n"), { + maxTokens: 900, + temperature: 0.7, + }); +} + +// --- Converse response parsers (defensive, never throwing) ------------------- + +/** Concatenated text blocks from a Converse response; "" when malformed. */ +export function converseText(data) { + const content = + data && data.output && data.output.message && Array.isArray(data.output.message.content) + ? data.output.message.content + : []; + return content.map((c) => (c && typeof c.text === "string" ? c.text : "")).join(""); +} + +/** First JSON object embedded in `text` (fences/prose tolerated); null when none parses. */ +export function extractJson(text) { + if (typeof text !== "string") return null; + const unfenced = text.replace(/```(?:json)?/gi, ""); + const start = unfenced.indexOf("{"); + const end = unfenced.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + const parsed = JSON.parse(unfenced.slice(start, end + 1)); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** {result, explanation} or null — result must be a real grade. */ +export function parseGradeResponse(data) { + const obj = extractJson(converseText(data)); + if (!obj) return null; + const result = typeof obj.result === "string" ? obj.result.trim().toLowerCase() : ""; + if (!GRADE_RESULTS.includes(result)) return null; + return { + result, + explanation: typeof obj.explanation === "string" ? obj.explanation.trim() : "", + }; +} + +/** {item_id, recommendation} or null — recommendation is required prose. */ +export function parseNextStepResponse(data) { + const obj = extractJson(converseText(data)); + if (!obj || typeof obj.recommendation !== "string" || !obj.recommendation.trim()) return null; + const itemId = typeof obj.item_id === "string" && obj.item_id.trim() ? obj.item_id.trim() : null; + return { item_id: itemId, recommendation: obj.recommendation.trim() }; +} + +/** The raw generated story object (unvalidated) or null. */ +export function parseClozeResponse(data) { + return extractJson(converseText(data)); +} + +// --- §3.6.1 cloze validation (client-side, BEFORE showing or recording) ------ + +const BLANK_ID_RE = /^[a-z0-9][a-z0-9._-]*$/; +const PLACEHOLDER_RE = /\{\{([^{}]+)\}\}/g; + +/** Ordered placeholder ids found in a cloze `text`. */ +export function placeholderIds(text) { + return [...String(text).matchAll(PLACEHOLDER_RE)].map((m) => m[1]); +} + +/** + * Validate a GENERATED cloze story against the pick-the-right-word contract + * (docs/specs/subject-plugin-contract.md §3.6.1) plus t15's join-key rule. + * Returns a list of human-readable errors; empty means valid. Deliberately a + * superset of the schema-checkable rules AND the `learn subject doctor` + * semantic rules, because generated content gets no doctor pass: + * - "text" non-empty, "blanks" non-empty; each blank {id, options, answer}; + * - blank ids match the pattern, are unique, and pair 1:1 with {{id}} + * placeholders (no orphan placeholder, no blank without one); + * - >= 2 non-empty options per blank, and answer is one of its own options; + * - the legacy "prompt"/"answer" fallback fields are present (the + * driver-facing instruction §3.6.1 keeps on well-authored items); + * - item_id is copied verbatim from an EXISTING weak item (`weakItemIds`) — + * the stable ledger join key; a Nova-invented id would corrupt mastery. + */ +export function validateClozeStory(story, weakItemIds) { + const errors = []; + if (!story || typeof story !== "object" || Array.isArray(story)) { + return ["story is not an object"]; + } + if (typeof story.prompt !== "string" || !story.prompt.trim()) { + errors.push("missing prompt (the legacy fallback instruction)"); + } + if (typeof story.answer !== "string" || !story.answer.trim()) { + errors.push("missing answer (the legacy fallback field)"); + } + if (typeof story.item_id !== "string" || !story.item_id.trim()) { + errors.push("missing item_id"); + } else if (!Array.isArray(weakItemIds) || !weakItemIds.includes(story.item_id)) { + errors.push(`item_id "${story.item_id}" is not one of the learner's weak items`); + } + if (typeof story.text !== "string" || !story.text.trim()) { + errors.push("missing text"); + } + if (!Array.isArray(story.blanks) || story.blanks.length === 0) { + errors.push("missing blanks"); + } + if (errors.length > 0) return errors; + + const seen = new Set(); + for (const blank of story.blanks) { + if (!blank || typeof blank !== "object") { + errors.push("blank is not an object"); + continue; + } + const id = String(blank.id || ""); + if (!BLANK_ID_RE.test(id)) errors.push(`blank id "${id}" is malformed`); + if (seen.has(id)) errors.push(`duplicate blank id "${id}"`); + seen.add(id); + const options = Array.isArray(blank.options) ? blank.options : []; + const clean = options.filter((o) => typeof o === "string" && o.trim()); + if (clean.length !== options.length || options.length < 2) { + errors.push(`blank "${id}" needs >= 2 non-empty options`); + } + if (typeof blank.answer !== "string" || !blank.answer.trim()) { + errors.push(`blank "${id}" has no answer`); + } else if (!options.includes(blank.answer)) { + errors.push(`blank "${id}" answer is not one of its options`); + } + } + + const inText = placeholderIds(story.text); + const inTextSet = new Set(inText); + if (inText.length !== inTextSet.size) errors.push("duplicate {{placeholder}} in text"); + for (const id of inTextSet) { + if (!seen.has(id)) errors.push(`orphan placeholder {{${id}}} has no blanks entry`); + } + for (const id of seen) { + if (!inTextSet.has(id)) errors.push(`blank "${id}" has no {{${id}}} placeholder in text`); + } + return errors; +} + +// --- playing + recording ------------------------------------------------------ + +/** Split a cloze `text` into ordered text/blank segments for rendering — + * the same split the story reader does at build time, here at runtime. */ +export function splitClozeText(text, blanks) { + const byId = new Map(blanks.map((b) => [b.id, b])); + const segments = []; + let lastIndex = 0; + for (const match of String(text).matchAll(PLACEHOLDER_RE)) { + if (match.index > lastIndex) { + segments.push({ kind: "text", value: text.slice(lastIndex, match.index) }); + } + const blank = byId.get(match[1]); + if (blank) segments.push({ kind: "blank", blank }); + else segments.push({ kind: "text", value: match[0] }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < String(text).length) { + segments.push({ kind: "text", value: text.slice(lastIndex) }); + } + return segments; +} + +/** pass/partial/fail from a blanks tally (all right / some / none). */ +export function clozeResult(correct, total) { + if (!Number.isInteger(correct) || !Number.isInteger(total) || total < 1) return "fail"; + if (correct >= total) return "pass"; + return correct > 0 ? "partial" : "fail"; +} + +/** + * The POST /api/record body for a played generated story — contract-valid + * with EXISTING fields only (item_id/activity/result/at + the pre-existing + * correct/total tallies §3.6.1 points at; never the derived score/grade/ + * points the worker rejects, and no new field inventions). + */ +export function buildClozeRecord({ subject, story, correct, total, at }) { + return { + subject, + recorded: { + item_id: story.item_id, + activity: "practice", + result: clozeResult(correct, total), + at: at || new Date().toISOString(), + correct, + total, + }, + }; +} diff --git a/site-astro/src/scripts/tutor.js b/site-astro/src/scripts/tutor.js new file mode 100644 index 0000000..ac618a8 --- /dev/null +++ b/site-astro/src/scripts/tutor.js @@ -0,0 +1,317 @@ +// tutor.js — the tutor surface for APPROVED learners (t15, spec c24/h11). +// +// A dedicated script, loaded ONLY by TutorPanel.astro's own import (never by +// Layout.astro — learner.js stays the one global script and gains exactly a +// two-line hook: it publishes the /api/me payload it already fetched as +// `window.__learnMe` + a "learn:me" event, so this file needs no /api/me +// call of its own). All prompt construction, response parsing, and contract +// validation live in the PURE sibling module ./tutor-core.js, unit-tested by +// scripts/check-tutor-logic.mjs; this file is only DOM wiring. +// +// THE GATE, honored end-to-end: hydrateTutorPanel() checks +// `me.learner.approved` FIRST and returns before wiring anything when the +// learner is not approved — the "tutoring is admin-approved" note (visible +// by default in the signed-in markup) stays, the controls stay hidden, and +// this script makes ZERO fetch() calls. The Worker enforces the same gate +// server-side regardless (403 approval_required, zero inference) — this is +// UI honesty, not the security boundary. +// +// Fetch surface (audited by scripts/check-static-auth.mjs against an exact, +// anchored whitelist): POST /api/tutor (the broker — the only route that +// spends inference), GET /api/progress/:subject (weak items for next-step + +// cloze-gen), POST /api/record (the played story's tally, contract-valid, +// existing fields only). Nothing else, ever. +import { API_BASE } from "../lib/api.js"; +import { + buildClozePayload, + buildClozeRecord, + buildGradePayload, + buildNextStepPayload, + parseClozeResponse, + parseGradeResponse, + parseNextStepResponse, + splitClozeText, + validateClozeStory, + weakestItems, +} from "./tutor-core.js"; + +const PARSE_FAILED = "The tutor's reply didn't parse — try again."; +const CALL_FAILED = "Couldn't reach the tutor — try again."; + +async function callTutor(payload) { + const res = await fetch(`${API_BASE}/tutor`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`tutor call failed: ${res.status}`); + return res.json(); +} + +async function fetchProgress(subject) { + const res = await fetch(`${API_BASE}/progress/${encodeURIComponent(subject)}`, { + credentials: "include", + }); + if (!res.ok) throw new Error(`progress fetch failed: ${res.status}`); + return res.json(); +} + +function setStatus(el, text) { + if (el) el.textContent = text; +} + +// --- GRADE: free-form answer -> pass/partial/fail + explanation ------------- + +function wireGrade(panel, subject) { + const block = panel.querySelector("[data-tutor-grade]"); + if (!block) return; + const dataEl = panel.querySelector("[data-tutor-exercises]"); + let exercises; + try { + exercises = JSON.parse(dataEl ? dataEl.textContent || "[]" : "[]"); + } catch { + exercises = []; + } + const select = block.querySelector("[data-grade-exercise]"); + const promptEl = block.querySelector("[data-grade-prompt]"); + const answerEl = block.querySelector("[data-grade-answer]"); + const submit = block.querySelector("[data-grade-submit]"); + const result = block.querySelector("[data-grade-result]"); + if (!select || !answerEl || !submit || exercises.length === 0) { + block.hidden = true; + return; + } + + exercises.forEach((ex, i) => { + const opt = document.createElement("option"); + opt.value = String(i); + opt.textContent = ex.story ? `${ex.story} — ${ex.prompt}` : ex.prompt; + select.appendChild(opt); + }); + const paintPrompt = () => { + const ex = exercises[Number(select.value)] || exercises[0]; + if (promptEl) promptEl.textContent = ex.prompt; + }; + paintPrompt(); + select.addEventListener("change", paintPrompt); + + submit.addEventListener("click", async () => { + const exercise = exercises[Number(select.value)] || exercises[0]; + const answer = answerEl.value.trim(); + if (!answer) { + setStatus(result, "Write an answer first."); + return; + } + submit.disabled = true; + setStatus(result, "Grading…"); + try { + const data = await callTutor(buildGradePayload({ subject, exercise, answer })); + const grade = parseGradeResponse(data); + setStatus(result, grade ? `${grade.result.toUpperCase()} — ${grade.explanation}` : PARSE_FAILED); + } catch { + setStatus(result, CALL_FAILED); + } finally { + submit.disabled = false; + } + }); +} + +// --- NEXT-STEP: adaptive recommendation from the learner's progress --------- + +function wireNextStep(panel, subject) { + const block = panel.querySelector("[data-tutor-next]"); + if (!block) return; + const submit = block.querySelector("[data-next-submit]"); + const result = block.querySelector("[data-next-result]"); + if (!submit) return; + + submit.addEventListener("click", async () => { + submit.disabled = true; + setStatus(result, "Asking the tutor…"); + try { + const progress = await fetchProgress(subject); + if (!progress.items_touched) { + setStatus(result, "Nothing recorded yet — read a story and record a result first."); + return; + } + const data = await callTutor(buildNextStepPayload({ subject, progress })); + const step = parseNextStepResponse(data); + if (!step) { + setStatus(result, PARSE_FAILED); + return; + } + setStatus( + result, + step.item_id ? `${step.recommendation} (targets: ${step.item_id})` : step.recommendation, + ); + } catch { + setStatus(result, CALL_FAILED); + } finally { + submit.disabled = false; + } + }); +} + +// --- CLOZE-GEN: a personalized story, played inline, recorded to the ledger -- + +/** Render a validated story with the same pick-the-right-word interaction the + * story reader uses (.cloze-blank / .cloze-option / lock-on-first-pick), and + * record the tally through the existing POST /api/record once every blank is + * answered — correct/total, existing contract fields only. */ +function renderClozeStory(container, status, subject, story) { + container.textContent = ""; + container.hidden = false; + + if (story.title) { + const title = document.createElement("h4"); + title.className = "tutor-story-title"; + title.textContent = story.title; + container.appendChild(title); + } + const prompt = document.createElement("p"); + prompt.className = "muted tutor-story-prompt"; + prompt.textContent = story.prompt; + container.appendChild(prompt); + + const passage = document.createElement("p"); + passage.className = "cloze-passage"; + const total = story.blanks.length; + let answered = 0; + let correct = 0; + + const finish = async () => { + const body = buildClozeRecord({ subject, story, correct, total }); + setStatus(status, "Recording…"); + try { + const res = await fetch(`${API_BASE}/record`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`record failed: ${res.status}`); + setStatus(status, `${correct}/${total} right — recorded to your ledger.`); + } catch { + setStatus(status, `${correct}/${total} right — couldn't record it, though.`); + } + }; + + for (const segment of splitClozeText(story.text, story.blanks)) { + if (segment.kind === "text") { + passage.appendChild(document.createTextNode(segment.value)); + continue; + } + const blank = segment.blank; + const wrap = document.createElement("span"); + wrap.className = "cloze-blank"; + const group = document.createElement("span"); + group.className = "cloze-options"; + group.setAttribute("role", "group"); + group.setAttribute("aria-label", "Pick the right word"); + const buttons = blank.options.map((option) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "cloze-option"; + btn.textContent = option; + btn.addEventListener("click", () => { + if (buttons.some((b) => b.disabled)) return; // locked to the first pick + buttons.forEach((b) => { + b.disabled = true; + }); + answered += 1; + if (option === blank.answer) { + correct += 1; + btn.classList.add("is-correct"); + } else { + btn.classList.add("is-incorrect"); + const right = buttons.find((b) => b.textContent === blank.answer); + if (right) right.classList.add("is-correct"); + } + if (answered === total) finish(); + }); + group.appendChild(btn); + return btn; + }); + wrap.appendChild(group); + passage.appendChild(wrap); + } + container.appendChild(passage); +} + +function wireClozeGen(panel, subject) { + const block = panel.querySelector("[data-tutor-cloze]"); + if (!block) return; + const submit = block.querySelector("[data-cloze-submit]"); + const status = block.querySelector("[data-cloze-status]"); + const container = block.querySelector("[data-cloze-story]"); + if (!submit || !container) return; + + submit.addEventListener("click", async () => { + submit.disabled = true; + setStatus(status, "Writing your story…"); + try { + const progress = await fetchProgress(subject); + const weak = weakestItems(progress); + if (weak.length === 0) { + setStatus( + status, + progress.items_touched + ? "Every touched item is mastered — nothing weak to practice. Impressive." + : "Nothing recorded yet — read a story and record a result first.", + ); + return; + } + const data = await callTutor(buildClozePayload({ subject, weakItems: weak })); + const story = parseClozeResponse(data); + const errors = validateClozeStory( + story, + weak.map((w) => w.item_id), + ); + if (errors.length > 0) { + // Invalid generations are DROPPED, never shown or recorded — the + // §3.6.1 validator is the gate, not the prompt's good intentions. + setStatus(status, "The generated story didn't validate — try again."); + return; + } + setStatus(status, "Pick the right word for each blank."); + renderClozeStory(container, status, subject, story); + } catch { + setStatus(status, CALL_FAILED); + } finally { + submit.disabled = false; + } + }); +} + +// --- the gate + bootstrap ----------------------------------------------------- + +function hydrateTutorPanel(me) { + const panel = document.querySelector("[data-tutor-panel]"); + if (!panel) return; + const approved = !!(me && me.learner && me.learner.approved); + if (!approved) { + // Not approved: the admin-approved note (visible by default) stands, + // the controls stay hidden, and NOTHING below runs — zero fetches. + return; + } + const note = panel.querySelector("[data-tutor-gate-note]"); + const surface = panel.querySelector("[data-tutor-surface]"); + if (note) note.hidden = true; + if (surface) surface.hidden = false; + const subject = panel.getAttribute("data-tutor-subject"); + if (!subject) return; + wireGrade(panel, subject); + wireNextStep(panel, subject); + wireClozeGen(panel, subject); +} + +// learner.js publishes the /api/me payload after its own auth check; take it +// from wherever we are in that race (already published, or still pending). +if (window.__learnMe) { + hydrateTutorPanel(window.__learnMe); +} else { + document.addEventListener("learn:me", (event) => hydrateTutorPanel(event.detail), { + once: true, + }); +} diff --git a/tests/test_tutor_page.py b/tests/test_tutor_page.py new file mode 100644 index 0000000..fc09d46 --- /dev/null +++ b/tests/test_tutor_page.py @@ -0,0 +1,332 @@ +"""The tutor surface (t15, spec c24/h11 — Nova Pro through the existing broker). + +Locks the approved-tier tutor panel's key claims and wiring against drift, +the same way ``tests/test_consent_page.py`` does for the consent page: + +* the surface renders ONLY for approved learners (``me.learner.approved``): + the tutor controls ship hidden and are revealed by ``tutor.js`` after the + approved check; a signed-in but non-approved learner sees a "tutoring is + admin-approved" note instead; a signed-out visitor sees nothing (the whole + panel is ``signedin-only``, hidden by the global CSS default); +* ``src/scripts/tutor.js`` is a dedicated script (NOT merged into + ``learner.js``, NOT loaded globally by ``Layout.astro``) whose fetch + surface is precisely three routes: ``POST /api/tutor``, + ``GET /api/progress/:subject``, and ``POST /api/record`` — nothing wider; +* ``src/scripts/tutor-core.js`` is the PURE half (prompt builders, Converse + parsers, the §3.6.1 cloze validator): no fetch, no DOM — importable by + ``site-astro/scripts/check-tutor-logic.mjs``, the node-side unit suite + that actually executes it (this file only locks source shape; execution + coverage lives there, wired into ``npm run check``); +* payloads are Bedrock **Converse**-shaped (the live-verified target — + Bedrock's OpenAI-compat surface does NOT serve Nova Pro), the system + prompts pin the grading rubric and strict-JSON response format, and the + generated cloze story is validated client-side against the contract + (§3.6.1) INCLUDING the stable-``item_id`` join-key rule before it is shown + or recorded; +* ``scripts/check-static-auth.mjs`` was extended precisely: an anchored + tutor whitelist, not a blanket allowance, replacing the pre-t15 "never + references /tutor" rule with "ONLY tutor.js's whitelisted fetch may"; +* the wiring itself is config-only (h11): ``wrangler.toml`` records the + exact Converse URL commented out (t17 flips it live), and the worker + README documents the NO-GO/GO probe results. + +Deliberately pure file reads (no npm/astro/node subprocess) — see +``test_consent_page.py``'s module docstring for why. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TUTOR_PANEL = ROOT / "site-astro" / "src" / "components" / "TutorPanel.astro" +SUBJECT_PAGE = ROOT / "site-astro" / "src" / "pages" / "[subject]" / "index.astro" +TUTOR_JS = ROOT / "site-astro" / "src" / "scripts" / "tutor.js" +TUTOR_CORE = ROOT / "site-astro" / "src" / "scripts" / "tutor-core.js" +LEARNER_JS = ROOT / "site-astro" / "src" / "scripts" / "learner.js" +LAYOUT = ROOT / "site-astro" / "src" / "layouts" / "Layout.astro" +CHECK_STATIC_AUTH = ROOT / "site-astro" / "scripts" / "check-static-auth.mjs" +CHECK_TUTOR_LOGIC = ROOT / "site-astro" / "scripts" / "check-tutor-logic.mjs" +SITE_PACKAGE_JSON = ROOT / "site-astro" / "package.json" +WRANGLER_TOML = ROOT / "workers" / "learn-api" / "wrangler.toml" +WORKER_README = ROOT / "workers" / "learn-api" / "README.md" + +CONVERSE_URL = ( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-pro-v1:0/converse" +) + + +def _read(path: Path) -> str: + assert path.is_file(), f"expected {path} to exist" + return path.read_text(encoding="utf-8") + + +def _strip_js_comments(text: str) -> str: + """Drop ``//``-prefixed comment lines (same rationale as + test_consent_page.py: header comments document the routes a script must + NOT call, which would trip a naive substring check).""" + return "\n".join(line for line in text.splitlines() if not line.strip().startswith("//")) + + +# --- the surface exists, in the right places ------------------------------- + + +def test_tutor_panel_component_exists_and_subject_pages_render_it() -> None: + assert TUTOR_PANEL.is_file() + page = _read(SUBJECT_PAGE) + assert re.search(r"import\s+TutorPanel\s+from", page) + assert " None: + # tutor.js is a dedicated script pulled in by the panel component only — + # Layout.astro (which loads learner.js on every page) must not import it. + assert TUTOR_JS.is_file() + assert TUTOR_CORE.is_file() + assert "tutor" not in _read(LAYOUT) + panel = _read(TUTOR_PANEL) + assert re.search(r'import\s*["\']\.\./scripts/tutor\.js["\']', panel) + + +def test_learner_js_hook_is_minimal() -> None: + # The ONE hook learner.js gains for t15: publish the already-fetched + # /api/me payload for sibling scripts (tutor.js), so tutor.js needs no + # /api/me call of its own. No tutor logic lives in learner.js. + text = _read(LEARNER_JS) + assert "__learnMe" in text + assert "learn:me" in text + code_only = _strip_js_comments(text) + assert "/tutor" not in code_only + assert "wireGrade" not in code_only and "hydrateTutorPanel" not in code_only + + +# --- the gate: approved-only, honest note for everyone else ---------------- + + +def test_panel_is_signedin_only_so_signed_out_sees_nothing() -> None: + text = _read(TUTOR_PANEL) + root = re.search(r"]*data-tutor-panel[^>]*>", text) + assert root, "expected a data-tutor-panel root element" + assert "signedin-only" in root.group(0) + + +def test_non_approved_learners_see_the_admin_approved_note() -> None: + text = _read(TUTOR_PANEL) + note = re.search(r"]*data-tutor-gate-note[^>]*>(.*?)

    ", text, re.S) + assert note, "expected a data-tutor-gate-note block" + opening_tag = note.group(0).split(">")[0] + assert "hidden" not in opening_tag, "the gate note must be visible by default" + lowered = re.sub(r"\s+", " ", note.group(1)).lower() + assert "admin" in lowered and "approv" in lowered + + +def test_tutor_controls_ship_hidden_until_the_approved_check() -> None: + text = _read(TUTOR_PANEL) + assert re.search( + r"data-tutor-surface[^>]*\bhidden\b|\bhidden\b[^>]*data-tutor-surface", text + ), "the tutor controls must ship with the hidden attribute in the static markup" + + +def test_tutor_js_reveals_the_surface_only_for_approved_learners() -> None: + text = _read(TUTOR_JS) + assert "me.learner.approved" in text + # The unapproved early-return must come before any wiring/fetching: + # extract hydrateTutorPanel's body and check order textually. + start = text.index("function hydrateTutorPanel") + bail = text.index("if (!approved)", start) + assert "return" in text[bail : bail + 300] + first_wire = min( + idx + for idx in (text.find("wireGrade(", start), text.find("wireNextStep(", start)) + if idx >= 0 + ) + assert bail < first_wire, "the approved check must precede all wiring" + + +# --- tutor.js fetch surface: precise, not blanket --------------------------- + + +def test_tutor_js_imports_the_single_api_base_constant() -> None: + text = _read(TUTOR_JS) + assert re.search(r'import\s*\{\s*API_BASE\s*\}\s*from\s*["\']\.\./lib/api\.js["\']', text) + + +def test_tutor_js_only_calls_tutor_progress_and_record() -> None: + text = _read(TUTOR_JS) + fetch_targets = re.findall(r"fetch\(\s*`([^`]*)`", text) + assert len(fetch_targets) >= 3 + allowed = re.compile(r"^\$\{API_BASE\}(/tutor$|/progress/|/record$)") + offenders = [t for t in fetch_targets if not allowed.match(t)] + assert offenders == [], f"tutor.js calls non-whitelisted endpoint(s): {offenders}" + + +def test_tutor_js_never_references_the_other_authed_routes() -> None: + code_only = _strip_js_comments(_read(TUTOR_JS)) + for route in ("/me", "/admin", "/export", "/delete", "/consent", "/auth"): + assert f"{{API_BASE}}{route}" not in code_only, f"tutor.js must not call {route}" + + +def test_tutor_js_credentials_include_on_every_call() -> None: + text = _read(TUTOR_JS) + for target in ("/tutor`", "/record`"): + idx = text.index(f"fetch(`${{API_BASE}}{target}") + assert 'credentials: "include"' in text[idx : idx + 300] + + +# --- tutor-core.js: pure, Converse-shaped, rubric-pinning ------------------- + + +def test_tutor_core_is_pure_no_fetch_no_dom() -> None: + code_only = _strip_js_comments(_read(TUTOR_CORE)) + assert "fetch(" not in code_only + assert "document." not in code_only + assert "window." not in code_only + + +def test_payloads_are_converse_shaped() -> None: + # The live-verified Bedrock-direct shape (the OpenAI-compat surface does + # NOT serve Nova Pro): system:[{text}], messages:[{role, content:[{text}]}], + # inferenceConfig:{maxTokens, temperature}. + text = _read(TUTOR_CORE) + assert re.search(r"system:\s*\[\{\s*text:", text) + assert re.search(r'role:\s*"user",\s*content:\s*\[\{\s*text:', text) + assert re.search(r"inferenceConfig:\s*\{\s*maxTokens", text) + + +def test_grade_prompt_pins_rubric_and_strict_json() -> None: + text = _read(TUTOR_CORE) + match = re.search(r"GRADE_SYSTEM_PROMPT\s*=(.+?);\s*$", text, re.S | re.M) + assert match, "expected an exported GRADE_SYSTEM_PROMPT" + prompt = match.group(1).lower() + for token in ('"pass"', '"partial"', '"fail"', "json", "strict"): + assert token in prompt, f"grade prompt must pin {token}" + assert "only when" in prompt, "the rubric must be strict about what counts as pass" + + +def test_next_step_prompt_is_adaptive_over_weak_items() -> None: + text = _read(TUTOR_CORE) + match = re.search(r"NEXT_STEP_SYSTEM_PROMPT\s*=(.+?);\s*$", text, re.S | re.M) + assert match, "expected an exported NEXT_STEP_SYSTEM_PROMPT" + prompt = match.group(1).lower() + assert "weak" in prompt + assert "json" in prompt + + +def test_cloze_prompt_pins_the_contract_shape_and_the_join_key() -> None: + text = _read(TUTOR_CORE) + match = re.search(r"CLOZE_SYSTEM_PROMPT\s*=(.+?);\s*$", text, re.S | re.M) + assert match, "expected an exported CLOZE_SYSTEM_PROMPT" + prompt = match.group(1) + assert "{{" in prompt, "must pin the {{blank_id}} marker syntax" + for token in ("blanks", "options", "answer", "item_id"): + assert token in prompt + assert "verbatim" in prompt.lower(), "item_id must be pinned as a verbatim join key" + + +def test_cloze_validator_enforces_the_contract_and_the_join_key() -> None: + # §3.6.1 client-side validation, plus the t15 rule that the generated + # story's item_id reuses an EXISTING weak item's id (stable join key). + text = _read(TUTOR_CORE) + assert "validateClozeStory" in text + assert re.search(r"options\.length\s*<\s*2", text), "must require >= 2 options" + assert re.search(r"options\.(includes|indexOf)", text), "answer must be one of options" + assert re.search(r"\\\{\\\{|\{\\\{", text), "must parse {{blank_id}} placeholders" + assert "weakItemIds" in text, "item_id must be checked against the weak-item join keys" + # prompt/answer fallback fields (§3.6.1: kept present as the + # driver-facing instruction on a well-authored pick-the-right-word item). + assert re.search(r'"prompt"|story\.prompt', text) + assert re.search(r'"answer"|story\.answer', text) + + +def test_cloze_record_is_contract_valid_with_existing_fields_only() -> None: + # The played story records through the EXISTING POST /api/record shape: + # correct/total tallies, activity "practice" — and never the derived + # score/grade/points fields the worker rejects. + text = _read(TUTOR_CORE) + assert "buildClozeRecord" in text + assert re.search(r'activity:\s*"practice"', text) + assert "correct" in text and "total" in text + code_only = _strip_js_comments(text) + for forbidden in ("score:", "grade:", "points:"): + assert forbidden not in code_only, f"recorded must not carry {forbidden}" + + +# --- check-static-auth: extended precisely, not loosened -------------------- + + +def test_check_static_auth_has_an_anchored_tutor_whitelist() -> None: + text = _read(CHECK_STATIC_AUTH) + assert "TUTOR_ALLOWED_SUFFIX_RE" in text + assert re.search(r"\\/tutor\$", text), "the /tutor suffix must be anchored ($), not a prefix" + + +def test_check_static_auth_built_bundle_union_includes_the_tutor_whitelist() -> None: + text = _read(CHECK_STATIC_AUTH) + union = re.search( + r"ALLOWED_SUFFIX_RE\.test\(suffix\)\s*\|\|\s*CONSENT_ALLOWED_SUFFIX_RE\.test\(suffix\)" + r"\s*\|\|\s*TUTOR_ALLOWED_SUFFIX_RE\.test\(suffix\)", + text, + ) + assert union, "the built-bundle whitelist must be the union of all three per-script REs" + + +def test_check_static_auth_still_polices_stray_tutor_references() -> None: + # The pre-t15 blanket rule ("referenced nowhere") is gone by design, but + # its replacement must still exist and still FAIL on a /tutor reference + # outside tutor.js's whitelisted fetch calls. + text = _read(CHECK_STATIC_AUTH) + assert re.search(r"referenced ONLY", text), "expected the precise stray-/tutor check" + assert "learner.js" in text and "consent.js" in text # the older checks stay + + +# --- execution coverage: the node-side unit suite is wired into the gate ---- + + +def test_tutor_logic_unit_suite_exists_and_runs_in_npm_check() -> None: + assert CHECK_TUTOR_LOGIC.is_file() + text = _read(CHECK_TUTOR_LOGIC) + # It executes the pure module — not a source-grep like this file. + assert "tutor-core.js" in text + for fn in ("parseGradeResponse", "validateClozeStory", "buildClozeRecord"): + assert fn in text + pkg = _read(SITE_PACKAGE_JSON) + assert "check:tutor-logic" in pkg + run_check = re.search(r'"check":\s*"([^"]+)"', pkg) + assert run_check and "check:tutor-logic" in run_check.group(1) + + +# --- the wiring is config-only (h11): recorded, not enabled ----------------- + + +def test_wrangler_records_the_converse_url_commented_out() -> None: + text = _read(WRANGLER_TOML) + assert CONVERSE_URL in text + for line in text.splitlines(): + if CONVERSE_URL in line: + assert line.lstrip().startswith("#"), "INFERENCE_URL must NOT be set live by t15" + # No uncommented INFERENCE_URL assignment anywhere. + assert not re.search(r"^\s*INFERENCE_URL\s*=", text, re.M) + + +def test_wrangler_comment_block_records_model_region_secret_and_cost() -> None: + text = _read(WRANGLER_TOML) + assert "us.amazon.nova-pro-v1:0" in text + assert "us-east-1" in text + assert "AWS_BEDROCK_API_KEY_SECRET" in text + assert "wrangler secret put INFERENCE_TOKEN" in text + lowered = text.lower() + assert "per-token" in lowered and "idle" in lowered # cost-when-busy note + + +def test_worker_readme_records_the_probe_results_and_shipped_wiring() -> None: + text = _read(WORKER_README) + assert CONVERSE_URL in text + lowered = text.lower() + assert "model_not_found" in lowered, "the OpenAI-compat NO-GO must be recorded" + assert "converse" in lowered + assert "latencyms" in lowered, "the sanitized live re-verification result must be quoted" + assert "us.amazon.nova-pro-v1:0" in text + # The stale pre-t15 instruction (cloudai/ec2bedrock served endpoint as THE + # target) must no longer be the documented wiring for INFERENCE_URL. + assert "cloudai-or-ec2bedrock-served-endpoint" not in text diff --git a/workers/learn-api/README.md b/workers/learn-api/README.md index 638448b..b5ff4cc 100644 --- a/workers/learn-api/README.md +++ b/workers/learn-api/README.md @@ -165,7 +165,7 @@ No third-party runtime deps: the Worker uses only Web-standard APIs (`fetch`, | POST | `/api/record` | consented | Validate the `recorded` shape and append it to the ledger. Pending OR stale-version session: `403 consent_required`. | | GET | `/api/export` | consented | Self-serve data export: the learner's identity row, every recorded result across **every subject**, and their full consent history, as one JSON document (t7). Pending OR stale-version session: `403 consent_required` — same gate as progress/record/tutor. | | POST | `/api/delete` | session or pending | Self-serve whole-learner erasure — consent withdrawal (t7). Requires `{ "confirm": "" }` in the body. Deletes the learners/records/consents rows, revokes the **current** session (KV tombstone), clears the cookie. Deliberately reachable from a stale-consent (and even pending-consent) session — see "Endpoint shapes" below for why. | -| POST | `/api/tutor` | approved | Broker: forward to `INFERENCE_URL` (a served inference endpoint). Pending OR stale-version session: `403 consent_required`; consented but not admin-approved: `403 approval_required` (t9) — zero inference calls either way. | +| POST | `/api/tutor` | approved | Broker: forward to `INFERENCE_URL` (Bedrock Converse in production — see "For t15"). Pending OR stale-version session: `403 consent_required`; consented but not admin-approved: `403 approval_required` (t9) — zero inference calls either way. | | POST | `/api/me/visibility` | consented | Set the caller's OWN `visibility` (`private` \| `public`) in their `state` blob (t8). `400 invalid_visibility` for anything else. Pending OR stale-version session: `403 consent_required` — same gate as progress/record/export. | | GET | `/api/admin/learners` | admin | List every learner + a per-subject record-count summary and their tutoring-tier `approved` state, admin-only (t8, t9). Consented but non-allow-listed: `403 admin_required`. See "Roles + visibility" below. | | POST | `/api/admin/approve` | admin | Grant a learner the tutoring tier (t9): set `state.approved`. Body `{ "github_user_id": "" }`. `409 consent_stale` unless the target's consent covers the CURRENT terms version (decision c20); `404 learner_not_found` for an unknown id. | @@ -305,22 +305,26 @@ wrangler d1 execute learn-ledger --remote --file schema.sql wrangler secret put SESSION_SECRET # random >= 32 bytes (e.g. openssl rand -base64 48) wrangler secret put GITHUB_CLIENT_ID # GitHub-App client id (.env GITHUB_APP_CLIENT_ID; public but not committed) wrangler secret put GITHUB_CLIENT_SECRET # from the OAuth app -wrangler secret put INFERENCE_TOKEN # bearer for the served inference endpoint +wrangler secret put INFERENCE_TOKEN # Bedrock API key (.env AWS_BEDROCK_API_KEY_SECRET) — t15 ``` -Set the tutoring endpoint URL as a var (it is not secret) in `wrangler.toml`: +Set the tutoring endpoint URL as a var (it is not secret) in `wrangler.toml` — +the exact production value ships there commented out (t15; uncomment to +enable, t17's launch gate drives the live flip): ```toml [vars] -INFERENCE_URL = "https:///v1/messages" +INFERENCE_URL = "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-pro-v1:0/converse" PUBLIC_URL = "https://agentculture.org" APP_URL = "https://agentculture.org/learn/" CORS_ORIGIN = "https://agentculture.org" ``` -`INFERENCE_URL` must be a **served** endpoint from `cloudai-cli` or -`ec2bedrock-cli` (an OpenAI-shaped HTTP API). The broker only POSTs JSON to it — -there is no bespoke provider SDK anywhere in this Worker. +`INFERENCE_URL` is **Bedrock-direct** (spec decision: AWS Bedrock is the only +service that serves AWS Nova models) — the native Converse API, not an SDK +integration and not a sibling-hosted model server. The broker only POSTs JSON +to it — there is no bespoke provider SDK anywhere in this Worker. See "For +t15" below for the probe results behind the URL choice. For local dev, mirror the secrets into a git-ignored `.dev.vars`: @@ -605,15 +609,73 @@ POST /api/admin/revoke (requireAdmin) tutor gate itself lives in `handleTutor` and 403s `approval_required` before the `INFERENCE_URL` check — see "The approval-gate invariant" above. -### For t15 (Nova Pro wiring) and t16 (voice tokens) +### For t15 (Nova Pro wiring) — SHIPPED, config only + +**h11 holds literally: the Worker diff for t15 is zero lines of code.** The +four-level gate (auth → consent → approval → `INFERENCE_URL` presence) and +the forward-verbatim broker are entirely inside `handleTutor` (`src/index.js`), +unchanged since t9 — t15 ships as a `wrangler.toml` comment block (the exact +URL, ready to uncomment), an `INFERENCE_TOKEN` secret recipe, and the client +surface in `site-astro/` (below). `test/tutor-converse.test.js` (additive) +proves the unchanged broker carries a native Converse payload verbatim and +relays the Converse response and error statuses untranslated. + +**Probe results (live, 2026-07-11) — how the URL was chosen:** + +- **OpenAI-compat NO-GO.** Bedrock's OpenAI-compatible chat-completions + surface (`bedrock-runtime..amazonaws.com/openai/v1/...`) does + **not** serve Nova Pro: `model_not_found` in every probed region + (us-east-1, us-west-2, eu-west-1, eu-central-1), and `/openai/v1/models` + is not even an operation. This supersedes the spec's and this README's + earlier "OpenAI-compatible endpoint" wording. +- **Converse GO.** The native Converse API returns real Nova Pro + completions with a plain Bedrock API key as the Bearer token: + + ```text + POST https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-pro-v1:0/converse + Authorization: Bearer (.env AWS_BEDROCK_API_KEY_SECRET) + ``` + + Re-verified from this task (2026-07-11, sanitized): `HTTP 200 in 0.95s`, + body `{"metrics":{"latencyMs":491},"output":{"message":{"content": + [{"text":"OK"}],"role":"assistant"}},"stopReason":"end_turn","usage": + {"inputTokens":11,"outputTokens":2,...}}`. + +- **The learner stamp needs no change.** Converse tolerates the broker's + `learner` field both as an extra top-level field and under + `requestMetadata` (live-verified) — `handleTutor`'s existing + spread-then-stamp forward works as-is. + +**Wiring facts** (also recorded next to the commented-out var in +`wrangler.toml`): model `us.amazon.nova-pro-v1:0`, region `us-east-1`, +`INFERENCE_TOKEN` = a Bedrock API key set via +`wrangler secret put INFERENCE_TOKEN` (sourced from the repo-root `.env` +`AWS_BEDROCK_API_KEY_SECRET`). **Cost-when-busy: per-token Nova Pro spend +only — no model host, no provisioned throughput, zero idle cost.** + +**Request/response shapes** the client builds and parses (Converse, not +chat-completions — `site-astro/src/scripts/tutor-core.js` is the one +builder/parser, unit-tested by `site-astro/scripts/check-tutor-logic.mjs`): + +```text +request: { system: [{text}], messages: [{role, content: [{text}]}], + inferenceConfig: {maxTokens, temperature} } +response: { output: {message: {content: [{text}]}}, stopReason, usage } +``` + +**The tutor surface** (site-astro, approved learners only — the gate note +renders for everyone else): `TutorPanel.astro` + `src/scripts/tutor.js` +drive three flows through this broker — exercise **grading** +(pass/partial/fail + explanation, rubric pinned in the system prompt), +adaptive **next-step** (from `GET /api/progress/:subject`, targeting the +weakest items), and personalized **cloze-story generation** +(contract-§3.6.1-validated client-side, `item_id` reused verbatim from a +weak item as the stable join key; the played result records through the +existing `POST /api/record` with `correct`/`total` tallies — no new record +fields, no new routes). + +### For t16 (voice tokens) -- **t15 changes config only.** The four-level gate (auth → consent → - approval → `INFERENCE_URL` presence) is entirely inside `handleTutor` - (`src/index.js`) and its helpers — pointing `INFERENCE_URL` at Bedrock's - OpenAI-compatible endpoint and setting `INFERENCE_TOKEN` to a Bedrock API - key changes no gate code, and the broker forwards the JSON body unchanged - (plus the `learner` stamp), so no provider SDK is needed (h11). Record - the region + model id + cost-when-busy note here when wiring it. - **t16's natural hook point:** a voice-token mint would be a NEW admin-independent route (e.g. `POST /api/voice/token`) that runs the same two learner-side gates the tutor route runs — `requireConsented` then the @@ -628,7 +690,7 @@ before the `INFERENCE_URL` check — see "The approval-gate invariant" above. node --test ``` -151 tests cover session sign/verify/expiry, `recorded` validation (including the +153 tests cover session sign/verify/expiry, `recorded` validation (including the `score`/`grade`/`points` rejection), the full record round-trip, per-learner ledger isolation, web + device OAuth flows, the consent gate (zero D1 writes on both unconsented sign-in paths, the pending-session 403 wall, accept ordering — @@ -665,7 +727,11 @@ a live token with no re-login; approve 409s `consent_stale` unless the target's consent is current (c20); a terms bump blocks tutoring even for an approved learner (both gates independent); approving one learner never touches another's row; and deletion erases the flag so a re-signup is not -approved. Tests invoke the Worker's +approved. The Converse pass-through (`test/tutor-converse.test.js`, t15, +additive) proves the unchanged broker carries a native Bedrock Converse +payload verbatim — plus exactly the `learner` stamp — and relays the +Converse response shape and upstream error statuses untranslated. Tests +invoke the Worker's `fetch` handler directly with in-memory KV/D1 stubs (the D1 stub logs every write statement, making "zero writes" literal); no network and no wrangler are needed. A published-version bump is simulated with diff --git a/workers/learn-api/test/tutor-converse.test.js b/workers/learn-api/test/tutor-converse.test.js new file mode 100644 index 0000000..c2df114 --- /dev/null +++ b/workers/learn-api/test/tutor-converse.test.js @@ -0,0 +1,118 @@ +// t15 (spec c24/h11): the broker + a Bedrock **Converse**-shaped payload. +// +// Additive proof that the Nova Pro wiring really is CONFIG ONLY: handleTutor's +// forward-verbatim code (unchanged since t9) already carries a native Converse +// request — system:[{text}], messages:[{role, content:[{text}]}], +// inferenceConfig:{maxTokens, temperature} — to INFERENCE_URL untouched, adds +// exactly the `learner` stamp as one extra top-level field (live-verified as +// tolerated by the Converse API on 2026-07-11; see README.md "For t15"), and +// hands the Converse-shaped response (output.message.content[].text, +// stopReason, usage) back to the client byte-for-byte. No provider SDK, no +// shape translation, no new headers. +// +// The URL below is the real production target (commented out in wrangler.toml +// until t17 flips it live); here it is only a stub key — no network happens. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import worker from "../src/index.js"; +import { TERMS_VERSION } from "../src/terms.js"; +import { + makeEnv, + makeFetchStub, + mintToken, + seedConsent, + jsonResp, + authedRequest, +} from "./helpers.js"; + +const BASE = "https://learn-api.example"; +const CONVERSE_URL = + "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-pro-v1:0/converse"; + +/** A consented + approved learner env pointing the broker at the Converse URL. */ +async function approvedEnv(fetchStub) { + const env = makeEnv({ + INFERENCE_URL: CONVERSE_URL, + INFERENCE_TOKEN: "bedrock-api-key", + FETCH: fetchStub, + }); + seedConsent(env, "42", TERMS_VERSION); + env.DB.learners.set("42", { + github_user_id: "42", + display_name: "Ada", + state: JSON.stringify({ approved: true }), + }); + const { token } = await mintToken(env, { uid: "42", name: "Ada" }); + return { env, token }; +} + +function call(env, request) { + return worker.fetch(request, env, { waitUntil() {} }); +} + +test("broker forwards a Converse-shaped payload verbatim + the learner stamp", async () => { + const converseResponse = { + metrics: { latencyMs: 491 }, + output: { message: { content: [{ text: "OK" }], role: "assistant" } }, + stopReason: "end_turn", + usage: { inputTokens: 11, outputTokens: 2, totalTokens: 13 }, + }; + const fetchStub = makeFetchStub({ [CONVERSE_URL]: () => jsonResp(converseResponse) }); + const { env, token } = await approvedEnv(fetchStub); + + const payload = { + system: [{ text: "You are the learn tutor." }], + messages: [{ role: "user", content: [{ text: "Grade my answer: ..." }] }], + inferenceConfig: { maxTokens: 300, temperature: 0 }, + }; + const res = await call( + env, + authedRequest(`${BASE}/api/tutor`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + + assert.equal(res.status, 200); + assert.equal(fetchStub.calls.length, 1); + const outbound = fetchStub.calls[0]; + assert.equal(outbound.url, CONVERSE_URL); + assert.equal(outbound.init.headers.Authorization, "Bearer bedrock-api-key"); + assert.equal(outbound.init.headers["Content-Type"], "application/json"); + + // Forward-verbatim: the Converse fields pass through UNCHANGED, and the + // one addition is the broker's top-level learner stamp — nothing else. + const forwarded = JSON.parse(outbound.init.body); + assert.deepEqual(forwarded, { ...payload, learner: "42" }); + + // The Converse response comes back byte-shape-identical to the client. + const body = await res.json(); + assert.deepEqual(body, converseResponse); +}); + +test("a Converse error status passes through the broker untranslated", async () => { + // Bedrock validation errors (e.g. a malformed inferenceConfig) surface as + // 4xx JSON — the dumb broker relays status + body rather than inventing + // its own error vocabulary for upstream failures. + const fetchStub = makeFetchStub({ + [CONVERSE_URL]: () => jsonResp({ message: "Malformed input request" }, 400), + }); + const { env, token } = await approvedEnv(fetchStub); + + const res = await call( + env, + authedRequest(`${BASE}/api/tutor`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [] }), + }), + ); + + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.message, "Malformed input request"); + assert.equal(fetchStub.calls.length, 1); +}); diff --git a/workers/learn-api/wrangler.toml b/workers/learn-api/wrangler.toml index 9406131..9c67d4e 100644 --- a/workers/learn-api/wrangler.toml +++ b/workers/learn-api/wrangler.toml @@ -31,9 +31,9 @@ routes = [ ] # --- Non-secret vars ------------------------------------------------------- -# INFERENCE_URL points at the cloudai-cli / ec2bedrock-cli SERVED endpoint -# (an OpenAI-shaped HTTP API), NEVER a bespoke provider SDK. The broker only -# ever POSTs JSON to this URL. Leave it unset to disable tutoring (broker 503s). +# INFERENCE_URL points at a plain HTTPS inference endpoint the broker POSTs +# JSON to — NEVER a bespoke provider SDK. Leave it unset to disable tutoring +# (broker 503s). The production target is Bedrock-direct (t15, below). [vars] # GITHUB_CLIENT_ID (the OAuth/GitHub-App client id) is technically public but # deliberately NOT committed here: like GITHUB_CLIENT_SECRET it is set via @@ -44,7 +44,28 @@ PUBLIC_URL = "https://agentculture.org" # origin used to build the OA APP_URL = "https://agentculture.org/learn/" # where the web callback redirects post-login CORS_ORIGIN = "https://agentculture.org" # allow the /learn site to call the API with creds PAGES_ORIGIN = "https://agentculture-learn.pages.dev" # static-site origin the /learn zone mount proxies to -# INFERENCE_URL = "https:///v1/messages" # unset ⇒ tutoring 503s + +# Tutoring inference (t15, spec c24/h11) — Bedrock-direct, CONFIG ONLY (the +# broker code is untouched; see test/tutor-converse.test.js): +# model: us.amazon.nova-pro-v1:0 (Nova Pro, cross-region inference profile) +# region: us-east-1 +# INFERENCE_TOKEN: a Bedrock API key — set with +# `wrangler secret put INFERENCE_TOKEN`, sourced from the repo-root .env +# (AWS_BEDROCK_API_KEY_SECRET). Never committed, never a [vars] entry. +# cost-when-busy: per-token Nova Pro spend ONLY — no model host, no +# provisioned throughput, zero idle cost. +# Probe record (2026-07-11, live): Bedrock's OpenAI-compatible chat-completions +# surface does NOT serve Nova Pro in any probed region (model_not_found in +# us-east-1 / us-west-2 / eu-west-1 / eu-central-1; /openai/v1/models is not +# even an operation) — the native Converse API below is the live-verified +# target (HTTP 200, real completions, ~0.5-1s round trip). Payloads are +# Converse-shaped: {system:[{text}], messages:[{role, content:[{text}]}], +# inferenceConfig:{maxTokens, temperature}}; responses +# {output:{message:{content:[{text}]}}, stopReason, usage}. Converse tolerates +# the broker's `learner` stamp as an extra top-level field (also +# live-verified), so the forward stays verbatim. Uncomment to enable tutoring +# (t17's launch gate drives the live flip): +# INFERENCE_URL = "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-pro-v1:0/converse" # ADMIN_GITHUB_IDS (spec c12/h4, task t8): comma-separated GitHub NUMERIC ids # allow-listed for the admin surface (GET /api/admin/learners today; the @@ -60,7 +81,7 @@ ADMIN_GITHUB_IDS = "20955789" # SESSION_SECRET random >=32 bytes; signs session tokens (HMAC-SHA256) # GITHUB_CLIENT_ID the GitHub-App client id (.env GITHUB_APP_CLIENT_ID) # GITHUB_CLIENT_SECRET from the GitHub OAuth app -# INFERENCE_TOKEN bearer token for the served inference endpoint +# INFERENCE_TOKEN Bedrock API key (t15 — .env AWS_BEDROCK_API_KEY_SECRET) # --- KV: sessions namespace ------------------------------------------------- # Holds device-flow bookkeeping and session-revocation tombstones. Sessions From 7339f1d30403eda455f055006cf6213e8813c860 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:10:55 +0300 Subject: [PATCH 16/24] =?UTF-8?q?feat(t17):=20extended=20launch=20gate=20?= =?UTF-8?q?=E2=80=94=20consent/approval/deletion/tutor/voice/cloze=20succe?= =?UTF-8?q?ss=20signals=20+=20boundary=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uplift's success signals (spec c8) as an extended launch gate that FAILS against pre-uplift prod and passes post-deploy (h17), plus the mechanical boundary invariants (h16). New: - tools/launch-gate/consent_walk.mjs — the consent -> approval -> deletion -> tutoring/voice signals as HTTP flows over a real origin. LOCAL mode drives the FULL authed flows against the in-process topology (10/10): zero D1 writes until consent (consent row before learner row), re-consent on version bump, self-serve delete + revoke, tutor 403 with zero outbound inference, admin approve/revoke, voice gate order (approval before config), policy/consent/voice pages + cloze content served. LIVE mode probes prod's unauthenticated surface (routes must 401 not 404, pages must 200 with markers). - tests/test_launch_gate_invariants.py — always-on mechanical invariants (h16): no email/password column, no provider SDK in the Worker (dep or import), static-auth check stays wired, no subject prose authored in learn-cli, privacy policy names every processor and its data claims match the schema. - tools/launch-gate/BASELINE-2026-07-11.md — the recorded pre-uplift LIVE baseline: 9/10 consent_walk LIVE checks fail against today's prod (routes/pages 404), the h17 evidence. Changed: - run.sh — two new steps: the Worker unit suite (168, the authoritative authed-flow proof) and consent_walk.mjs (honors LIVE_ORIGIN). - api-server.mjs — fix ERR_HTTP_HEADERS_SENT on Set-Cookie responses (setHeader after writeHead); the harness could not serve consent accept/login/logout/delete e2e. Surfaced by the new consent walk. - report.py — labels for the new audiences. Gates: pytest 568 passed / 12 e2e-skipped; worker 168/168; site build + npm run check green; consent_walk LOCAL 10/10, LIVE 9/10 (baseline); black/isort/flake8/bandit clean; rubric doctor PASS; markdownlint 0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- tests/test_launch_gate_invariants.py | 246 ++++++++++++++ tools/launch-gate/BASELINE-2026-07-11.md | 98 ++++++ tools/launch-gate/README.md | 78 ++++- tools/launch-gate/api-server.mjs | 6 +- tools/launch-gate/consent_walk.mjs | 416 +++++++++++++++++++++++ tools/launch-gate/report.py | 21 +- tools/launch-gate/run.sh | 23 ++ 7 files changed, 876 insertions(+), 12 deletions(-) create mode 100644 tests/test_launch_gate_invariants.py create mode 100644 tools/launch-gate/BASELINE-2026-07-11.md create mode 100644 tools/launch-gate/consent_walk.mjs diff --git a/tests/test_launch_gate_invariants.py b/tests/test_launch_gate_invariants.py new file mode 100644 index 0000000..087ca2a --- /dev/null +++ b/tests/test_launch_gate_invariants.py @@ -0,0 +1,246 @@ +"""Boundary invariants of the consent-tutoring uplift, asserted mechanically. + +These are the launch gate's *always-on* half (t17, spec honesty condition h16: +"The invariants are mechanically checkable"). Unlike the live/e2e probes under +``tests/e2e/`` — which need a running origin and only fire under +``RUN_LAUNCH_GATE=1`` — every check here is a pure file read over the committed +tree, so it runs in the normal ``uv run pytest -n auto`` suite and locks the +four scope/boundary guarantees the spec's "Scope / boundaries" section makes: + +1. **No email/password, ever.** The only persisted identity is the GitHub + numeric id + the public display name — the schema stores no ``email`` or + ``password`` column (also the concrete half of h9: the Privacy Policy's + "no email, no password" claim must match what the code persists). +2. **Signed-out stays zero-API/zero-model.** The static-auth proof + (``check-static-auth.mjs``) exists and is wired into the site's + ``npm run check`` — its execution is a ``run.sh`` gate step; this test locks + that it stays wired. +3. **The Worker stays provider-agnostic.** No Bedrock/OpenAI/Anthropic/AWS SDK + is added to the Worker (neither a dependency nor a ``src/`` import) — h11's + "no code change beyond config", enforced structurally. +4. **No subject prose is authored inside learn-cli.** The curriculum is + authored UPSTREAM in the subject repos and pulled in via ``learn site + export``; learn-cli's own ``learn/`` source tree carries no story/lesson + prose, and the exported bundle carries its generated provenance. + +A fifth check ties the Privacy Policy to reality (h9's second half): every +processor the code actually calls — GitHub, Cloudflare, AWS Bedrock — is named +in the published policy, and the policy's data claims (no email, no password, +export + delete) are not contradicted by the schema. + +Deliberately pure file reads (no subprocess, no network) — same rationale as +``test_tutor_page.py`` / ``test_consent_page.py``: these lock *source shape*, +and stay hermetic so the default suite is fast and offline. +""" + +from __future__ import annotations + +import ast +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCHEMA_SQL = ROOT / "workers" / "learn-api" / "schema.sql" +WORKER_PKG = ROOT / "workers" / "learn-api" / "package.json" +WORKER_SRC = ROOT / "workers" / "learn-api" / "src" +SITE_PKG = ROOT / "site-astro" / "package.json" +CHECK_STATIC_AUTH = ROOT / "site-astro" / "scripts" / "check-static-auth.mjs" +PRIVACY_PAGE = ROOT / "site-astro" / "src" / "pages" / "privacy" / "index.astro" +CONTENT_EXPORT = ROOT / "site-astro" / "src" / "content-export" +EXPORT_PY = ROOT / "learn" / "front" / "_export.py" + + +def _read(path: Path) -> str: + assert path.is_file(), f"expected {path} to exist" + return path.read_text(encoding="utf-8") + + +# --- 1. no email/password column (privacy invariant + h9's concrete half) ---- + + +def test_schema_defines_exactly_the_three_uplift_tables() -> None: + sql = _read(SCHEMA_SQL).lower() + for table in ("learners", "records", "consents"): + assert re.search(rf"create table if not exists {table}\b", sql), f"missing table {table}" + + +def test_schema_has_no_email_or_password_column() -> None: + # Column-name scan: split on CREATE TABLE bodies and look for a column whose + # NAME is email/password (not merely the word appearing in a comment). + sql = _read(SCHEMA_SQL) + # Strip SQL line comments so the schema's own "no password, no email" note + # (a comment, deliberately) can't trip the check. + code = "\n".join(line.split("--", 1)[0] for line in sql.splitlines()) + column_names = re.findall( + r"^\s*([a-z_][a-z0-9_]*)\s+(?:text|integer|blob|real|numeric)", code, re.I | re.M + ) + lowered = {c.lower() for c in column_names} + assert "email" not in lowered, "schema must not persist an email column" + assert "password" not in lowered, "schema must not persist a password column" + # The identity we DO persist is exactly the GitHub id + display name. + assert "github_user_id" in lowered + assert "display_name" in lowered + + +# --- 2. signed-out zero-API check stays wired -------------------------------- + + +def test_static_auth_check_exists_and_is_wired_into_npm_check() -> None: + assert CHECK_STATIC_AUTH.is_file(), "check-static-auth.mjs (the zero-API proof) must exist" + pkg = json.loads(_read(SITE_PKG)) + scripts = pkg.get("scripts", {}) + check_cmd = scripts.get("check", "") + # `npm run check` must invoke the static-auth proof (directly or via a + # sub-script it chains). Accept either the script name or the file. + wired = "check-static-auth" in check_cmd or any( + "check-static-auth" in v for v in scripts.values() + ) + assert wired, "npm run check must run check-static-auth.mjs (the signed-out zero-API gate)" + + +# --- 3. the Worker adds no provider SDK -------------------------------------- + +_PROVIDER_DEP_RE = re.compile(r"@?(aws-sdk|aws-crt|@aws|bedrock|openai|anthropic|@smithy)", re.I) + + +def test_worker_declares_no_provider_sdk_dependency() -> None: + pkg = json.loads(_read(WORKER_PKG)) + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + offenders = [name for name in deps if _PROVIDER_DEP_RE.search(name)] + assert offenders == [], f"Worker must add no provider SDK dependency; found: {offenders}" + # The only devDependency the broker-through-config design needs is wrangler. + assert "wrangler" in deps, "wrangler is the Worker's one expected devDependency" + + +def test_worker_src_imports_no_provider_sdk() -> None: + offenders: list[str] = [] + for js in sorted(WORKER_SRC.glob("*.js")): + for line in _read(js).splitlines(): + stripped = line.strip() + if stripped.startswith("//") or stripped.startswith("*"): + continue + m = re.search(r"""(?:import\b[^;]*from|require\()\s*['"]([^'"]+)['"]""", line) + if m and _PROVIDER_DEP_RE.search(m.group(1)): + offenders.append(f"{js.name}: {stripped}") + assert offenders == [], f"Worker src must import no provider SDK; found: {offenders}" + + +# --- 4. no subject prose authored inside learn-cli --------------------------- + + +# A story/lesson body runs to hundreds of characters; a legitimate format +# string, error message, or SQL fragment in learn-cli's own source does not. +# 200 chars is comfortably above the latter and below the former. +_PROSE_LEN = 200 + + +def _long_string_constants(py_text: str) -> list[str]: + """Every string-literal *value* longer than ``_PROSE_LEN`` in a Python + source, EXCLUDING docstrings (module/class/function). Uses ``ast`` so it + counts real string constants — never code that happens to sit between two + unrelated short literals (the trap a bare regex falls into).""" + tree = ast.parse(py_text) + docstrings: set[int] = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + body = getattr(node, "body", []) + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + docstrings.add(id(body[0].value)) + out: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if id(node) in docstrings: + continue + if len(node.value) > _PROSE_LEN and "http" not in node.value: + out.append(node.value) + return out + + +def test_export_driver_spawns_subjects_and_embeds_no_prose() -> None: + # learn/front/_export.py is the ONLY producer of the content bundle. It must + # drive the subject CLIs (drive(...)) and contain no embedded story text — + # the prose comes from the subjects, never from learn-cli source. + text = _read(EXPORT_PY) + assert "drive(" in text, "the export must pull content from the subject CLIs via drive()" + long_literals = _long_string_constants(text) + assert long_literals == [], "the exporter must embed no story prose of its own" + + +def test_content_bundle_carries_generated_provenance() -> None: + meta = json.loads(_read(CONTENT_EXPORT / "meta.json")) + assert set(meta) >= {"contract_version", "schema_version", "subjects"} + assert sorted(meta["subjects"]) == ["culture-guide", "french", "spanish"] + + +# The content-bearing fields of a story/exercise (the subject-plugin contract's +# story + cloze shapes). In learn-cli's own JSON these appear ONLY as schema +# property NAMES (whose value is a type descriptor), never populated with prose. +_CONTENT_FIELDS = {"body", "text", "prompt", "answer", "explanation", "choices", "options"} + + +def _content_field_prose(obj: object) -> list[str]: + """Long STRING values sitting under a content-bearing key — the signature of + a forked story/exercise. A schema's ``"body": {"type": "string"}`` has an + OBJECT value and is ignored; only a ``"body": ""`` is flagged.""" + found: list[str] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + for key, val in node.items(): + if key in _CONTENT_FIELDS and isinstance(val, str) and len(val) > _PROSE_LEN: + found.append(f"{key}={val[:50]}...") + walk(val) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(obj) + return found + + +def test_no_subject_content_is_committed_under_the_learn_package() -> None: + # learn/ holds registry metadata + contract SCHEMAS + CLI/portal code — it + # declares the story/cloze fields but never populates them with prose. A + # forked French story would show up as a long `body`/`text`/... string + # VALUE under learn/ (the subjects' own repos are the only home for that). + offenders: list[str] = [] + for jf in (ROOT / "learn").rglob("*.json"): + try: + data = json.loads(jf.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + for hit in _content_field_prose(data): + offenders.append(f"{jf.relative_to(ROOT)}: {hit}") + assert offenders == [], f"learn/ must carry no forked subject content; found: {offenders}" + + +# --- 5. the Privacy Policy matches what the code persists (h9) ---------------- + + +def test_privacy_policy_names_every_processor_the_code_calls() -> None: + text = _read(PRIVACY_PAGE) + lowered = text.lower() + for processor in ("github", "cloudflare", "bedrock"): + assert processor in lowered, f"Privacy Policy must name the {processor} processor" + + +def test_privacy_policy_data_claims_match_the_schema() -> None: + text = _read(PRIVACY_PAGE).lower() + # The policy claims no email + no password — and the schema must back that. + assert "no email" in text or "not.*email" in text or "email" in text + assert "password" in text + # And it must describe the self-serve export + delete path (right to withdraw). + assert "export" in text and "delete" in text + # Cross-check against the live schema: no email/password column (the same + # assertion as test_schema_has_no_email_or_password_column, tied here to the + # policy so a schema regression that adds one surfaces as a policy lie too). + sql = _read(SCHEMA_SQL) + code = "\n".join(line.split("--", 1)[0] for line in sql.splitlines()).lower() + assert not re.search(r"^\s*email\s+(text|integer)", code, re.M) + assert not re.search(r"^\s*password\s+(text|integer)", code, re.M) diff --git a/tools/launch-gate/BASELINE-2026-07-11.md b/tools/launch-gate/BASELINE-2026-07-11.md new file mode 100644 index 0000000..1d8a3c1 --- /dev/null +++ b/tools/launch-gate/BASELINE-2026-07-11.md @@ -0,0 +1,98 @@ +# Pre-uplift launch-gate baseline — 2026-07-11 + +Spec honesty condition **h17**: *"Each listed check becomes a real test in the +extended launch gate or worker suite and FAILS against today's build — they are +acceptance tests that distinguish shipped from not-shipped, not restatements."* + +This file records that baseline: the extended gate's **LIVE probes**, run against +the **pre-uplift production deployment** at `https://agentculture.org/learn` +**before** the consent-tutoring uplift is deployed. Every distinguishing check +FAILS here — which is the point. After the supervised deploy, the identical +`LIVE_ORIGIN=https://agentculture.org` run turns green. + +Captured read-only: GETs and unauthenticated POSTs only (each rejected before +any write) — no session was minted against production, nothing was mutated. No +secrets appear below. + +## `consent_walk.mjs` LIVE probes vs pre-uplift prod + +Command: + +```bash +LIVE_ORIGIN=https://agentculture.org node tools/launch-gate/consent_walk.mjs +``` + +Result: **1 PASS / 9 FAIL** (2026-07-11). The nine failures are the shipped-vs- +not distinguishers: + +| Check | Pre-uplift prod | Post-deploy target | +| --- | --- | --- | +| `route_exists__export` (`GET /api/export`) | **404** — route absent | 401 (route live, rejects unauth) | +| `route_exists__consent_accept` (`POST /api/consent/accept`) | **404** — route absent | 401 | +| `route_exists__admin_learners` (`GET /api/admin/learners`) | **404** — route absent | 401 | +| `route_exists__voice_token` (`POST /api/voice/token`) | **404** — route absent | 401 | +| `page_live__terms_` (`GET /learn/terms/`) | **404** — page absent | 200 + "terms" | +| `page_live__privacy_` (`GET /learn/privacy/`) | **404** — page absent | 200 + github/cloudflare/bedrock | +| `page_live__consent_` (`GET /learn/consent/`) | **404** — page absent | 200 + "consent" | +| `page_live__voice_` (`GET /learn/voice/`) | **404** — page absent | 200 + "voice" | +| `tutor_panel_and_cloze_live` (subject + cloze story) | **FAIL** — no `data-tutor-panel`/`data-cloze-blank` in the served markup | pass | +| `tutor_signed_out_401` (`POST /api/tutor` signed out) | **PASS** (401) | PASS (401) | + +The one PASS, `tutor_signed_out_401`, is honest but **not** a distinguisher: +`/api/tutor` existed pre-uplift and already 401s a signed-out caller (zero +inference), so it passes on both sides. It is kept because the zero-inference- +when-signed-out invariant must not regress — but it is not evidence of the +uplift shipping. + +## Raw HTTP status snapshot (curl, 2026-07-11T14:58Z) + +```text +GET /learn/ -> 200 (site live pre-uplift) +GET /learn/french/ -> 200 (subject page live, but no tutor panel / cloze) +GET /learn/terms/ -> 404 +GET /learn/privacy/ -> 404 +GET /learn/consent/ -> 404 +GET /learn/voice/ -> 404 +POST /learn/api/tutor -> 401 (pre-existing; signed-out, zero inference) +GET /learn/api/me -> 401 (pre-existing session route) +GET /learn/api/export -> 404 +POST /learn/api/consent/accept -> 404 +GET /learn/api/admin/learners -> 404 +POST /learn/api/voice/token -> 404 +``` + +## The authed half (not probeable against prod) + +The consent/approval/deletion **authed** flows cannot be exercised against prod +(the gate cannot mint a production session). They are proven two ways instead, +both green on this branch pre-deploy: + +- `consent_walk.mjs` **LOCAL** mode — the full flows over HTTP against the + in-process real topology (`api-server.mjs`: the actual Worker default export + + the built site): **10/10 PASS**. This is the "passes now" evidence. +- The Worker's own unit suite (`cd workers/learn-api && npm test`): **168/168**, + including the zero-inference counting proof, consent-row-before-learner-row + ordering, revocation on delete, and the voice gate order. Run as a `run.sh` + gate step. + +## Which boundary invariants would fail a pre-uplift TREE + +The mechanical invariants (`tests/test_launch_gate_invariants.py`, always-on) +lock the post-uplift tree. On the **pre-uplift** tree they would fail because: +`schema.sql` had no `consents` table; there was no `approved` flag or admin +allow-list; no `/learn/terms` or `/learn/privacy` page named the Bedrock +processor; and no cloze exercises existed to be exported. They pass now — that +transition is the uplift. + +## Post-deploy: re-run to green + +After `wrangler deploy` (Worker) + the Pages redeploy (site) + `sam deploy` +(voice bridge, optional for the non-voice checks), re-run: + +```bash +RUN_LAUNCH_GATE=1 LIVE_ORIGIN=https://agentculture.org bash tools/launch-gate/run.sh +``` + +The nine failures above must flip to PASS. That green run — recorded while the +public `agentculture.org/learn` nav link is live (h14) — is the launch gate the +uplift ships behind. diff --git a/tools/launch-gate/README.md b/tools/launch-gate/README.md index 15b0666..514b051 100644 --- a/tools/launch-gate/README.md +++ b/tools/launch-gate/README.md @@ -126,13 +126,53 @@ Every launch-bar number, machine-checked: - The zero-API static check is `site-astro`'s own `npm run check` (static-auth + export-pages), run as a `run.sh` step since it needs the node build. +## The consent-tutoring uplift checks (t17) + +The extended gate adds the uplift's four new guarantees — consent, approval, +deletion, and the tutoring/voice tiers — on top of the original walk. They live +in two places so the pre/post-deploy story is legible: + +- **`consent_walk.mjs`** — the success signals as HTTP flows over a real origin, + in two modes with the SAME check ids: + - **LOCAL** (default): the FULL authed flows against the in-process topology + (`api-server.mjs`) — a fresh sign-in writes **zero D1 rows until consent is + accepted** (and the consent row is written **before** the learner row); a + published-version bump forces **re-consent**; **self-serve delete** erases + every row and revokes the session; a consented-but-unapproved `/api/tutor` + call gets **403 with the inference endpoint hit zero times**; **admin + approve/revoke** flips the tutoring tier; the **voice-token mint** enforces + the same gate in the same order (approval before config); and the policy / + consent / voice pages plus **cloze** story content are actually served. This + is the "passes now" evidence. + - **LIVE** (`LIVE_ORIGIN` set): the deployed origin's **unauthenticated** + surface — new routes must answer 401 (not 404), new pages must exist with + their markers. Against **pre-uplift** prod these FAIL (404s) — the recorded + **`BASELINE-2026-07-11.md`** baseline (spec h17); post-deploy they pass. +- **`tests/test_launch_gate_invariants.py`** (repo `tests/`, always-on — not + gated by `RUN_LAUNCH_GATE`) — the mechanical boundary invariants (h16): the + schema stores **no email/password** column, the Worker adds **no provider + SDK** (dependency or import), the static-auth zero-API check stays wired, and + **no subject prose is authored inside learn-cli** (the exporter drives the + subject CLIs and embeds no prose; learn-cli's own JSON carries no story + content). These run in the normal `uv run pytest` suite. + +The **authed** consent/approval/delete/voice flows cannot be reproduced against +LIVE prod (no mintable prod session), so the Worker's own unit suite +(`cd workers/learn-api && npm test`, 168 tests) is run as a `run.sh` step and is +the authoritative proof of the zero-inference counting, consent ordering, and +revocation invariants — `consent_walk.mjs` LOCAL mode re-proves them at the HTTP +layer. + ## Files - `run.sh` — the single entrypoint; orchestrates every check, prints the table. - `api-server.mjs` — wraps the real Worker + static site into one local origin. - `walk.mjs` — the Playwright walk (web audience) + the CLI parity bridge. +- `consent_walk.mjs` — the consent → approval → deletion → tutoring/voice success + signals (t17), LOCAL authed flows + LIVE unauthenticated probes. - `launch_bar.py` — the measurable launch-bar checks. - `report.py` — reads the collected NDJSON results, renders the PASS/FAIL table. +- `BASELINE-2026-07-11.md` — the recorded pre-uplift LIVE baseline (h17). - `package.json` — this package's own `playwright` devDependency (**not** added to `site-astro/package.json`). - `.out/` — generated results (gitignored). @@ -150,18 +190,36 @@ origin: LIVE_ORIGIN=https://agentculture.org bash tools/launch-gate/run.sh ``` -`walk.mjs` honors `LIVE_ORIGIN` for the signed-out walk (cards, story -body/glossary, `data-auth="out"`, and the single-`/api/me` network invariant) -against the real deployment. The signed-in walk needs a real session, which the -gate cannot mint against production — sign in through the deployed site and drive -that leg by hand, or supply a session out of band. The local-artifact checks -(launch bar, `npm run check`, the CLI/agent audiences) validate the very build -that was deployed and still run. +Both `walk.mjs` (the signed-out progress walk) and `consent_walk.mjs` (the +uplift LIVE probes) honor `LIVE_ORIGIN`. `walk.mjs` re-runs the signed-out walk +(cards, story body/glossary, `data-auth="out"`, the single-`/api/me` network +invariant); `consent_walk.mjs` probes the new routes (must 401, not 404) and +pages (must 200 with their markers). The signed-in walk and the authed +consent/approval/delete/voice flows need a real session, which the gate cannot +mint against production — the Worker's unit suite + `consent_walk.mjs` LOCAL +mode prove those; against prod, sign in through the deployed site and drive the +last mile by hand. The local-artifact checks (launch bar, `npm run check`, the +CLI/agent audiences, the boundary invariants) validate the very build deployed. + +**The uplift ships behind a fully-green LIVE run.** Before deploy, +`BASELINE-2026-07-11.md` records the 9-of-10 `consent_walk.mjs` LIVE failures +that distinguish shipped-from-not (spec h17). After `wrangler deploy` (Worker) + +the Pages redeploy (site) — and `sam deploy` for the voice bridge — the same +command flips them to PASS: + +```bash +RUN_LAUNCH_GATE=1 LIVE_ORIGIN=https://agentculture.org bash tools/launch-gate/run.sh +``` ## What an operator must re-run against the LIVE site after deployment - The signed-out live walk above (routing, the `agentculture.org/learn/*` zone mount, the static assets, and the single-`/api/me` invariant on real infra). -- A manual signed-in pass: sign in on the deployed site, record a result, and - confirm the learner panel + `GET /learn/api/progress/:subject` agree — the - live-infra version of the parity bridge this gate proves locally. +- The uplift LIVE probes (`consent_walk.mjs` via `run.sh` with `LIVE_ORIGIN`): + every new route 401s unauthenticated, every new page is served with its + markers, the subject page carries the tutor panel, the cloze story renders. +- A manual signed-in pass: sign in on the deployed site, accept the consent + notice, record a result, confirm the learner panel + `GET + /learn/api/progress/:subject` agree, then (as an admin-approved learner) run a + Nova Pro-graded exercise and a Nova Sonic 2 voice exchange — the live-infra + version of the tiers this gate proves locally. diff --git a/tools/launch-gate/api-server.mjs b/tools/launch-gate/api-server.mjs index 810c235..a0ad15d 100644 --- a/tools/launch-gate/api-server.mjs +++ b/tools/launch-gate/api-server.mjs @@ -117,8 +117,12 @@ async function webResponseToNodeRes(webRes, res) { if (key.toLowerCase() === "set-cookie") return; headers[key] = value; }); + // Multiple Set-Cookie headers go in as an ARRAY value on the writeHead + // headers object — setHeader() AFTER writeHead throws ERR_HTTP_HEADERS_SENT, + // which corrupts every Set-Cookie-bearing response (login, consent accept, + // logout, delete). Keep them together in the single writeHead call. + if (setCookies.length) headers["Set-Cookie"] = setCookies; res.writeHead(webRes.status, headers); - if (setCookies.length) res.setHeader("Set-Cookie", setCookies); const buf = Buffer.from(await webRes.arrayBuffer()); res.end(buf); } diff --git a/tools/launch-gate/consent_walk.mjs b/tools/launch-gate/consent_walk.mjs new file mode 100644 index 0000000..7ddf804 --- /dev/null +++ b/tools/launch-gate/consent_walk.mjs @@ -0,0 +1,416 @@ +// consent_walk.mjs — the consent → approval → tutoring → deletion success +// signals (t17), end-to-end over a REAL origin. +// +// This is the launch-gate half that walk.mjs (the signed-in/out progress walk) +// does not cover: the uplift's four new guarantees exercised as HTTP flows, +// not unit-mocked. It runs in two modes, and the SAME check ids appear in both +// so the pre/post-deploy diff is legible: +// +// LOCAL (default) — stands up the real production topology in-process +// (api-server.mjs: the actual Worker default export + the built site behind +// one origin) and drives the FULL authed flows with real tokens: a fresh +// sign-in writes zero D1 rows until consent is accepted (consent row before +// the learner row); a version bump forces re-consent; self-serve delete +// erases every row and revokes the session; a consented-but-unapproved +// /api/tutor call gets 403 with the inference endpoint called ZERO times; +// an admin approve flips the tutoring tier and revoke withdraws it; the +// voice-token mint enforces the same gate in the same order; and the policy +// / consent / voice pages plus cloze story content are actually served. +// This is the "passes now" evidence (spec h17). +// +// LIVE (LIVE_ORIGIN=https://agentculture.org) — probes the deployed origin's +// UNAUTHENTICATED surface only (the gate cannot mint a prod session): the new +// routes must answer 401 (not 404), and the new pages must exist and carry +// their markers. Against TODAY's pre-uplift prod these FAIL (routes/pages +// 404) — that failure IS the h17 baseline; after the supervised deploy the +// same probes pass. The authed flows stay proven by the LOCAL run above and +// by the Worker's own 168 unit tests (run as a separate run.sh step). +// +// Emits one NDJSON line {audience, check, status, detail} per check to +// $LAUNCH_GATE_RESULTS (folded into report.py's table); exits non-zero on any +// FAIL. Detail strings are kept quote/backslash-free for the NDJSON. + +import { appendFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { startTopology, mintToken } from "./api-server.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "..", ".."); + +const liveOrigin = process.env.LIVE_ORIGIN || ""; +const resultsFile = process.env.LAUNCH_GATE_RESULTS || ""; +const distDeploy = process.env.LAUNCH_GATE_DIST_DEPLOY || resolve(repoRoot, "site-astro", "dist-deploy"); + +const results = []; +function emit(audience, check, status, detail = "") { + const clean = String(detail).replace(/["\\\n\r]/g, " ").slice(0, 180); + results.push({ audience, check, status, detail: clean }); + const mark = status === "PASS" ? "PASS " : status === "SKIP" ? "SKIP " : "FAIL "; + console.log(` [${mark}] (${audience}) ${check}${clean ? " — " + clean : ""}`); +} + +// A check helper: run `fn`, PASS if it does not throw, FAIL with the message. +async function check(audience, id, fn) { + try { + const detail = (await fn()) || ""; + emit(audience, id, "PASS", detail); + } catch (err) { + emit(audience, id, "FAIL", String(err && err.message ? err.message : err).split("\n")[0]); + } +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +const bearer = (token) => ({ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }); + +async function jget(url, token) { + const res = await fetch(url, token ? bearer(token) : {}); + let body = {}; + try { + body = await res.json(); + } catch { + body = {}; + } + return { status: res.status, body }; +} + +async function jpost(url, token, payload) { + const init = { method: "POST", ...(token ? bearer(token) : { headers: { "Content-Type": "application/json" } }) }; + init.body = JSON.stringify(payload || {}); + const res = await fetch(url, init); + let body = {}; + try { + body = await res.json(); + } catch { + body = {}; + } + return { status: res.status, body }; +} + +// ============================================================================ +// LOCAL mode — the full authed flows against the in-process topology. +// ============================================================================ + +async function localFlows() { + const topo = await startTopology({ distDeployDir: distDeploy }); + const api = topo.origin + "/learn/api"; + const site = topo.origin + "/learn"; + // A known admin id for the admin-surface checks (the topology's makeEnv has + // no ADMIN_GITHUB_IDS; set one so isAdmin has an allow-list to enforce). + topo.env.ADMIN_GITHUB_IDS = "999"; + + try { + // --- consent gate: zero writes before accept, consent row before learner -- + await check("consent", "consent_zero_writes_until_accept", async () => { + const uid = "1001"; + const { token: pending } = await mintToken(topo.env, { uid, name: "Ada Pending" }, 600, { + pendingConsent: true, + }); + const writesBefore = topo.db.writes.length; + const me = await jget(api + "/me", pending); + assert(me.status === 200 && me.body.pending_consent === true, "GET /api/me must report pending_consent"); + const prog = await jget(api + "/progress/french", pending); + assert(prog.status === 403 && prog.body.error === "consent_required", "pending progress must 403 consent_required"); + assert(topo.db.writes.length === writesBefore, "NO D1 write may occur before consent is accepted"); + return "pending session: /api/me pending, /progress 403, zero writes"; + }); + + await check("consent", "consent_accept_writes_consent_before_learner", async () => { + const uid = "1001b"; + const { token: pending } = await mintToken(topo.env, { uid, name: "Ada Accept" }, 600, { + pendingConsent: true, + }); + const from = topo.db.writes.length; + const acc = await jpost(api + "/consent/accept", pending, {}); + assert(acc.status === 200 && acc.body.status === "consented", "accept must return consented + full token"); + const newWrites = topo.db.writes.slice(from); + const consentIdx = newWrites.findIndex((w) => w.table === "consents" && w.op === "insert"); + const learnerIdx = newWrites.findIndex((w) => w.table === "learners" && w.op === "insert"); + assert(consentIdx >= 0 && learnerIdx >= 0, "accept must insert both a consent and a learner row"); + assert(consentIdx < learnerIdx, "the consent row must be written BEFORE the learner row"); + // And a record write now succeeds under the full token. + const rec = await jpost(api + "/record", acc.body.token, { + subject: "french", + recorded: { item_id: "fr.greetings.bonjour", result: "pass", activity: "lesson", at: "2026-07-11T00:00:00Z" }, + }); + assert(rec.status === 200 || rec.status === 201, `record after consent must succeed (got ${rec.status})`); + return "consent row precedes learner row; record works post-consent"; + }); + + // --- re-consent on a published-version bump ------------------------------ + await check("consent", "reconsent_on_version_bump", async () => { + const uid = "1002"; + const { token: pending } = await mintToken(topo.env, { uid, name: "Bo Reconsent" }, 600, { + pendingConsent: true, + }); + const acc = await jpost(api + "/consent/accept", pending, {}); + assert(acc.status === 200, "initial consent must succeed"); + const full = acc.body.token; + // A working consented request first. + const ok = await jget(api + "/progress/french", full); + assert(ok.status === 200, `consented progress must 200 (got ${ok.status})`); + // Publish a new terms version → the SAME token is now stale. + topo.env.TERMS_VERSION_OVERRIDE = "999.0.0"; + try { + const stale = await jget(api + "/progress/french", full); + assert(stale.status === 403 && stale.body.error === "consent_required", "stale token must 403 consent_required"); + assert(stale.body.reason === "stale_version", "the 403 must carry reason stale_version"); + // Re-accept (reachable on requireAuth, not requireConsented). + const re = await jpost(api + "/consent/accept", full, {}); + assert(re.status === 200 && re.body.status === "consented", "re-accept must succeed"); + } finally { + delete topo.env.TERMS_VERSION_OVERRIDE; + } + return "version bump routes the live token back to consent; re-accept clears it"; + }); + + // --- self-serve delete erases every row and revokes the session ---------- + await check("delete", "self_serve_delete_erases_and_revokes", async () => { + const uid = "1003"; + const { token: pending } = await mintToken(topo.env, { uid, name: "Cy Delete" }, 600, { + pendingConsent: true, + }); + const acc = await jpost(api + "/consent/accept", pending, {}); + const full = acc.body.token; + await jpost(api + "/record", full, { + subject: "french", + recorded: { item_id: "fr.greetings.bonjour", result: "pass", activity: "lesson", at: "2026-07-11T00:00:00Z" }, + }); + const del = await jpost(api + "/delete", full, { confirm: uid }); + assert(del.status === 200, `delete must 200 (got ${del.status})`); + // No row anywhere carries the id. + const inLearners = topo.db.learners.has(uid); + const inRecords = topo.db.records.some((r) => r.github_user_id === uid); + const inConsents = topo.db.consents.some((c) => c.github_user_id === uid); + assert(!inLearners && !inRecords && !inConsents, "delete must leave no learners/records/consents row"); + // The old token is revoked. + const after = await jget(api + "/me", full); + assert(after.status === 401, `revoked token must 401 (got ${after.status})`); + return "all three tables cleared for the id; old token 401s"; + }); + + // --- approval gate: 403 with ZERO outbound inference --------------------- + await check("approval", "tutor_403_zero_inference_when_unapproved", async () => { + // A counting inference endpoint; the gate must never reach it for a + // consented-but-unapproved learner (spec h5). + let inferenceCalls = 0; + topo.env.FETCH = async (url, init) => { + inferenceCalls += 1; + return new Response(JSON.stringify({ echo: true }), { status: 200, headers: { "Content-Type": "application/json" } }); + }; + topo.env.INFERENCE_URL = "http://inference.invalid/v1"; + try { + const uid = "1004"; + const { token: pending } = await mintToken(topo.env, { uid, name: "Di Unapproved" }, 600, { + pendingConsent: true, + }); + const acc = await jpost(api + "/consent/accept", pending, {}); + const tut = await jpost(api + "/tutor", acc.body.token, { messages: [] }); + assert(tut.status === 403 && tut.body.error === "approval_required", "unapproved tutor must 403 approval_required"); + assert(inferenceCalls === 0, `ZERO inference calls expected, got ${inferenceCalls}`); + return "consented+unapproved /api/tutor 403; inference endpoint hit 0 times"; + } finally { + delete topo.env.FETCH; + delete topo.env.INFERENCE_URL; + } + }); + + // --- admin approve/revoke flips the tutoring tier ------------------------ + await check("approval", "admin_approve_and_revoke_flip_tutoring", async () => { + const adminUid = "999"; + const learnerUid = "1005"; + // Admin must itself be a consented learner to use authed routes. + const { token: adminPending } = await mintToken(topo.env, { uid: adminUid, name: "Admin" }, 600, { + pendingConsent: true, + }); + const adminAcc = await jpost(api + "/consent/accept", adminPending, {}); + const adminTok = adminAcc.body.token; + // The learner consents (c20: approve 409s unless the target's consent is current). + const { token: lp } = await mintToken(topo.env, { uid: learnerUid, name: "El Approved" }, 600, { + pendingConsent: true, + }); + const lAcc = await jpost(api + "/consent/accept", lp, {}); + const learnerTok = lAcc.body.token; + // Non-admin cannot list learners. + const forbidden = await jget(api + "/admin/learners", learnerTok); + assert(forbidden.status === 403, `non-admin admin route must 403 (got ${forbidden.status})`); + // Admin can. + const roster = await jget(api + "/admin/learners", adminTok); + assert(roster.status === 200, `admin roster must 200 (got ${roster.status})`); + // Approve the learner. + const appr = await jpost(api + "/admin/approve", adminTok, { github_user_id: learnerUid }); + assert(appr.status === 200, `approve must 200 (got ${appr.status}: ${appr.body.error || ""})`); + // The approved learner's tutor call now passes the approval gate — it + // reaches the config check (503 no_inference, since INFERENCE_URL unset), + // NOT the 403 approval_required wall. + const tutApproved = await jpost(api + "/tutor", learnerTok, { messages: [] }); + assert(tutApproved.status !== 403, `approved tutor must clear the 403 gate (got ${tutApproved.status})`); + assert(tutApproved.status === 503, `approved tutor with no INFERENCE_URL should 503 (got ${tutApproved.status})`); + // Revoke → back to 403. + const rev = await jpost(api + "/admin/revoke", adminTok, { github_user_id: learnerUid }); + assert(rev.status === 200, `revoke must 200 (got ${rev.status})`); + const tutRevoked = await jpost(api + "/tutor", learnerTok, { messages: [] }); + assert(tutRevoked.status === 403 && tutRevoked.body.error === "approval_required", "revoked tutor must 403 again"); + return "non-admin 403; approve reaches config gate; revoke restores 403"; + }); + + // --- voice-token mint enforces the same gate in the same order ----------- + await check("voice", "voice_token_same_gate_same_order", async () => { + // Unapproved → 403 approval_required (BEFORE any 503 not_configured). + const uid = "1006"; + const { token: pending } = await mintToken(topo.env, { uid, name: "Fi Voice" }, 600, { + pendingConsent: true, + }); + const acc = await jpost(api + "/consent/accept", pending, {}); + const learnerTok = acc.body.token; + const unappr = await jpost(api + "/voice/token", learnerTok, {}); + assert( + unappr.status === 403 && unappr.body.error === "approval_required", + `unapproved voice must 403 approval_required (got ${unappr.status})`, + ); + // Approve, then the mint reaches the config check: 503 not_configured + // (VOICE_BRIDGE_URL unset) — proving the approval gate precedes config. + const adminUid = "999"; + const { token: ap } = await mintToken(topo.env, { uid: adminUid, name: "Admin" }, 600, { pendingConsent: true }); + const adminTok = (await jpost(api + "/consent/accept", ap, {})).body.token; + await jpost(api + "/admin/approve", adminTok, { github_user_id: uid }); + const approved = await jpost(api + "/voice/token", learnerTok, {}); + assert( + approved.status === 503 && approved.body.error === "not_configured", + `approved voice with no bridge must 503 not_configured (got ${approved.status}: ${approved.body.error})`, + ); + return "unapproved 403 approval_required; approved 503 not_configured — approval before config"; + }); + + // --- the new pages + cloze content are actually served ------------------- + await check("policy", "policy_consent_voice_pages_served", async () => { + for (const path of ["/terms/", "/privacy/", "/consent/", "/voice/"]) { + const res = await fetch(site + path); + assert(res.status === 200, `GET /learn${path} must 200 (got ${res.status})`); + } + const privacy = await fetch(site + "/privacy/").then((r) => r.text()); + const lc = privacy.toLowerCase(); + for (const proc of ["github", "cloudflare", "bedrock"]) { + assert(lc.includes(proc), `privacy page must name the ${proc} processor`); + } + return "terms/privacy/consent/voice served; privacy names github, cloudflare, bedrock"; + }); + + await check("policy", "policy_pages_linked_from_learn_footer", async () => { + const landing = await fetch(site + "/").then((r) => r.text()); + assert(/href="[^"]*\/terms\/?"/.test(landing), "the /learn footer must link Terms"); + assert(/href="[^"]*\/privacy\/?"/.test(landing), "the /learn footer must link Privacy"); + return "footer links Terms + Privacy"; + }); + + await check("cloze", "tutor_panel_and_cloze_content_served", async () => { + // The subject page carries the (signed-in-only) tutor panel markup. + const subjectPage = await fetch(site + "/french/").then((r) => r.text()); + assert(subjectPage.includes("data-tutor-panel"), "the french subject page must carry the tutor panel"); + // A story known to ship a cloze exercise renders the pick-the-word widget. + const storyPage = await fetch(site + "/french/stories/fr-b1-le-covoiturage/").then((r) => r.text()); + assert(storyPage.includes("data-cloze-blank"), "the cloze story must render a data-cloze-blank widget"); + return "subject page has the tutor panel; cloze story renders data-cloze-blank"; + }); + } finally { + await topo.close(); + } +} + +// ============================================================================ +// LIVE mode — unauthenticated probes against the deployed origin. +// Against pre-uplift prod these FAIL (404s) = the h17 baseline; post-deploy +// they pass. State-safe: GETs + unauthenticated POSTs (rejected before any +// write), never a real session, never a mutation. +// ============================================================================ + +async function liveProbes(origin) { + const api = origin + "/learn/api"; + const site = origin + "/learn"; + + // New routes must exist and reject the unauthenticated caller (401), not 404. + const routeProbes = [ + ["GET", "/export"], + ["POST", "/consent/accept"], + ["GET", "/admin/learners"], + ["POST", "/voice/token"], + ]; + for (const [method, path] of routeProbes) { + await check("uplift-live", `route_exists_${path.replace(/\//g, "_")}`, async () => { + const res = method === "GET" ? await jget(api + path, "") : await jpost(api + path, "", {}); + assert(res.status === 401, `${method} /api${path} must exist + 401 unauthenticated (got ${res.status})`); + return `${method} /api${path} -> 401 (route live)`; + }); + } + + // The signed-out /api/tutor never spends inference (401, no session). + await check("uplift-live", "tutor_signed_out_401", async () => { + const res = await jpost(api + "/tutor", "", { messages: [] }); + assert(res.status === 401, `signed-out /api/tutor must 401 (got ${res.status})`); + return "signed-out /api/tutor -> 401 (zero inference)"; + }); + + // New pages must exist and carry their markers. + const pageProbes = [ + ["/terms/", ["terms"]], + ["/privacy/", ["github", "cloudflare", "bedrock"]], + ["/consent/", ["consent"]], + ["/voice/", ["voice"]], + ]; + for (const [path, markers] of pageProbes) { + await check("uplift-live", `page_live_${path.replace(/\//g, "_")}`, async () => { + const res = await fetch(site + path); + assert(res.status === 200, `GET /learn${path} must 200 (got ${res.status})`); + const lc = (await res.text()).toLowerCase(); + for (const m of markers) assert(lc.includes(m), `/learn${path} must mention ${m}`); + return `/learn${path} -> 200 with markers [${markers.join(", ")}]`; + }); + } + + // The subject + cloze story content is live. + await check("uplift-live", "tutor_panel_and_cloze_live", async () => { + const subj = await fetch(site + "/french/").then((r) => r.text()); + assert(subj.includes("data-tutor-panel"), "french subject page must carry the tutor panel"); + const story = await fetch(site + "/french/stories/fr-b1-le-covoiturage/").then((r) => r.text()); + assert(story.includes("data-cloze-blank"), "cloze story must render data-cloze-blank"); + return "subject tutor panel + cloze widget live"; + }); +} + +// ============================================================================ + +async function main() { + if (liveOrigin) { + console.log(`\nconsent walk: LIVE mode against ${liveOrigin}`); + console.log(" (pre-uplift prod is EXPECTED to fail these — that is the h17 baseline)"); + await liveProbes(liveOrigin); + } else { + console.log("\nconsent walk: LOCAL mode (in-process real topology)"); + await localFlows(); + } + + if (resultsFile) { + for (const r of results) appendFileSync(resultsFile, JSON.stringify(r) + "\n"); + } + const failed = results.filter((r) => r.status === "FAIL"); + const skipped = results.filter((r) => r.status === "SKIP"); + console.log( + `\nconsent walk: ${results.length} checks — ${results.length - failed.length - skipped.length} PASS, ` + + `${failed.length} FAIL, ${skipped.length} SKIP`, + ); + process.exit(failed.length ? 1 : 0); +} + +main().catch((err) => { + console.error("consent walk crashed:", err); + if (resultsFile) { + appendFileSync( + resultsFile, + JSON.stringify({ audience: "uplift", check: "consent walk harness", status: "FAIL", detail: String(err).split("\n")[0] }) + "\n", + ); + } + process.exit(1); +}); diff --git a/tools/launch-gate/report.py b/tools/launch-gate/report.py index f487ed4..3136318 100755 --- a/tools/launch-gate/report.py +++ b/tools/launch-gate/report.py @@ -15,7 +15,20 @@ import sys from collections import OrderedDict -AUDIENCE_ORDER = ["preflight", "static", "launch-bar", "web", "cli", "agent"] +AUDIENCE_ORDER = [ + "preflight", + "static", + "launch-bar", + "web", + "cli", + "agent", + "consent", + "approval", + "voice", + "policy", + "cloze", + "uplift-live", +] AUDIENCE_LABEL = { "preflight": "Preflight (subject CLIs)", "static": "Static / zero-API (site build)", @@ -23,6 +36,12 @@ "web": "Web (Playwright signed-in/out)", "cli": "CLI (pytest golden --json)", "agent": "Agent (MCP harness)", + "consent": "Consent gate (t17 walk)", + "approval": "Approval gate (t17 walk)", + "voice": "Voice gate (t17 walk)", + "policy": "Policy pages (t17 walk)", + "cloze": "Cloze content (t17 walk)", + "uplift-live": "Uplift LIVE probes (post-deploy)", } diff --git a/tools/launch-gate/run.sh b/tools/launch-gate/run.sh index b992914..9f9c555 100755 --- a/tools/launch-gate/run.sh +++ b/tools/launch-gate/run.sh @@ -90,6 +90,21 @@ RUN_LAUNCH_GATE=1 uv --project "$REPO_ROOT" run pytest "$REPO_ROOT/tests/e2e" \ -p no:randomly -q >/dev/null 2>&1 echo " (per-test results recorded to the gate table)" +# --- 5b. Worker unit suite: the authed consent/approval/delete/voice flows ---- +# The consent walk (step 6b) drives these flows over HTTP against the LOCAL +# topology, but the AUTHED half cannot be reproduced against LIVE prod (no +# mintable prod session). The Worker's own in-memory suite is the authoritative +# proof of the same invariants (zero-inference counting, consent ordering, +# revocation), so run it here and fold one row into the gate table. +step "worker unit suite (consent/approval/delete/voice invariants)" +if (cd "$REPO_ROOT/workers/learn-api" && npm test >/dev/null 2>&1); then + echo " [PASS ] workers/learn-api node --test green" + emit static "worker unit suite (node --test)" PASS "authed consent/approval/delete/voice invariants green" +else + echo " [FAIL ] workers/learn-api node --test failed" + emit static "worker unit suite (node --test)" FAIL "see: cd workers/learn-api && npm test" +fi + # --- 6. web audience: the scripted success walk (Playwright) ------------------ step "web audience: scripted success walk (phone + desktop)" ( @@ -101,6 +116,14 @@ step "web audience: scripted success walk (phone + desktop)" node walk.mjs ) || true +# --- 6b. consent/approval/deletion walk (the uplift success signals) ---------- +# LOCAL: full authed flows over HTTP against the in-process topology (the +# "passes now" evidence). LIVE (LIVE_ORIGIN set): unauthenticated probes of the +# deployed origin — against pre-uplift prod these FAIL by design (the h17 +# baseline); after the supervised deploy they pass. Plain node, no Playwright. +step "consent/approval/deletion walk (uplift success signals)" +node "$SCRIPT_DIR/consent_walk.mjs" || true + # --- 7. render the final PASS/FAIL table + gate verdict ----------------------- uv --project "$REPO_ROOT" run python "$SCRIPT_DIR/report.py" "$RESULTS" exit $? From 6e95af27235be92093c0c152ab1e5e295c97a6f1 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:14:46 +0300 Subject: [PATCH 17/24] chore: bump 0.6.0, changelog, and record the c24 Converse correction (pending user confirmation) - version-bump minor 0.5.4 -> 0.6.0 with the full uplift changelog - spec: post-convergence correction note on c24 (OpenAI-compat -> native Converse), annotating not rewriting the converged claim; captured as q1 in the frame questions store for user confirmation at this PR gate. h11 (config-only, no provider SDK) still holds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- CHANGELOG.md | 25 +++++++++++++++++++ ...e-org-learn-is-now-a-consent-first-role.md | 25 +++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecfb4ad..40399c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-07-11 + +### Added + +- Consent-first sign-in: both the web callback and device-flow paths now issue a short-lived pending-consent session and write ZERO to D1 until the learner accepts the published Terms/Privacy — the consent row is recorded before the learner row (consents table, no FK to learners). +- Versioned re-consent: a TERMS_VERSION bump routes a live full session back to the consent screen (403 consent_required, reason stale_version) until re-acceptance. +- Self-serve data rights: GET /api/export (whole-learner JSON across every subject + consent history) and POST /api/delete (confirm = own github_user_id) erasing all three tables and revoking the session. +- Role-aware access: ADMIN_GITHUB_IDS server-side allow-list, GET /api/admin/learners roster, default-private learner visibility with POST /api/me/visibility. +- Approval-gated Bedrock tutoring tier: POST /api/admin/approve|revoke, a four-level tutor gate (signed-out < consented < approved < configured) where a non-approved /api/tutor call 403s with zero outbound inference; approve is c20-gated on current consent. +- Nova Pro text tutoring surface (grading, adaptive next-step, personalized cloze-story generation) through the existing broker via Bedrock Converse — config only, no provider SDK added to the Worker. +- Nova Sonic 2 voice: approval-gated POST /api/voice/token mint with a per-learner monthly budget, the /learn/voice page, and a serverless SAM voice bridge (API Gateway WebSocket + arm64 Lambda + $20 AWS Budgets ceiling) under infra/. +- Terms of Use + Privacy Policy pages under /learn, versioned from a single shared/terms-version.mjs source, naming GitHub, Cloudflare, and AWS Bedrock as processors; a /learn/consent page with a five-state client flow. +- Cloze (pick-the-right-word) exercise kind in the subject-plugin contract (contract §3.6.1), shipped by french-cli, spanish-cli, and culture-guide and re-exported to /learn. +- Extended launch gate: tools/launch-gate/consent_walk.mjs drives the consent/approval/deletion/tutor/voice/cloze success signals end-to-end (LOCAL authed flows + LIVE unauthenticated probes), tests/test_launch_gate_invariants.py locks the boundary invariants (no email/password, no provider SDK, no forked subject prose), with a recorded pre-uplift baseline. + +### Changed + +- GITHUB_CLIENT_ID is no longer committed in wrangler.toml [vars] — it is set as a secret from .env GITHUB_APP_CLIENT_ID, so a plain wrangler deploy never overwrites it. +- check-static-auth.mjs now polices four per-script fetch whitelists (learner, consent, voice, tutor); the tutor surface renders only for admin-approved learners. + +### Fixed + +- Launch-gate api-server.mjs no longer throws ERR_HTTP_HEADERS_SENT on Set-Cookie responses (consent accept / login / logout / delete), so those flows can be exercised end-to-end. +- getConsent tiebreaks same-millisecond granted_at rows by rowid, removing a flaky re-consent race. + ## [0.5.4] - 2026-07-11 ### Added diff --git a/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md b/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md index 00006db..d2559fb 100644 --- a/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md +++ b/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md @@ -69,3 +69,28 @@ - Model access is Bedrock-direct (user decision): AWS Bedrock is the only service that can serve AWS Nova models — both the Nova Pro text path (OpenAI-compatible endpoint + Bedrock API key) and the Nova Sonic 2 voice path (InvokeModelWithBidirectionalStream) terminate at Bedrock itself. Anything between the learner and Bedrock is thin credentialed plumbing enforcing the approval gate, never a model host. Supersedes the earlier cloudai-cli/ec2bedrock-cli serving framing. - Policy docs live /learn-local (user decision, resolves v1): learn-cli authors, versions, and serves the Terms of Use + Privacy Policy under /learn — it is the only surface processing personal data (auth, ledger, Bedrock); org's static site collects nothing. Re-consent versioning stays inside learn-cli, uncoupled from org releases. org gets a filed issue to link the policies site-wide and to record this decision. - AWS plumbing is serverless, pay-as-you-go (user decision, resolves v7): no EC2 unless explicitly requested. The Nova Sonic 2 voice bridge follows league-of-agents-platform's proven infra pattern (built by Fable): AWS SAM, Lambda (arm64) behind an API Gateway WebSocket API relaying learner audio to Bedrock's bidirectional stream, an AWS Budgets alarm pinned to a hard monthly USD ceiling with every sizing choice commented against it, and zero idle cost. Bedrock itself is serverless GenAI — nothing always-on sits between the learner and it. + +## Post-convergence correction (2026-07-11, pending user confirmation) + +> This note annotates — it does not silently rewrite — a converged claim. It is +> captured as `q1` in the frame's questions store and awaits user confirmation +> at the final PR gate. + +- **c24 endpoint wording (OpenAI-compatible → native Converse).** c24 (and the + Decisions "Model access is Bedrock-direct" line) say the Nova Pro text path + points `INFERENCE_URL` at Bedrock's *OpenAI-compatible chat-completions* + endpoint (`bedrock-runtime..amazonaws.com/openai/v1/...`). A live probe + during t15 (2026-07-11) proved that endpoint returns `model_not_found` for + Nova Pro in every region tried (us-east-1, us-west-2, eu-west-1, eu-central-1), + and `/openai/v1/models` is not even an operation. The **native Bedrock Converse + API** — `POST /model/us.amazon.nova-pro-v1:0/converse` with a Bedrock API key as + the Bearer token — DID return real Nova Pro completions (HTTP 200, ~0.5–1s) and + tolerates the broker's `learner` stamp as an extra field. So the honesty + condition **h11 still holds** (no Worker code change beyond config; no provider + SDK): only the endpoint URL and the request/response *shape* change from the + OpenAI chat-completions form to the Converse form. The shipped code + (`wrangler.toml`, `workers/learn-api/README.md`, `site-astro/src/scripts/ + tutor-core.js`) already targets Converse; this note reconciles the spec's + wording with what shipped. **Proposed:** retarget c24's "OpenAI-compatible + chat-completions endpoint" wording to the native Converse API. Awaiting user + confirmation. diff --git a/pyproject.toml b/pyproject.toml index bcc5c34..f7f120f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "learn-cli" -version = "0.5.4" +version = "0.6.0" description = "A CLI, MCP server, and web front for learning — a hosted site where humans and agents learn a subject step by step. Fronts the french-cli and spanish-cli language tutors, and generalizes to other learnable domains such as leading teams of agents. The learn command is the local operator surface and how agents drive it." readme = "README.md" license = "Apache-2.0" From 5dc69277090683c3a06db928b7cdd90fc493c0e1 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:16:07 +0300 Subject: [PATCH 18/24] chore: gitignore .devague/questions (devague working-state, auto-added) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- .gitignore | 2 ++ uv.lock | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1964510..ea0ee28 100644 --- a/.gitignore +++ b/.gitignore @@ -235,3 +235,5 @@ skills.local.yaml # wrangler local state (created by `wrangler pages deploy` at repo root) .wrangler/ + +.devague/questions/ diff --git a/uv.lock b/uv.lock index bb7e564..4730450 100644 --- a/uv.lock +++ b/uv.lock @@ -474,7 +474,7 @@ wheels = [ [[package]] name = "learn-cli" -version = "0.5.4" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "agentfront", extra = ["mcp"] }, From 2a1a24430a962e0a090d39ab78635c79291ac5c3 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:21:26 +0300 Subject: [PATCH 19/24] chore: scope GitGuardian ignore to the t16 voice-token test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-language contract fixture (tests/fixtures/voice_token_cross_ language.json) is a deterministic HMAC of a PUBLIC payload with a fake, committed test secret — it pins the JS minter / Python verifier agreement byte-for-byte and cannot change. GitGuardian's JWT detector matches its shape; this narrowly-scoped ignore (that one path + the known fake secret string) records it as a documented false positive. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- .gitguardian.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .gitguardian.yaml diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 0000000..dbab6f4 --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,20 @@ +# GitGuardian secret-scan configuration. +# +# The single ignored path below is a TEST-ONLY fixture, not a leaked +# credential. It pins the cross-language voice-token contract (task t16): a +# voice token minted ONCE by workers/learn-api/src/voice.js#mintVoiceToken with +# a deterministic, fake secret ("cross-language-contract-test-secret") over a +# public payload, committed so the JS minter (workers/learn-api/test/ +# voice.test.js) and the Python verifier (tests/test_voice_token_cross_language +# .py against infra/voice_bridge/tokens.py) can each prove they still agree +# byte-for-byte. The token is an HMAC of non-secret data with a non-secret test +# key — GitGuardian's JWT detector matches its shape, but there is no real +# secret here (the fixture's own `note` says as much), and it CANNOT be changed +# without breaking the contract pin. Scope the ignore to this one file only. +version: 2 +secret: + ignored-paths: + - tests/fixtures/voice_token_cross_language.json + ignored-matches: + - name: voice-token cross-language contract test secret (t16 fixture) + match: cross-language-contract-test-secret From 1280d0bcdcee0304d03b7883dd80db4f3d72b463 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:29:31 +0300 Subject: [PATCH 20/24] spec: apply the user-confirmed c24 correction (OpenAI-compat -> native Converse) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User confirmed 2026-07-11. c24 and the Model-access decision now name the native Bedrock Converse API (POST /model/us.amazon.nova-pro-v1:0/converse, Bearer Bedrock API key); the addendum is retained as the evidence trail and frame question q1 is resolved. h11 holds — config only, no provider SDK. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- ...ture-org-learn-is-now-a-consent-first-role.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md b/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md index d2559fb..4affdb8 100644 --- a/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md +++ b/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md @@ -34,9 +34,9 @@ - honesty: All three subjects pass learn subject doctor with at least one cloze item present, and a learner's pre-existing mastery/progress reads back identically after the re-export (item_id join keys unchanged) - Terms of Use + Privacy Policy (#11): both pages published, versioned, linked from the /learn footer and from the consent notice. The Privacy Policy names GitHub (OAuth identity), Cloudflare (Workers/KV/D1/Pages hosting), and AWS Bedrock (Nova Sonic 2 / Nova Pro — approved tier only, learner speech/text leaves to the model provider) as processors, states retention + the delete/export path, and matches what the code actually persists (no email, no password). - honesty: Every processor the code actually calls (GitHub OAuth endpoints, Cloudflare storage/hosting, the Bedrock-backed inference endpoint) is named in the published Privacy Policy, and nothing the policy claims about data (no email, no password, deletable on request) is contradicted by the schema or worker code -- Nova Pro tutoring (#8): text tutoring (exercise grading, adaptive next-step, explanation, personalized cloze-story generation) routes through the existing /api/tutor broker with INFERENCE_URL pointing directly at AWS Bedrock's OpenAI-compatible chat-completions endpoint (bedrock-runtime .../openai/v1) and a Bedrock API key as INFERENCE_TOKEN — AWS Bedrock is the only service that serves AWS Nova models; no sibling-hosted model server, no idle host. Cost-when-busy: per-token Nova Pro spend only. Generated cloze stories and their results are recorded to the existing ledger as contract-valid records with stable item_ids. - - instruction: Verify with a curl against the Bedrock OpenAI-compatible endpoint using the Bedrock API key (expect a Nova Pro completion), then the worker test that /api/tutor forwards approved traffic and the Worker diff shows no provider SDK added - - honesty: The Worker gains no Bedrock/provider SDK and no code change beyond config — a Bedrock API key against the OpenAI-compatible endpoint returns a Nova Pro completion, and the broker forwards approved traffic to it unchanged (h6's condition, retargeted at Bedrock-direct) +- Nova Pro tutoring (#8): text tutoring (exercise grading, adaptive next-step, explanation, personalized cloze-story generation) routes through the existing /api/tutor broker with INFERENCE_URL pointing directly at AWS Bedrock's native Converse API (bedrock-runtime .../model/us.amazon.nova-pro-v1:0/converse) and a Bedrock API key as INFERENCE_TOKEN — AWS Bedrock is the only service that serves AWS Nova models; no sibling-hosted model server, no idle host. Cost-when-busy: per-token Nova Pro spend only. Generated cloze stories and their results are recorded to the existing ledger as contract-valid records with stable item_ids. (Corrected 2026-07-11, user-confirmed: Bedrock's OpenAI-compatible endpoint does NOT serve Nova Pro — see the correction note below.) + - instruction: Verify with a curl against the Bedrock Converse endpoint using the Bedrock API key (expect a Nova Pro completion), then the worker test that /api/tutor forwards approved traffic and the Worker diff shows no provider SDK added + - honesty: The Worker gains no Bedrock/provider SDK and no code change beyond config — a Bedrock API key against the Converse endpoint returns a Nova Pro completion, and the broker forwards approved traffic to it unchanged (h6's condition, retargeted at Bedrock-direct) ## Honesty conditions @@ -66,15 +66,15 @@ - Consent UX shape: an unconsented sign-in issues a short-lived pending-consent session whose ONLY capabilities are viewing the consent notice + accepting or declining — no learner row exists yet, /api/me reports pending_consent, every other authed route 403s. Decline = the session is dropped and nothing was ever written. This keeps the OAuth redirect flow intact (the user lands signed-in-pending on the consent page) without persisting pre-consent. - Sequencing: the consent gate + published policy docs (#10/#11) ship BEFORE the tutoring tier is enabled for anyone — no learner is approved for Bedrock until the Privacy Policy disclosing Bedrock processing is live and they have consented to it. Content work (#9) proceeds upstream in parallel, unblocked. - Content flows upstream-first (#9): curriculum improvements are authored in french-cli / spanish-cli / culture-guide, released to PyPI, then pulled into /learn via learn site export + redeploy — learn-cli never forks subject content locally -- Model access is Bedrock-direct (user decision): AWS Bedrock is the only service that can serve AWS Nova models — both the Nova Pro text path (OpenAI-compatible endpoint + Bedrock API key) and the Nova Sonic 2 voice path (InvokeModelWithBidirectionalStream) terminate at Bedrock itself. Anything between the learner and Bedrock is thin credentialed plumbing enforcing the approval gate, never a model host. Supersedes the earlier cloudai-cli/ec2bedrock-cli serving framing. +- Model access is Bedrock-direct (user decision): AWS Bedrock is the only service that can serve AWS Nova models — both the Nova Pro text path (native Converse API + Bedrock API key; corrected 2026-07-11 from "OpenAI-compatible endpoint", which does not serve Nova Pro) and the Nova Sonic 2 voice path (InvokeModelWithBidirectionalStream) terminate at Bedrock itself. Anything between the learner and Bedrock is thin credentialed plumbing enforcing the approval gate, never a model host. Supersedes the earlier cloudai-cli/ec2bedrock-cli serving framing. - Policy docs live /learn-local (user decision, resolves v1): learn-cli authors, versions, and serves the Terms of Use + Privacy Policy under /learn — it is the only surface processing personal data (auth, ledger, Bedrock); org's static site collects nothing. Re-consent versioning stays inside learn-cli, uncoupled from org releases. org gets a filed issue to link the policies site-wide and to record this decision. - AWS plumbing is serverless, pay-as-you-go (user decision, resolves v7): no EC2 unless explicitly requested. The Nova Sonic 2 voice bridge follows league-of-agents-platform's proven infra pattern (built by Fable): AWS SAM, Lambda (arm64) behind an API Gateway WebSocket API relaying learner audio to Bedrock's bidirectional stream, an AWS Budgets alarm pinned to a hard monthly USD ceiling with every sizing choice commented against it, and zero idle cost. Bedrock itself is serverless GenAI — nothing always-on sits between the learner and it. -## Post-convergence correction (2026-07-11, pending user confirmation) +## Post-convergence correction (2026-07-11, user-confirmed) -> This note annotates — it does not silently rewrite — a converged claim. It is -> captured as `q1` in the frame's questions store and awaits user confirmation -> at the final PR gate. +> **Confirmed by the user on 2026-07-11** (frame question `q1`, resolved). The +> correction below is now folded into c24 and the "Model access is +> Bedrock-direct" decision above; this note is retained as the evidence trail. - **c24 endpoint wording (OpenAI-compatible → native Converse).** c24 (and the Decisions "Model access is Bedrock-direct" line) say the Nova Pro text path From fc352acee9ed20602b3ded31b52aa70fddcf802f Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:41:14 +0300 Subject: [PATCH 21/24] fix: reduce cognitive complexity of cloze conformance checks (S3776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud flagged _validate_cloze_exercise (28) and _probe_cloze_items (37) as CRITICAL cognitive-complexity issues, both over the allowed 15 (python:S3776). Extract each distinct validation/probing concern into a small, docstringed helper so both top-level functions become short orchestrators: - _validate_cloze_exercise: _validate_cloze_top_level_fields (text/item_id checks), _validate_cloze_blank_id (id presence/uniqueness/placeholder match), _validate_cloze_blank_answer (options/answer checks), _validate_cloze_blank (per-blank shape + id + answer), and _validate_cloze_orphan_placeholders (text placeholders with no blank). - _probe_cloze_items: _read_story_exercises (one story's story-read + parse), _check_cloze_exercise_id_reuse (cross-story id dedup), _validate_cloze_in_story (one exercise's validation + dedup), _probe_story_cloze_items (one story's exercise loop), and _summarize_cloze_check (final check assembly). Behavior-preserving: identical error strings, error ordering, and return shapes throughout — no test needed to change. Full suite stays green (568 passed, 12 skipped) plus black/isort/flake8/bandit and the `teken cli doctor . --strict` rubric gate all pass. --- learn/subjects/conformance.py | 259 +++++++++++++++++++++++----------- 1 file changed, 180 insertions(+), 79 deletions(-) diff --git a/learn/subjects/conformance.py b/learn/subjects/conformance.py index a4b0b18..15b83ca 100644 --- a/learn/subjects/conformance.py +++ b/learn/subjects/conformance.py @@ -260,25 +260,85 @@ def _is_cloze_blanks_item(exercise: Any) -> bool: ) +def _validate_cloze_top_level_fields(prefix: str, exercise: dict[str, Any], text: Any) -> list[str]: + """Check the exercise-level fields: `text` is a non-empty string, `item_id` is set. + + Independent of the per-blank checks below — both run even if the other fails. + """ + errors: list[str] = [] + if not isinstance(text, str) or not text.strip(): + errors.append(f"{prefix}: `text` must be a non-empty string") + if not exercise.get("item_id"): + errors.append(f"{prefix}: missing `item_id` (the join key `record --item` expects)") + return errors + + +def _validate_cloze_blank_id( + bpath: str, bid: Any, placeholder_ids: set[str], seen_ids: set[str] +) -> list[str]: + """Check one blank's `id`: present, unique in the exercise, matches a `text` placeholder. + + Records a valid, non-duplicate id into ``seen_ids`` (mutated in place) so the + caller can later find `text` placeholders with no matching blank. + """ + if not isinstance(bid, str) or not bid: + return [f"{bpath}: missing/empty `id`"] + if bid in seen_ids: + return [f"{bpath}: duplicate blank id '{bid}' within this exercise"] + seen_ids.add(bid) + if bid not in placeholder_ids: + return [f"{bpath}: id '{bid}' has no matching {{{{{bid}}}}} placeholder in `text`"] + return [] + + +def _validate_cloze_blank_answer(bpath: str, options: Any, answer: Any) -> list[str]: + """Check one blank's `options` (>=2 words) and `answer` (present, among `options`).""" + errors: list[str] = [] + if not isinstance(options, list) or len(options) < 2: + errors.append(f"{bpath}: `options` must list at least 2 words") + if not isinstance(answer, str) or not answer: + errors.append(f"{bpath}: missing/empty `answer`") + elif isinstance(options, list) and answer not in options: + errors.append(f"{bpath}: `answer` {answer!r} is not among its own `options`") + return errors + + +def _validate_cloze_blank( + bpath: str, blank: Any, placeholder_ids: set[str], seen_ids: set[str] +) -> list[str]: + """Validate one `blanks[i]` entry in full: shape, then id, then options/answer.""" + if not isinstance(blank, dict): + return [f"{bpath}: must be an object"] + errors = _validate_cloze_blank_id(bpath, blank.get("id"), placeholder_ids, seen_ids) + errors.extend(_validate_cloze_blank_answer(bpath, blank.get("options"), blank.get("answer"))) + return errors + + +def _validate_cloze_orphan_placeholders( + prefix: str, placeholder_ids: set[str], seen_ids: set[str] +) -> list[str]: + """`text` placeholders with no matching `blanks` entry, sorted for stable output.""" + return [ + f"{prefix}: `text` placeholder {{{{{orphan}}}}} has no matching `blanks` entry" + for orphan in sorted(placeholder_ids - seen_ids) + ] + + def _validate_cloze_exercise(story_id: str, exercise: dict[str, Any]) -> list[str]: """Semantic checks the mini JSON-Schema validator can't express: cross-field rules (an option list contains its own answer, blank ids are unique and match `text`'s placeholders 1:1). Structural shape (types, minItems, ...) is already covered by :func:`learn.contract.validate` against the schema. """ - errors: list[str] = [] exercise_id = exercise.get("id", "?") prefix = f"story '{story_id}' exercise '{exercise_id}'" text = exercise.get("text") blanks = exercise.get("blanks") if text is None or blanks is None: - errors.append(f"{prefix}: a pick-the-right-word cloze item needs BOTH `text` and `blanks`") - return errors - if not isinstance(text, str) or not text.strip(): - errors.append(f"{prefix}: `text` must be a non-empty string") - if not exercise.get("item_id"): - errors.append(f"{prefix}: missing `item_id` (the join key `record --item` expects)") + return [f"{prefix}: a pick-the-right-word cloze item needs BOTH `text` and `blanks`"] + + errors = _validate_cloze_top_level_fields(prefix, exercise, text) if not isinstance(blanks, list) or not blanks: errors.append(f"{prefix}: `blanks` must be a non-empty array") return errors @@ -286,37 +346,114 @@ def _validate_cloze_exercise(story_id: str, exercise: dict[str, Any]) -> list[st placeholder_ids = set(_BLANK_PLACEHOLDER_RE.findall(text)) if isinstance(text, str) else set() seen_ids: set[str] = set() for i, blank in enumerate(blanks): - bpath = f"{prefix} blanks[{i}]" - if not isinstance(blank, dict): - errors.append(f"{bpath}: must be an object") - continue - bid = blank.get("id") - options = blank.get("options") - answer = blank.get("answer") - if not isinstance(bid, str) or not bid: - errors.append(f"{bpath}: missing/empty `id`") - elif bid in seen_ids: - errors.append(f"{bpath}: duplicate blank id '{bid}' within this exercise") - else: - seen_ids.add(bid) - if bid not in placeholder_ids: - errors.append( - f"{bpath}: id '{bid}' has no matching {{{{{bid}}}}} placeholder in `text`" - ) - if not isinstance(options, list) or len(options) < 2: - errors.append(f"{bpath}: `options` must list at least 2 words") - if not isinstance(answer, str) or not answer: - errors.append(f"{bpath}: missing/empty `answer`") - elif isinstance(options, list) and answer not in options: - errors.append(f"{bpath}: `answer` {answer!r} is not among its own `options`") - - for orphan in sorted(placeholder_ids - seen_ids): - errors.append( - f"{prefix}: `text` placeholder {{{{{orphan}}}}} has no matching `blanks` entry" + errors.extend( + _validate_cloze_blank(f"{prefix} blanks[{i}]", blank, placeholder_ids, seen_ids) + ) + + errors.extend(_validate_cloze_orphan_placeholders(prefix, placeholder_ids, seen_ids)) + return errors + + +def _read_story_exercises( + exe: str, entry: SubjectEntry, story_id: str, timeout: float +) -> tuple[list[Any], str | None]: + """Read one story via ``story read`` and return (exercises, error). + + On any failure to read/parse the story, returns ``([], )``. + On success, returns ``(, None)`` — + a malformed/missing ``exercises`` field is not itself an error here; it just + yields nothing to iterate (the schema check on ``story_read`` catches that + shape drift, this probe only cares about cloze content). + """ + result = _run(exe, entry, ("story", "read", story_id), learner=True, timeout=timeout) + if isinstance(result, str): + return [], f"story '{story_id}': could not read to verify cloze items ({result})" + rc, out, _err = result + if rc != 0: + return [], f"story '{story_id}': story read exited {rc}; could not verify cloze items" + try: + payload = json.loads(out) + except json.JSONDecodeError: + return [], f"story '{story_id}': story read did not emit valid JSON" + story = payload.get("story") if isinstance(payload, dict) else None + exercises = story.get("exercises") if isinstance(story, dict) else None + return (exercises if isinstance(exercises, list) else []), None + + +def _check_cloze_exercise_id_reuse( + exercise_id: str, story_id: str, first_story_for_exercise_id: dict[str, str] +) -> str | None: + """Flag a cloze exercise ``id`` already seen under a different story. + + Records the first (story_id) an id was seen under into + ``first_story_for_exercise_id`` (mutated in place). + """ + prior = first_story_for_exercise_id.get(exercise_id) + if prior is not None and prior != story_id: + return ( + f"cloze exercise id '{exercise_id}' is reused in both '{prior}' and " + f"'{story_id}' — exercise ids must be unique" + ) + first_story_for_exercise_id.setdefault(exercise_id, story_id) + return None + + +def _validate_cloze_in_story( + story_id: str, exercise: dict[str, Any], first_story_for_exercise_id: dict[str, str] +) -> list[str]: + """Validate one cloze exercise's well-formedness plus its id's cross-story uniqueness.""" + errors = _validate_cloze_exercise(story_id, exercise) + exercise_id = exercise.get("id") + if isinstance(exercise_id, str) and exercise_id: + reuse_error = _check_cloze_exercise_id_reuse( + exercise_id, story_id, first_story_for_exercise_id ) + if reuse_error is not None: + errors.append(reuse_error) return errors +def _probe_story_cloze_items( + story_id: str, exercises: list[Any], first_story_for_exercise_id: dict[str, str] +) -> tuple[list[str], int]: + """Validate every pick-the-right-word cloze exercise in one story's exercise list. + + Returns ``(errors, found_count)`` — exercises that aren't the cloze-blanks + variant (see :func:`_is_cloze_blanks_item`) are skipped, uncounted. + """ + errors: list[str] = [] + found = 0 + for exercise in exercises: + if not _is_cloze_blanks_item(exercise): + continue + found += 1 + errors.extend(_validate_cloze_in_story(story_id, exercise, first_story_for_exercise_id)) + return errors, found + + +def _summarize_cloze_check( + cid: str, errors: list[str], found: int, remediation: str +) -> dict[str, Any]: + """Turn accumulated per-story errors/found-count into the final `cloze-items` check. + + A subject with no cloze-blanks items (``found == 0``) passes trivially, even + if some story failed to read along the way — that's a content-verification + no-op, not this check's failure to report. + """ + if found == 0: + return _check(cid, True, "no pick-the-right-word cloze items declared (nothing to verify)") + if errors: + return _check( + cid, + False, + f"{errors[0]} ({len(errors)} issue(s) total across {found} cloze item(s))", + remediation=remediation, + ) + return _check( + cid, True, f"{found} pick-the-right-word cloze item(s) verified: well-formed blanks" + ) + + def _probe_cloze_items( exe: str, entry: SubjectEntry, @@ -349,53 +486,17 @@ def _probe_cloze_items( story_id = summary.get("id") if isinstance(summary, dict) else None if not isinstance(story_id, str) or not story_id: continue - result = _run(exe, entry, ("story", "read", story_id), learner=True, timeout=timeout) - if isinstance(result, str): - errors.append(f"story '{story_id}': could not read to verify cloze items ({result})") + exercises, read_error = _read_story_exercises(exe, entry, story_id, timeout) + if read_error is not None: + errors.append(read_error) continue - rc, out, _err = result - if rc != 0: - errors.append( - f"story '{story_id}': story read exited {rc}; could not verify cloze items" - ) - continue - try: - payload = json.loads(out) - except json.JSONDecodeError: - errors.append(f"story '{story_id}': story read did not emit valid JSON") - continue - story = payload.get("story") if isinstance(payload, dict) else None - exercises = story.get("exercises") if isinstance(story, dict) else None - if not isinstance(exercises, list): - continue - for exercise in exercises: - if not _is_cloze_blanks_item(exercise): - continue - found += 1 - errors.extend(_validate_cloze_exercise(story_id, exercise)) - exercise_id = exercise.get("id") - if isinstance(exercise_id, str) and exercise_id: - prior = first_story_for_exercise_id.get(exercise_id) - if prior is not None and prior != story_id: - errors.append( - f"cloze exercise id '{exercise_id}' is reused in both '{prior}' and " - f"'{story_id}' — exercise ids must be unique" - ) - else: - first_story_for_exercise_id.setdefault(exercise_id, story_id) - - if found == 0: - return _check(cid, True, "no pick-the-right-word cloze items declared (nothing to verify)") - if errors: - return _check( - cid, - False, - f"{errors[0]} ({len(errors)} issue(s) total across {found} cloze item(s))", - remediation=remediation, + story_errors, story_found = _probe_story_cloze_items( + story_id, exercises, first_story_for_exercise_id ) - return _check( - cid, True, f"{found} pick-the-right-word cloze item(s) verified: well-formed blanks" - ) + errors.extend(story_errors) + found += story_found + + return _summarize_cloze_check(cid, errors, found, remediation) def run_conformance(entry: SubjectEntry, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: From cb11a5f88e9aa68045fce857f9eeab04a1bfbb73 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:41:34 +0300 Subject: [PATCH 22/24] fix(voice-bridge): Query the concurrency gate instead of Scan-ing the table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo finding (BUG 2, performance): DynamoSessionStore.count_active() used table.scan(FilterExpression=...) to count active sessions for the $connect concurrency gate. Sessions and inbound audio frames share one DynamoDB table, and frames are the hot path (thousands of writes per session), so a Scan read every frame item before filtering — $connect latency/cost grew with frame volume instead of staying bounded by the (small, cap-limited) session count. Fix: move session-metadata items to a constant partition key (PK="ACTIVE", SK=) instead of PK=SESSION#/SK=METADATA. Frame items keep their existing PK=SESSION# shape untouched. count_active() now does table.query(Select="COUNT", KeyConditionExpression="PK = :active", ...) against that one partition, which never touches frame items. put_session/get_session/delete_session updated to the new key shape; no template.yaml schema change needed (PK/SK stay String HASH/RANGE on the base table — no GSI required). Test-first: added FakeDynamoTable (a boto3 Table-resource stand-in that records scan()/query() calls) and four new tests against DynamoSessionStore directly, including test_count_active_queries_the_constant_pk_and_ignores_frame_items, which seeds 3 metadata items alongside 1000 frame items and asserts the count is exactly 3, scan() is never called, and query() is called once keyed on PK="ACTIVE". 568 -> 572 passed, 12 skipped. black/isort/flake8 clean; bandit findings unchanged (4 pre-existing low-severity, none introduced by this change). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- infra/template.yaml | 8 ++ infra/voice_bridge/handler.py | 44 ++++++--- tests/test_voice_bridge_handler.py | 142 +++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 15 deletions(-) diff --git a/infra/template.yaml b/infra/template.yaml index 6bcc6a6..b260d29 100644 --- a/infra/template.yaml +++ b/infra/template.yaml @@ -320,6 +320,14 @@ Resources: # session's model tokens. TTL (expires_at) garbage-collects sessions and # frames for free, so a crashed holder cannot pin the concurrency cap # (rows expire ~1 h past deadline) or accumulate storage cost. + # + # Session-metadata items live under the constant partition key PK=ACTIVE + # (frames keep their own PK=SESSION# partition) precisely so + # the $connect concurrency gate (DynamoSessionStore.count_active) can + # Query that one partition instead of Scan-ing the whole table — a Scan + # would read every frame row too, so its cost/latency would grow with + # frame volume even though the gate only ever needs the session count. + # The base table's PK/SK schema already covers this Query; no GSI needed. VoiceSessionsTable: Type: AWS::DynamoDB::Table Properties: diff --git a/infra/voice_bridge/handler.py b/infra/voice_bridge/handler.py index 80d2ca6..17566d8 100644 --- a/infra/voice_bridge/handler.py +++ b/infra/voice_bridge/handler.py @@ -120,45 +120,59 @@ def _frame(event: dict, connection_id: str, deps: Deps) -> dict: # --- production wiring (boto3; built lazily, unused under pytest) ----------- +#: Constant partition key every session-metadata item lives under. Frame +#: items keep their own per-connection partition (``SESSION#``), +#: so this key never collides with them — Query-ing it touches only the +#: handful of metadata rows the concurrency gate cares about, never the +#: thousands of frame rows the hot path writes. +_ACTIVE_PK = "ACTIVE" + + class DynamoSessionStore: """Sessions + inbound frames in the one PAY_PER_REQUEST table. - Item shapes: ``PK=SESSION#``, ``SK=METADATA`` for the session - row; ``SK=FRAME##`` for inbound audio frames (ordering - is client-``seq`` authoritative — see relay.order_frames; the SK only - approximates arrival). Every item carries an ``expires_at`` TTL so a - crashed session leaves nothing behind and storage cost stays ~zero. + Item shapes: ``PK=ACTIVE``, ``SK=`` for the session-metadata + row (a constant partition key on purpose — see ``count_active`` below); + ``PK=SESSION#``, ``SK=FRAME##`` for + inbound audio frames (ordering is client-``seq`` authoritative — see + relay.order_frames; the SK only approximates arrival). Every item + carries an ``expires_at`` TTL so a crashed session leaves nothing behind + and storage cost stays ~zero. """ def __init__(self, table: Any): self._table = table def count_active(self, now: int) -> int: - # A Scan is O(table), which is fine *because* the concurrency cap - # keeps this table at a handful of rows by construction; a GSI would - # be added capacity for no measurable gain at cap=2. - result = self._table.scan( + # Metadata lives under the constant PK=ACTIVE, so this is a Query + # against a single partition — it reads only metadata items, never + # the frame items (PK=SESSION#) sharing this table. + # Frames are the hot path (thousands of writes per session), so a + # Scan here would have cost/latency growing with frame volume; this + # Query's cost is bounded by the (small, cap-limited) session count. + result = self._table.query( Select="COUNT", - FilterExpression="SK = :meta AND expires_at > :now", - ExpressionAttributeValues={":meta": "METADATA", ":now": now}, + KeyConditionExpression="PK = :active", + FilterExpression="expires_at > :now", + ExpressionAttributeValues={":active": _ACTIVE_PK, ":now": now}, ) return int(result.get("Count", 0)) def put_session(self, connection_id: str, record: dict) -> None: item = { - "PK": f"SESSION#{connection_id}", - "SK": "METADATA", + "PK": _ACTIVE_PK, + "SK": connection_id, "expires_at": record["deadline"] + _TTL_GRACE_SECONDS, **record, } self._table.put_item(Item=item) def get_session(self, connection_id: str) -> dict | None: - result = self._table.get_item(Key={"PK": f"SESSION#{connection_id}", "SK": "METADATA"}) + result = self._table.get_item(Key={"PK": _ACTIVE_PK, "SK": connection_id}) return result.get("Item") def delete_session(self, connection_id: str) -> None: - self._table.delete_item(Key={"PK": f"SESSION#{connection_id}", "SK": "METADATA"}) + self._table.delete_item(Key={"PK": _ACTIVE_PK, "SK": connection_id}) def store_frame(self, connection_id: str, body: str) -> None: self._table.put_item( diff --git a/tests/test_voice_bridge_handler.py b/tests/test_voice_bridge_handler.py index 74f54b8..910b82c 100644 --- a/tests/test_voice_bridge_handler.py +++ b/tests/test_voice_bridge_handler.py @@ -182,3 +182,145 @@ def test_session_mode_event_routes_to_the_session_runner() -> None: assert response == {"ok": True} assert ran == [payload] assert launched == [] + + +# --- DynamoSessionStore: the Query-not-Scan performance fix ----------------- +# +# Qodo finding: count_active() used to Scan the whole voice-sessions table, +# and sessions + frames share that table. A Scan reads every item — frames +# included — before filtering, so $connect latency/cost grew with frame +# volume even though the concurrency gate only ever needs the session count. +# The fix stores session-metadata items under a constant partition key +# (PK="ACTIVE") so count_active can Query that one partition instead. The +# fake table below mimics just enough of the boto3 Table-resource surface +# (get_item/put_item/delete_item/query/scan) to prove the new access path +# for real, against DynamoSessionStore itself — not the FakeStore fake the +# rest of this file uses, which never touches boto3-shaped calls. + + +class FakeDynamoTable: + """Boto3 Table-resource stand-in — only the calls DynamoSessionStore makes. + + Tracks every scan()/query() call it receives so tests can assert on the + access *path* (Query on the constant PK, never a table-wide Scan), not + just the returned count. + """ + + def __init__(self): + self._items: dict[tuple[str, str], dict] = {} + self.scan_calls: list[dict] = [] + self.query_calls: list[dict] = [] + + def seed(self, item: dict) -> None: + self._items[(item["PK"], item["SK"])] = item + + def put_item(self, Item: dict) -> None: # noqa: N803 - boto3's own casing + self._items[(Item["PK"], Item["SK"])] = Item + + def get_item(self, Key: dict) -> dict: # noqa: N803 + item = self._items.get((Key["PK"], Key["SK"])) + return {"Item": item} if item is not None else {} + + def delete_item(self, Key: dict) -> None: # noqa: N803 + self._items.pop((Key["PK"], Key["SK"]), None) + + def scan(self, **kwargs) -> dict: + # A real Scan reads every item in the table regardless of partition + # — record the call (and everything currently stored) so a test can + # assert this path was never exercised, and the count it *would* + # have produced, for comparison against the Query path. + self.scan_calls.append(kwargs) + return {"Count": len(self._items), "Items": list(self._items.values())} + + def query(self, **kwargs) -> dict: + self.query_calls.append(kwargs) + values = kwargs.get("ExpressionAttributeValues", {}) + pk_value = values[":active"] + now = values[":now"] + matched = [ + item + for (pk, _sk), item in self._items.items() + if pk == pk_value and item.get("expires_at", 0) > now + ] + if kwargs.get("Select") == "COUNT": + return {"Count": len(matched)} + return {"Items": matched} + + +def _metadata_item(connection_id: str, expires_at: int) -> dict: + return {"PK": "ACTIVE", "SK": connection_id, "expires_at": expires_at} + + +def _frame_item(connection_id: str, seq: int, expires_at: int) -> dict: + return { + "PK": f"SESSION#{connection_id}", + "SK": f"FRAME#{seq:013d}#deadbeef", + "body": "audio-bytes", + "expires_at": expires_at, + } + + +def test_count_active_queries_the_constant_pk_and_ignores_frame_items() -> None: + """The Qodo fix: count_active must Query PK=ACTIVE, never Scan the table. + + Seeds three active session-metadata items alongside a thousand frame + items belonging to those same sessions (the hot path this bug made + every $connect pay for). The count must equal exactly the metadata + count, the access path must be a Query keyed on the constant partition, + and Scan must never be called at all. + """ + table = FakeDynamoTable() + store = handler.DynamoSessionStore(table) + for i in range(3): + table.seed(_metadata_item(f"conn-{i}", expires_at=NOW + 3600)) + for i in range(1000): + table.seed(_frame_item(f"conn-{i % 3}", seq=i, expires_at=NOW + 120)) + + count = store.count_active(NOW) + + assert count == 3 + assert table.scan_calls == [] + assert len(table.query_calls) == 1 + call = table.query_calls[0] + assert call["Select"] == "COUNT" + assert call["ExpressionAttributeValues"][":active"] == "ACTIVE" + assert "PK" in call["KeyConditionExpression"] + assert "SK" not in call["KeyConditionExpression"] + + +def test_count_active_excludes_ttl_expired_metadata() -> None: + """A crashed session's metadata row must stop pinning the concurrency cap.""" + table = FakeDynamoTable() + store = handler.DynamoSessionStore(table) + table.seed(_metadata_item("conn-live", expires_at=NOW + 3600)) + table.seed(_metadata_item("conn-expired", expires_at=NOW - 1)) + + assert store.count_active(NOW) == 1 + + +def test_put_get_delete_session_round_trip_under_the_constant_pk() -> None: + """put_session/get_session/delete_session all key off PK=ACTIVE, SK=connection.""" + table = FakeDynamoTable() + store = handler.DynamoSessionStore(table) + store.put_session("conn-1", {"uid": "20955789", "connected_at": NOW, "deadline": NOW + 300}) + + stored = table._items[("ACTIVE", "conn-1")] + assert stored["uid"] == "20955789" + assert store.get_session("conn-1")["uid"] == "20955789" + + store.delete_session("conn-1") + + assert store.get_session("conn-1") is None + assert ("ACTIVE", "conn-1") not in table._items + + +def test_store_frame_keeps_the_per_connection_partition_key() -> None: + """Frames must stay under PK=SESSION# — only metadata moved.""" + table = FakeDynamoTable() + store = handler.DynamoSessionStore(table) + + store.store_frame("conn-1", "audio-bytes") + + ((pk, sk),) = table._items.keys() + assert pk == "SESSION#conn-1" + assert sk.startswith("FRAME#") From b2399885586c3a1761ba98685ba3519a1df96fda Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:54:08 +0300 Subject: [PATCH 23/24] =?UTF-8?q?fix(worker):=20close=20two=20Qodo=20revie?= =?UTF-8?q?w=20findings=20=E2=80=94=20delete=20session=20leak=20+=20voice?= =?UTF-8?q?=20budget=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG 1 (security): POST /api/delete only revoked the calling session's own sid tombstone, so a learner's other still-valid sessions (another tab, a CLI token, a session /api/me had refreshed) survived a self-delete. handleDelete now also stamps a per-uid `revoked_uid:` KV marker with the delete epoch second; auth.js#requireAuth rejects any token for that uid whose `iat` predates it (strict `<`, not `<=`, so a resignup landing in the same epoch-second as the delete is never false-revoked — see that function's comment for why). session.js#issueSession gained an optional `opts.now` override (mirroring voice.js#mintVoiceToken's own test-only override) so tests can pin `iat` deterministically instead of relying on wall-clock luck. BUG 3 (reliability): handleVoiceToken's monthly voice-budget cap was a plain read-check-write — two concurrent mints could both read the same `used`, both pass the cap check, and both write, minting two sessions' worth of budget for one booking. db.js#setLearnerVoiceUsage is now a compare-and-swap on the learner row's `updated_at` (`WHERE github_user_id = ? AND updated_at = ?`); index.js#bookVoiceUsage retries (bounded, 3 attempts) against a fresh read on a lost CAS and refuses the mint — without leaking the already-minted token — rather than risk exceeding VOICE_MONTHLY_SECONDS_CAP. test/helpers.js's D1 stub models the conditional UPDATE so the race is provably closed without wrangler or real concurrency. New tests: test/auth.test.js (deterministic requireAuth revocation-boundary unit tests), test/export-delete.test.js (multi-session delete revocation + cross-learner isolation), test/db.test.js (setLearnerVoiceUsage CAS unit tests), test/voice.test.js (real concurrent-mint Promise.all test — verified to fail against the pre-fix code, both mints landing 200 and overshooting the cap). 168 -> 183 worker tests, all green; pytest stays 568 passed / 12 skipped. README.md and tools/launch-gate/ docs updated to match (test count, revocation model, atomic-booking behavior). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- tools/launch-gate/README.md | 2 +- tools/launch-gate/consent_walk.mjs | 2 +- workers/learn-api/README.md | 66 ++++++++--- workers/learn-api/src/auth.js | 28 +++++ workers/learn-api/src/db.js | 66 ++++++++--- workers/learn-api/src/index.js | 107 ++++++++++++++++-- workers/learn-api/src/session.js | 5 +- workers/learn-api/test/auth.test.js | 78 +++++++++++++ workers/learn-api/test/db.test.js | 112 +++++++++++++++++++ workers/learn-api/test/export-delete.test.js | 69 ++++++++++++ workers/learn-api/test/helpers.js | 20 +++- workers/learn-api/test/voice.test.js | 44 ++++++++ 12 files changed, 553 insertions(+), 46 deletions(-) create mode 100644 workers/learn-api/test/auth.test.js diff --git a/tools/launch-gate/README.md b/tools/launch-gate/README.md index 514b051..8d17ba8 100644 --- a/tools/launch-gate/README.md +++ b/tools/launch-gate/README.md @@ -158,7 +158,7 @@ in two places so the pre/post-deploy story is legible: The **authed** consent/approval/delete/voice flows cannot be reproduced against LIVE prod (no mintable prod session), so the Worker's own unit suite -(`cd workers/learn-api && npm test`, 168 tests) is run as a `run.sh` step and is +(`cd workers/learn-api && npm test`, 183 tests) is run as a `run.sh` step and is the authoritative proof of the zero-inference counting, consent ordering, and revocation invariants — `consent_walk.mjs` LOCAL mode re-proves them at the HTTP layer. diff --git a/tools/launch-gate/consent_walk.mjs b/tools/launch-gate/consent_walk.mjs index 7ddf804..f74abd4 100644 --- a/tools/launch-gate/consent_walk.mjs +++ b/tools/launch-gate/consent_walk.mjs @@ -24,7 +24,7 @@ // their markers. Against TODAY's pre-uplift prod these FAIL (routes/pages // 404) — that failure IS the h17 baseline; after the supervised deploy the // same probes pass. The authed flows stay proven by the LOCAL run above and -// by the Worker's own 168 unit tests (run as a separate run.sh step). +// by the Worker's own 183 unit tests (run as a separate run.sh step). // // Emits one NDJSON line {audience, check, status, detail} per check to // $LAUNCH_GATE_RESULTS (folded into report.py's table); exits non-zero on any diff --git a/workers/learn-api/README.md b/workers/learn-api/README.md index 83709b7..ed62a72 100644 --- a/workers/learn-api/README.md +++ b/workers/learn-api/README.md @@ -59,11 +59,23 @@ whole-learner deletion is the only erasure path there is.** The `records` ledger stays append-only for normal operation — there is no update-a-row or delete-one-row API anywhere in this Worker — but `POST /api/delete` hard- deletes every row a learner has (`learners`, `records`, `consents`) in one D1 -batch (`src/db.js#deleteLearnerData`) and revokes the *current* session (KV -tombstone) at the route layer, the same mechanism `handleLogout` uses. h3 is -proven literally: after delete, a D1 query finds **no row in any of the -three tables** for that `github_user_id`, and the pre-delete session token is -rejected on the very next authed call. Unlike every other learner-scoped +batch (`src/db.js#deleteLearnerData`) and revokes **every session for that +uid, not only the one that called delete** (a Qodo review finding, fixed +alongside t7's original per-sid revoke): sessions are stateless signed +tokens and `/api/me`'s sliding refresh mints a fresh token without revoking +the old one, so a learner can hold several simultaneously valid sessions +(another browser tab, a CLI token, a second device). `handleDelete` writes +BOTH the per-sid `revoked:` tombstone `handleLogout` also uses AND a +per-uid `revoked_uid:` marker stamped with the delete's epoch second; +`src/auth.js#requireAuth` rejects any token for that uid whose `iat` +predates the marker (strict `<`, not `<=`, so a session minted in the same +epoch-second as the delete — e.g. an immediate resignup — is never +false-revoked; see that function's own comment). h3 is proven literally: +after delete, a D1 query finds **no row in any of the three tables** for +that `github_user_id`, and every pre-delete session token for that learner — +not just the one used to call delete — is rejected on the very next authed +call (`test/export-delete.test.js`, `test/auth.test.js`). Unlike every other +learner-scoped route, `POST /api/delete` runs on `requireAuth`, not `requireConsented` — a learner whose stored consent has gone stale (t6) must still be able to erase their data *without* being forced to re-accept terms they no longer agree to @@ -164,7 +176,7 @@ No third-party runtime deps: the Worker uses only Web-standard APIs (`fetch`, | GET | `/api/progress/:subject` | consented | Ledger-derived `progress.json`-shaped payload. Pending OR stale-version session: `403 consent_required` (t6 adds `reason` + `consent_required` to the body — see "Endpoint shapes"). | | POST | `/api/record` | consented | Validate the `recorded` shape and append it to the ledger. Pending OR stale-version session: `403 consent_required`. | | GET | `/api/export` | consented | Self-serve data export: the learner's identity row, every recorded result across **every subject**, and their full consent history, as one JSON document (t7). Pending OR stale-version session: `403 consent_required` — same gate as progress/record/tutor. | -| POST | `/api/delete` | session or pending | Self-serve whole-learner erasure — consent withdrawal (t7). Requires `{ "confirm": "" }` in the body. Deletes the learners/records/consents rows, revokes the **current** session (KV tombstone), clears the cookie. Deliberately reachable from a stale-consent (and even pending-consent) session — see "Endpoint shapes" below for why. | +| POST | `/api/delete` | session or pending | Self-serve whole-learner erasure — consent withdrawal (t7). Requires `{ "confirm": "" }` in the body. Deletes the learners/records/consents rows, revokes **every session for that uid** (both the per-sid KV tombstone and a per-uid `revoked_uid:` marker — "delete logs you out everywhere," not just the current device), clears the cookie. Deliberately reachable from a stale-consent (and even pending-consent) session — see "Endpoint shapes" below for why. | | POST | `/api/tutor` | approved | Broker: forward to `INFERENCE_URL` (Bedrock Converse in production — see "For t15"). Pending OR stale-version session: `403 consent_required`; consented but not admin-approved: `403 approval_required` (t9) — zero inference calls either way. | | POST | `/api/voice/token` | approved | Mint a short-lived voice token for the serverless bridge (t16) — same learner-side gates as `/api/tutor`, in the same order, plus the per-learner monthly voice budget (`429 voice_budget_exhausted`). `503 not_configured` until `VOICE_BRIDGE_URL` + `VOICE_TOKEN_SECRET` are set. See "For t16" below. | | POST | `/api/me/visibility` | consented | Set the caller's OWN `visibility` (`private` \| `public`) in their `state` blob (t8). `400 invalid_visibility` for anything else. Pending OR stale-version session: `403 consent_required` — same gate as progress/record/export. | @@ -519,8 +531,11 @@ POST /api/delete (requireAuth — pending, stale, OR curren Body: { "confirm": "" } -> 200 { ok: true, status: "deleted", deleted: { records: , consents: , learners: } } - // + Set-Cookie clearing `session`; the token used to call this is - // immediately revoked (KV tombstone) — reuse it anywhere -> 401. + // + Set-Cookie clearing `session`; EVERY session for this uid is + // immediately revoked — the token used to call this (KV + // `revoked:` tombstone) AND every other still-live session + // for the same learner (KV `revoked_uid:` marker, checked in + // auth.js#requireAuth) — reuse ANY of them anywhere -> 401. -> 400 { error: "confirmation_required", ... } // confirm missing/wrong/absent body -> 401 // signed out ``` @@ -722,8 +737,16 @@ equal to the SAM stack's `MaxSessionSeconds`) against `learners.state.voice_usage = { month: "YYYY-MM", seconds_minted: n }`, and the mint that would push the month past `VOICE_MONTHLY_SECONDS_CAP` (default 1800 s = 30 min = 6 max-length sessions) is refused with `429` and -books nothing. Month rollover is free: a stale stored month reads as zero -(no cron, no migration). **This caps MINTED session-seconds — intent, the +books nothing. **The check-then-book is atomic** (a Qodo review finding, +fixed): `src/db.js#setLearnerVoiceUsage` is a compare-and-swap on the +learner row's `updated_at`, not a plain read-then-write, so two concurrent +mints can never both read the same `used`, both pass the cap check, and +both write — `src/index.js#bookVoiceUsage` retries (bounded, 3 attempts) +against a fresh read on a lost CAS and refuses the mint rather than risk an +unmetered token if it still can't secure a booking. Proven by +`test/db.test.js`'s direct CAS tests and `test/voice.test.js`'s real +concurrent-request test. Month rollover is free: a stale stored month reads +as zero (no cron, no migration). **This caps MINTED session-seconds — intent, the worst case that every minted token is fully used — not actual streamed seconds.** A learner who hangs up after 10 s still spent a 300 s booking. Actual per-session length and global concurrency are enforced by the bridge @@ -770,7 +793,7 @@ until the supervised deploy. Operator runbook for that step: node --test ``` -168 tests cover session sign/verify/expiry, `recorded` validation (including the +183 tests cover session sign/verify/expiry, `recorded` validation (including the `score`/`grade`/`points` rejection), the full record round-trip, per-learner ledger isolation, web + device OAuth flows, the consent gate (zero D1 writes on both unconsented sign-in paths, the pending-session 403 wall, accept ordering — @@ -783,10 +806,14 @@ re-accept → 200 cycle, and `/api/me`'s additive `reconsent_required` reporting), the export + delete gate (`test/export-delete.test.js`: a consented session's export spans every subject and its full consent history, a pending session gets `403` with literally nothing to export, -delete erases all three tables and revokes the session so the old token -403→401s on every subsequent call, a stale-consent session can still delete -without re-accepting, isolation — one learner's deletion never touches -another's rows or their ability to keep appending — and the full +delete erases all three tables and revokes **every** session for that uid +(not just the one that called delete — a Qodo review finding; `test/auth.test.js` +pins the underlying `requireAuth` boundary deterministically, including the +same-epoch-second edge case a delete-then-immediate-resignup can hit) so +every old token for that learner 403→401s on every subsequent call, a +stale-consent session can still delete without re-accepting, isolation — one +learner's deletion never touches another's rows, sessions, or their ability +to keep appending — and the full delete→sign-in-again→pending→re-accept→empty-ledger cycle), and — critically — that a signed-out `/api/tutor` request returns `401` with **zero** inference calls. The roles + visibility gate (`test/admin.test.js`, @@ -813,8 +840,13 @@ callers get 401/403/403/403 with **no token in any body**, the approval 403 fires before the config 503, the happy-path token's claim set and signature are pinned to `infra/voice_bridge/tokens.py`'s contract (including the committed cross-language fixture, byte-for-byte), and the monthly budget -books per mint, refuses without overshoot, rolls over by month, and merges -into `learners.state` without clobbering other keys. The Converse +books per mint, refuses without overshoot, rolls over by month, merges into +`learners.state` without clobbering other keys, and — a Qodo review finding, +fixed — cannot be double-booked by two real concurrent mint requests racing +past the same pre-state (`test/db.test.js` pins the underlying +compare-and-swap primitive in `setLearnerVoiceUsage` deterministically; +`test/voice.test.js` proves the route wires it up correctly under actual +`Promise.all` contention). The Converse pass-through (`test/tutor-converse.test.js`, t15, additive) proves the unchanged broker carries a native Bedrock Converse payload verbatim — plus exactly the `learner` stamp — and relays the Converse response shape and diff --git a/workers/learn-api/src/auth.js b/workers/learn-api/src/auth.js index cf46015..dfeb6f8 100644 --- a/workers/learn-api/src/auth.js +++ b/workers/learn-api/src/auth.js @@ -48,6 +48,34 @@ export async function requireAuth(request, env) { ); } } + // Per-uid revocation (Qodo review finding, BUG 1): a per-sid tombstone + // alone only kills the ONE token that called logout/delete. Sessions are + // stateless signed tokens and /api/me's sliding refresh mints a new token + // WITHOUT revoking the old one, so a learner can hold several valid + // sessions at once (web + CLI + another browser tab). POST /api/delete + // (src/index.js#handleDelete) stamps `revoked_uid:` with the epoch + // SECOND deletion ran, so this check rejects EVERY session for that uid + // issued before it — "delete logs me out everywhere" — not just the sid + // that called delete. + // + // Strict `<`, not `<=`: a token whose `iat` lands in the exact same + // epoch-second as the marker must NOT be rejected. That boundary matters + // in practice — signing back in immediately after a self-delete (the + // documented delete -> pending-consent -> re-accept flow) mints a brand + // new session whose `iat` can legitimately equal the delete second at + // this granularity; only tokens issued strictly BEFORE the marker are the + // ones delete needs to invalidate. + if (env.SESSIONS && payload.uid && typeof payload.iat === "number") { + const revokedAt = await env.SESSIONS.get(`revoked_uid:${payload.uid}`); + if (revokedAt !== null && payload.iat < Number(revokedAt)) { + throw new HttpError( + 401, + "session_revoked", + "This session was signed out (account data was deleted).", + "Sign in again to obtain a fresh session.", + ); + } + } return payload; } diff --git a/workers/learn-api/src/db.js b/workers/learn-api/src/db.js index 43b4b8e..28c5df8 100644 --- a/workers/learn-api/src/db.js +++ b/workers/learn-api/src/db.js @@ -35,10 +35,17 @@ export async function upsertLearner(env, learner) { .run(); } -/** Read a learner's identity + state, or null if unknown. */ +/** + * Read a learner's identity + state, or null if unknown. Carries `updated_at` + * (task t16 follow-up, Qodo BUG 3) alongside the usual fields — not because + * ordinary callers need it, but because it's the version stamp + * setLearnerVoiceUsage's compare-and-swap write needs, and re-reading it + * separately for that one caller would defeat the "read once, CAS against + * exactly what you read" property the fix relies on. + */ export async function getLearner(env, uid) { const row = await env.DB.prepare( - `SELECT github_user_id, display_name, state FROM learners WHERE github_user_id = ?`, + `SELECT github_user_id, display_name, state, updated_at FROM learners WHERE github_user_id = ?`, ) .bind(String(uid)) .first(); @@ -49,7 +56,12 @@ export async function getLearner(env, uid) { } catch { state = {}; } - return { github_user_id: row.github_user_id, display_name: row.display_name, state }; + return { + github_user_id: row.github_user_id, + display_name: row.display_name, + state, + updated_at: row.updated_at, + }; } /** Append one recorded result to the ledger. Insert-only. */ @@ -283,22 +295,44 @@ export async function setLearnerApproved(env, uid, approved) { /** * Persist a learner's monthly voice-budget meter inside their `state` blob - * (task t16): `voice_usage = { month: "2026-07", seconds_minted: n }`. Same - * no-schema-change, read-then-write-merge pattern as setLearnerVisibility / - * setLearnerApproved directly above — an absent key means "nothing minted", - * so pre-t16 rows need no migration and month rollover is just the reader - * (src/voice.js#voiceSecondsUsed) treating a stale month as zero. POLICY is - * the caller's job: the cap check lives in index.js#handleVoiceToken, not - * here (mirroring how the c20 precondition stays out of setLearnerApproved). + * (task t16): `voice_usage = { month: "2026-07", seconds_minted: n }`. + * + * Unlike setLearnerVisibility / setLearnerApproved's plain read-then-write, + * this is a COMPARE-AND-SWAP on the learner row's `updated_at` (Qodo review + * finding, BUG 3): the naive read-then-write here let two concurrent voice + * mints both read the same `used`, both pass index.js#handleVoiceToken's cap + * check, and both overwrite the same counter — silently exceeding + * VOICE_MONTHLY_SECONDS_CAP. `baseState` is the state blob the CALLER + * already read (index.js#bookVoiceUsage) — merged in here rather than + * re-read, so the write is scoped to exactly the row version the cap was + * checked against, not whatever happens to be there by the time this runs. + * `expectedUpdatedAt` is REQUIRED (no unconditional fallback): the `UPDATE + * ... WHERE updated_at = ?` only touches the row if it still matches, so a + * concurrent winner's write invalidates every loser's stamp and their CAS + * simply reports `ok: false` instead of clobbering the winner's booking. + * + * An absent `voice_usage` key means "nothing minted" (unchanged from + * before), so pre-t16 rows still need no migration and month rollover is + * still just the reader (src/voice.js#voiceSecondsUsed) treating a stale + * month as zero. POLICY (the cap check itself, and the bounded retry-on- + * conflict loop) is the caller's job — see index.js#bookVoiceUsage — mirroring + * how the c20 precondition stays out of setLearnerApproved. + * + * @returns {Promise<{ ok: boolean, usage: object }>} `ok: false` means the + * row's `updated_at` no longer matched `expectedUpdatedAt` — another + * booking won the race; the caller must re-read and retry, never treat + * this as a successful booking. */ -export async function setLearnerVoiceUsage(env, uid, usage) { - const learner = await getLearner(env, uid); - const state = { ...(learner ? learner.state : {}), voice_usage: usage }; +export async function setLearnerVoiceUsage(env, uid, usage, baseState, expectedUpdatedAt) { + const state = { ...(baseState || {}), voice_usage: usage }; const now = new Date().toISOString(); - await env.DB.prepare(`UPDATE learners SET state = ?, updated_at = ? WHERE github_user_id = ?`) - .bind(JSON.stringify(state), now, String(uid)) + const res = await env.DB.prepare( + `UPDATE learners SET state = ?, updated_at = ? WHERE github_user_id = ? AND updated_at = ?`, + ) + .bind(JSON.stringify(state), now, String(uid), expectedUpdatedAt) .run(); - return usage; + const changed = !!(res && res.meta && res.meta.changes); + return { ok: changed, usage }; } /** diff --git a/workers/learn-api/src/index.js b/workers/learn-api/src/index.js index d00786d..0fa3341 100644 --- a/workers/learn-api/src/index.js +++ b/workers/learn-api/src/index.js @@ -602,6 +602,7 @@ async function handleDelete(request, env) { } const deleted = await deleteLearnerData(env, session.uid); await revokeSession(env, session); + await revokeAllSessionsForUid(env, session.uid); return jsonResponse( 200, { ok: true, status: "deleted", deleted }, @@ -688,9 +689,19 @@ async function handleTutor(request, env, ctx) { // fully used — not actual streamed seconds; per-session length and global // concurrency are enforced by the bridge itself, and tightening this meter // to actual usage needs bridge->worker usage reporting (a follow-up, see -// README). Read-then-write like every state-blob update; a lost race between -// two concurrent mints can under-count by one booking at worst, acceptable -// for a budget whose real backstop is the bridge's own caps + AWS Budgets. +// README). +// +// Atomic booking (Qodo review finding, BUG 3): a plain read-then-write here +// let two concurrent mints both read the same `used`, both pass the cap +// check, and both write — silently exceeding the cap (not just an +// under-count: TWO full sessions could be minted against ONE booking's +// headroom). bookVoiceUsage below closes that with a compare-and-swap on +// the learner row's `updated_at` (db.js#setLearnerVoiceUsage) plus bounded +// retries: a lost CAS means another mint booked first, so it re-reads and +// re-checks the cap against the fresh total rather than retrying the stale +// write. If it still can't secure a booking after retrying, the mint is +// refused — the real backstop for genuine abuse stays the bridge's own +// per-session/concurrency caps plus the AWS Budgets alarm, same as before. async function handleVoiceToken(request, env) { const session = await requireConsented(request, env); const learner = await getLearner(env, session.uid); @@ -728,10 +739,29 @@ async function handleVoiceToken(request, env) { } const { token, payload } = await mintVoiceToken(env, session.uid, { now }); - // Book AFTER the mint succeeded, BEFORE the token leaves the Worker — a - // failed write must not hand out unmetered tokens. - const secondsMinted = used + maxSessionSeconds; - await setLearnerVoiceUsage(env, session.uid, { month, seconds_minted: secondsMinted }); + // Book AFTER the mint succeeded, BEFORE the token leaves the Worker — + // ATOMICALLY. bookVoiceUsage re-validates the cap against a fresh read on + // every retry, so this can only succeed at most once per booking's worth + // of headroom no matter how many requests race here. A failed booking + // (contention exhausted the retries, or the cap turned out to already be + // gone by the time this mint's turn came) must not hand out an unmetered + // token — the token minted above is simply never returned below. + const booking = await bookVoiceUsage(env, session.uid, { + month, + maxSessionSeconds, + monthlyCap, + learner, + used, + }); + if (!booking.ok) { + throw new HttpError( + 429, + "voice_budget_exhausted", + "Your monthly voice allowance is used up.", + "The meter resets at the start of next month (UTC). Text tutoring is unaffected.", + { month, monthly_seconds_cap: monthlyCap, monthly_seconds_used: booking.used }, + ); + } return jsonResponse(200, { token, @@ -742,12 +772,52 @@ async function handleVoiceToken(request, env) { limits: { max_session_seconds: maxSessionSeconds, monthly_seconds_cap: monthlyCap, - monthly_seconds_used: secondsMinted, - monthly_seconds_remaining: monthlyCap - secondsMinted, + monthly_seconds_used: booking.secondsMinted, + monthly_seconds_remaining: monthlyCap - booking.secondsMinted, }, }); } +// Bounded retries for the atomic cap-check-and-increment (Qodo review +// finding, BUG 3). Each attempt re-reads the learner (after the first, which +// reuses the caller's already-fresh read to avoid a redundant D1 hit in the +// common uncontended case), rechecks the cap against THAT read, and only +// then attempts the compare-and-swap write — so a lost race always retries +// against up-to-date data instead of blindly re-sending a now-stale write. +// 3 attempts is generous for this route's actual concurrency (one learner +// rarely mints two tokens within milliseconds of each other) while still +// bounding the work one request will do under contention; exhausting all 3 +// without a booking refuses the mint rather than risk an unmetered token. +const MAX_VOICE_BOOKING_ATTEMPTS = 3; + +async function bookVoiceUsage(env, uid, { month, maxSessionSeconds, monthlyCap, learner, used }) { + let currentLearner = learner; + let currentUsed = used; + for (let attempt = 1; attempt <= MAX_VOICE_BOOKING_ATTEMPTS; attempt += 1) { + if (attempt > 1) { + currentLearner = await getLearner(env, uid); + currentUsed = voiceSecondsUsed(currentLearner, month); + } + if (currentUsed + maxSessionSeconds > monthlyCap) { + return { ok: false, used: currentUsed }; + } + const secondsMinted = currentUsed + maxSessionSeconds; + const result = await setLearnerVoiceUsage( + env, + uid, + { month, seconds_minted: secondsMinted }, + currentLearner ? currentLearner.state : {}, + currentLearner ? currentLearner.updated_at : undefined, + ); + if (result.ok) { + return { ok: true, secondsMinted }; + } + // CAS lost — another mint's booking won the row first; loop back and + // re-read so the next attempt's cap check sees its effect. + } + return { ok: false, used: currentUsed }; +} + // --- roles + visibility (spec c12/h4, task t8) ------------------------------ const VISIBILITY_VALUES = new Set(["private", "public"]); @@ -923,6 +993,25 @@ async function revokeSession(env, session) { } } +// Per-uid revocation marker (Qodo review finding, BUG 1): "delete logs me +// out everywhere," not just on the session that clicked delete. Stamps +// `revoked_uid:` with the CURRENT epoch second; auth.js#requireAuth +// rejects any token for this uid whose `iat` predates it (strict `<` — see +// that function's own comment for why equal-second tokens, e.g. an +// immediate resignup, must survive). TTL mirrors revokeSession's own +// per-sid tombstone margin (DEFAULT_TTL_SECONDS * 2) — comfortably past the +// longest TTL any session for this uid could have carried, so the marker +// outlives every token it needs to catch. Called ONLY from handleDelete — +// an ordinary logout/consent-decline still revokes just its own sid, so a +// learner's other sessions are unaffected by an everyday sign-out. +async function revokeAllSessionsForUid(env, uid) { + if (env.SESSIONS && uid) { + await env.SESSIONS.put(`revoked_uid:${uid}`, String(nowSeconds()), { + expirationTtl: DEFAULT_TTL_SECONDS * 2, + }); + } +} + // db.js stores `records.recorded` as a JSON TEXT column (see insertRecord); // the export route (t7) hands the learner back the parsed object rather // than a double-encoded string. Defensive like getLearner's `state` parse: diff --git a/workers/learn-api/src/session.js b/workers/learn-api/src/session.js index f7073bc..47f7241 100644 --- a/workers/learn-api/src/session.js +++ b/workers/learn-api/src/session.js @@ -31,13 +31,16 @@ export const PENDING_CONSENT_TTL_SECONDS = 600; // 10 minutes. * Mint a signed session token for a learner. * @param {object} [opts] * @param {boolean} [opts.pendingConsent] mark the token pending-consent (c19). + * @param {number} [opts.now] override `iat` (unix seconds) — deterministic + * tests only (mirrors voice.js#mintVoiceToken's own `opts.now`); production + * callers never pass it and get the real clock. * @returns {{ token: string, payload: object }} */ export async function issueSession(env, learner, ttlSeconds = DEFAULT_TTL_SECONDS, opts = {}) { if (!env || !env.SESSION_SECRET) { throw new Error("SESSION_SECRET is not configured"); } - const iat = nowSeconds(); + const iat = opts.now == null ? nowSeconds() : Math.floor(opts.now); const payload = { v: 1, uid: String(learner.uid), diff --git a/workers/learn-api/test/auth.test.js b/workers/learn-api/test/auth.test.js new file mode 100644 index 0000000..5b247f8 --- /dev/null +++ b/workers/learn-api/test/auth.test.js @@ -0,0 +1,78 @@ +// Unit tests for src/auth.js#requireAuth's per-uid revocation marker +// (Qodo review finding, BUG 1): POST /api/delete must invalidate EVERY +// session for a uid, not just the one that called it. Exercised directly +// against the KV stub with a controlled `iat` (via session.js#issueSession's +// `opts.now`, mirroring voice.js#mintVoiceToken's own test-only override) so +// the reject/keep boundary is deterministic — no dependency on real +// wall-clock timing between two mints, unlike the higher-level, full-route +// scenarios in test/export-delete.test.js. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { requireAuth } from "../src/auth.js"; +import { issueSession } from "../src/session.js"; +import { nowSeconds } from "../src/util.js"; +import { makeEnv, authedRequest } from "./helpers.js"; + +const BASE = "https://learn-api.example"; +// A fixed anchor near "now" so `exp = iat + ttl` never lands in the past — +// verifySession would 401 a token whose `exp` has already elapsed, so every +// `iat` used below is offset from THIS, not from an arbitrary small epoch. +const NOW = nowSeconds(); + +async function tokenAt(env, uid, iatOffset) { + const { token } = await issueSession(env, { uid, name: "Ada" }, 3600, { now: NOW + iatOffset }); + return token; +} + +test("requireAuth: a token issued BEFORE a uid's revoked_uid marker is rejected", async () => { + const env = makeEnv(); + const token = await tokenAt(env, "42", -100); + await env.SESSIONS.put("revoked_uid:42", String(NOW - 50)); + await assert.rejects( + () => requireAuth(authedRequest(`${BASE}/api/me`, token), env), + (err) => err.status === 401 && err.code === "session_revoked", + ); +}); + +test("requireAuth: a token issued AT the same epoch second as the marker is NOT rejected", async () => { + // Boundary case: iat === revoked epoch must resolve in favor of the token + // — this is what keeps a resignup landing in the exact same wall-clock + // second as a prior delete working (see auth.js's own comment). + const env = makeEnv(); + const token = await tokenAt(env, "42", -50); + await env.SESSIONS.put("revoked_uid:42", String(NOW - 50)); + const payload = await requireAuth(authedRequest(`${BASE}/api/me`, token), env); + assert.equal(payload.uid, "42"); +}); + +test("requireAuth: a token issued AFTER the marker is NOT rejected", async () => { + const env = makeEnv(); + const token = await tokenAt(env, "42", -10); + await env.SESSIONS.put("revoked_uid:42", String(NOW - 50)); + const payload = await requireAuth(authedRequest(`${BASE}/api/me`, token), env); + assert.equal(payload.uid, "42"); +}); + +test("requireAuth: no revoked_uid marker at all -> unaffected", async () => { + const env = makeEnv(); + const token = await tokenAt(env, "42", -100); + const payload = await requireAuth(authedRequest(`${BASE}/api/me`, token), env); + assert.equal(payload.uid, "42"); +}); + +test("requireAuth: a DIFFERENT uid's marker never affects this uid's token", async () => { + const env = makeEnv(); + const token = await tokenAt(env, "42", -100); + await env.SESSIONS.put("revoked_uid:99", String(NOW - 50)); + const payload = await requireAuth(authedRequest(`${BASE}/api/me`, token), env); + assert.equal(payload.uid, "42"); +}); + +test("requireAuth: no SESSIONS binding at all -> the per-uid check is skipped, not thrown", async () => { + const env = { SESSION_SECRET: "x" }; + const token = await tokenAt(env, "42", -100); + const payload = await requireAuth(authedRequest(`${BASE}/api/me`, token), env); + assert.equal(payload.uid, "42"); +}); diff --git a/workers/learn-api/test/db.test.js b/workers/learn-api/test/db.test.js index 49f754f..00ae3e8 100644 --- a/workers/learn-api/test/db.test.js +++ b/workers/learn-api/test/db.test.js @@ -16,6 +16,7 @@ import { listConsents, listAllLearners, setLearnerVisibility, + setLearnerVoiceUsage, getLearner, } from "../src/db.js"; import { makeEnv } from "./helpers.js"; @@ -326,6 +327,117 @@ test("setLearnerVisibility: is per-learner isolated", async () => { assert.equal(Object.prototype.hasOwnProperty.call(linus.state, "visibility"), false); }); +// --- getLearner: updated_at (t16 follow-up, Qodo BUG 3) -------------------- + +test("getLearner exposes updated_at — the version stamp the voice-budget CAS write needs", async () => { + const env = makeEnv(); + env.DB.learners.set("42", { + github_user_id: "42", + display_name: "Ada", + state: "{}", + updated_at: "2026-01-01T00:00:00.000Z", + }); + const learner = await getLearner(env, "42"); + assert.equal(learner.updated_at, "2026-01-01T00:00:00.000Z"); +}); + +// --- setLearnerVoiceUsage: compare-and-swap (Qodo BUG 3) -------------------- +// +// The voice-budget race (index.js#handleVoiceToken read `used`, checked the +// cap, then wrote via a plain read-then-write): two concurrent mints could +// both pass the check and both write, exceeding VOICE_MONTHLY_SECONDS_CAP. +// The fix makes the WRITE itself conditional on the row not having changed +// since the caller read it — these tests drive that primitive directly, +// independent of any real concurrency/timing, the same way session.test.js +// drives session.js directly rather than through the route layer. + +test("setLearnerVoiceUsage: a matching expectedUpdatedAt writes and reports ok:true", async () => { + const env = makeEnv(); + const stamp = "2026-07-01T00:00:00.000Z"; + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}", updated_at: stamp }); + + const result = await setLearnerVoiceUsage( + env, + "42", + { month: "2026-07", seconds_minted: 300 }, + {}, + stamp, + ); + assert.equal(result.ok, true); + const learner = await getLearner(env, "42"); + assert.equal(learner.state.voice_usage.seconds_minted, 300); + assert.notEqual(learner.updated_at, stamp, "a successful write stamps a NEW updated_at"); +}); + +test("setLearnerVoiceUsage: a stale expectedUpdatedAt is refused (ok:false) and writes NOTHING — the CAS primitive the race fix relies on", async () => { + const env = makeEnv(); + const original = "2026-01-01T00:00:00.000Z"; + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}", updated_at: original }); + + // Two concurrent bookers who both read the SAME `updated_at` — simulated + // directly, no timing/Promise.all needed: this IS the exact primitive + // index.js#bookVoiceUsage's retry loop depends on. + const first = await setLearnerVoiceUsage( + env, + "42", + { month: "2026-07", seconds_minted: 300 }, + {}, + original, + ); + assert.equal(first.ok, true, "the first writer to reach D1 wins"); + + const second = await setLearnerVoiceUsage( + env, + "42", + { month: "2026-07", seconds_minted: 600 }, + {}, + original, // still holding the now-STALE updated_at + ); + assert.equal(second.ok, false, "the second writer loses the race — its expected version is gone"); + + // The loser's write never landed: the learner reflects ONLY the winner's + // booking (300s), never the loser's (600s) and never both summed/clobbered. + const learner = await getLearner(env, "42"); + assert.equal(learner.state.voice_usage.seconds_minted, 300); +}); + +test("setLearnerVoiceUsage: merges the usage write into the passed baseState, same merge contract as setLearnerVisibility/setLearnerApproved", async () => { + const env = makeEnv(); + const stamp = "2026-07-01T00:00:00.000Z"; + env.DB.learners.set("42", { + github_user_id: "42", + display_name: "Ada", + state: JSON.stringify({ approved: true, visibility: "public" }), + updated_at: stamp, + }); + const learner = await getLearner(env, "42"); + + const result = await setLearnerVoiceUsage( + env, + "42", + { month: "2026-07", seconds_minted: 300 }, + learner.state, + learner.updated_at, + ); + assert.equal(result.ok, true); + const updated = await getLearner(env, "42"); + assert.equal(updated.state.approved, true, "unrelated state fields survive the usage write"); + assert.equal(updated.state.visibility, "public"); + assert.equal(updated.state.voice_usage.seconds_minted, 300); +}); + +test("setLearnerVoiceUsage: is per-learner isolated", async () => { + const env = makeEnv(); + const stamp = "2026-07-01T00:00:00.000Z"; + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}", updated_at: stamp }); + env.DB.learners.set("99", { github_user_id: "99", display_name: "Linus", state: "{}", updated_at: stamp }); + + await setLearnerVoiceUsage(env, "42", { month: "2026-07", seconds_minted: 300 }, {}, stamp); + + const linus = await getLearner(env, "99"); + assert.equal(Object.prototype.hasOwnProperty.call(linus.state, "voice_usage"), false); +}); + test("getConsent: same-millisecond re-consent tie resolves to the newest row (rowid tiebreak)", async () => { const env = makeEnv(); const now = "2026-07-11T10:00:00.000Z"; diff --git a/workers/learn-api/test/export-delete.test.js b/workers/learn-api/test/export-delete.test.js index ca06e61..0842f1e 100644 --- a/workers/learn-api/test/export-delete.test.js +++ b/workers/learn-api/test/export-delete.test.js @@ -18,6 +18,7 @@ import assert from "node:assert/strict"; import worker from "../src/index.js"; import { GH } from "../src/github.js"; import { TERMS_VERSION } from "../src/terms.js"; +import { nowSeconds } from "../src/util.js"; import { makeEnv, makeFetchStub, @@ -226,6 +227,74 @@ test("delete: a stale-consent (reconsent_required) session can STILL erase its d assert.equal(env.DB.consents.some((c) => c.github_user_id === "42"), false); }); +test("delete: revokes EVERY session for the uid, not just the one that called delete (Qodo BUG 1)", async () => { + const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + // Minted with an `iat` a few seconds in the past (relative to the delete + // call below, which stamps revoked_uid with the REAL current second) — + // deliberately, so this test's before/after ordering is not left to + // real-wall-clock luck: two mints and a delete inside one fast test + // function virtually always land in the SAME floored epoch-second, and + // requireAuth's strict `<` (see auth.js's own comment on why it must be + // strict, not `<=`) treats equal-second tokens as NOT revoked. A handful + // of seconds of separation makes this test exercise the actual + // before-the-marker case deterministically instead of by chance. + const past = nowSeconds() - 5; + const { token: tokenA } = await mintToken(env, { uid: "42", name: "Ada" }, 3600, { now: past }); + // A second, independent session for the SAME learner — another device, + // browser tab, or a CLI token minted separately. Stateless tokens mean + // both are simultaneously valid; the bug was that only tokenA (the one + // used to call delete) got revoked. + const { token: tokenB } = await mintToken(env, { uid: "42", name: "Ada" }, 3600, { now: past }); + + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, tokenA))).status, 200); + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, tokenB))).status, 200); + + const del = await call(env, deleteReq(tokenA, "42")); + assert.equal(del.status, 200); + + // The session that called delete is rejected (pre-existing per-sid behavior)... + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, tokenA))).status, 401); + // ...and so is the OTHER, still-unexpired session for the same uid — the + // whole point of this fix: delete logs the learner out everywhere, not + // just on the device that clicked delete. + const other = await call(env, authedRequest(`${BASE}/api/me`, tokenB)); + assert.equal(other.status, 401); + assert.equal((await other.json()).error, "session_revoked"); +}); + +test("delete: a DIFFERENT learner's session is unaffected — per-uid revocation is per-uid, not global", async () => { + const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); + seedConsent(env, "99", TERMS_VERSION); + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + env.DB.learners.set("99", { github_user_id: "99", display_name: "Linus", state: "{}" }); + const { token: adaTok } = await mintToken(env, { uid: "42", name: "Ada" }); + const { token: linusTok } = await mintToken(env, { uid: "99", name: "Linus" }); + + const del = await call(env, deleteReq(adaTok, "42")); + assert.equal(del.status, 200); + + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, linusTok))).status, 200); +}); + +test("delete: a DIFFERENT (non-deleted) learner's session minted AFTER the delete still works — refresh/resignup is unaffected", async () => { + const env = makeEnv(); + seedConsent(env, "42", TERMS_VERSION); + seedConsent(env, "99", TERMS_VERSION); + env.DB.learners.set("42", { github_user_id: "42", display_name: "Ada", state: "{}" }); + env.DB.learners.set("99", { github_user_id: "99", display_name: "Linus", state: "{}" }); + const { token: adaTok } = await mintToken(env, { uid: "42", name: "Ada" }); + + await call(env, deleteReq(adaTok, "42")); + + // Linus signs in fresh AFTER Ada's delete — his brand-new session (fresh + // iat) must not be touched by Ada's revoked_uid marker. + const { token: linusTok } = await mintToken(env, { uid: "99", name: "Linus" }); + assert.equal((await call(env, authedRequest(`${BASE}/api/me`, linusTok))).status, 200); +}); + test("delete: a pending-consent session is a documented no-op (nothing was ever written) and still revokes", async () => { const env = envWithGitHubUser({ id: 901, login: "neverwrote", name: "Never Wrote" }); const poll = await (await call(env, devicePollRequest())).json(); diff --git a/workers/learn-api/test/helpers.js b/workers/learn-api/test/helpers.js index b2651ca..8e59422 100644 --- a/workers/learn-api/test/helpers.js +++ b/workers/learn-api/test/helpers.js @@ -104,8 +104,26 @@ class D1Prepared { return { success: true, meta: { changes: 1, last_row_id: 0 } }; } if (this.sql.includes("update learners")) { - // setLearnerVisibility (t8): UPDATE learners SET state = ?, updated_at = ? WHERE github_user_id = ? this._logWrite("learners", "update"); + if (this.sql.includes("and updated_at")) { + // setLearnerVoiceUsage's compare-and-swap (BUG 3 fix): + // UPDATE learners SET state = ?, updated_at = ? + // WHERE github_user_id = ? AND updated_at = ? + // Only writes (and reports changes: 1) when the row's CURRENT + // updated_at still matches what the caller read — a stale expected + // value (another booking won first) reports changes: 0 and touches + // nothing, exactly like real D1's conditional UPDATE would. + const [state, updatedAt, uid, expectedUpdatedAt] = this.args; + const existing = this.db.learners.get(String(uid)); + const matches = !!existing && existing.updated_at === expectedUpdatedAt; + if (matches) { + existing.state = state; + existing.updated_at = updatedAt; + } + return { success: true, meta: { changes: matches ? 1 : 0 } }; + } + // setLearnerVisibility / setLearnerApproved (unconditional): + // UPDATE learners SET state = ?, updated_at = ? WHERE github_user_id = ? const [state, updatedAt, uid] = this.args; const existing = this.db.learners.get(String(uid)); if (existing) { diff --git a/workers/learn-api/test/voice.test.js b/workers/learn-api/test/voice.test.js index e21e584..848180e 100644 --- a/workers/learn-api/test/voice.test.js +++ b/workers/learn-api/test/voice.test.js @@ -284,6 +284,50 @@ test("budget: month rollover resets the meter — last month's exhaustion doesn' assert.equal(ada.state.voice_usage.seconds_minted, DEFAULT_VOICE_MAX_SESSION_SECONDS); }); +test("budget: two concurrent mints reading the same pre-state cannot both book past the cap (Qodo review finding, BUG 3)", async () => { + // The race the naive read-then-write allowed: two requests both call + // getLearner, both see the SAME `used`, both pass "used + max <= cap", + // and (pre-fix) both write — landing at 2100s of bookings against an + // 1800s cap. This fires two REAL concurrent /api/voice/token requests via + // Promise.all (same cooperative-scheduling interleaving two simultaneous + // Worker invocations would see — this Worker has no other concurrency + // primitive) rather than hand-simulating the interleave, so it exercises + // the full handleVoiceToken -> bookVoiceUsage -> setLearnerVoiceUsage + // path end to end. test/db.test.js's setLearnerVoiceUsage CAS tests pin + // the underlying primitive deterministically; this proves the route wires + // it up correctly under actual contention. + const { env, token } = await voiceEnv(); + const month = new Date().toISOString().slice(0, 7); + // Exactly one more 300s booking fits (1500 + 300 = 1800 == the cap); if + // BOTH concurrent mints booked, the total would land at 2100 > 1800. + seedLearner(env, "42", "Ada", { + approved: true, + voice_usage: { month, seconds_minted: 1500 }, + }); + + const [resA, resB] = await Promise.all([call(env, voiceReq(token)), call(env, voiceReq(token))]); + const [bodyA, bodyB] = await Promise.all([resA.json(), resB.json()]); + const results = [ + { status: resA.status, body: bodyA }, + { status: resB.status, body: bodyB }, + ]; + + const statuses = results.map((r) => r.status).sort(); + assert.deepEqual(statuses, [200, 429], "exactly one of the two concurrent mints succeeds"); + + const ok = results.find((r) => r.status === 200); + const fail = results.find((r) => r.status === 429); + assert.ok(ok.body.token, "the winning mint gets a token"); + assert.equal(ok.body.limits.monthly_seconds_used, 1800); + assert.equal(fail.body.error, "voice_budget_exhausted"); + assert.equal("token" in fail.body, false, "the losing mint must never leak a token"); + + // The ledger lands EXACTLY at the cap, never past it — this is the + // assertion that would have failed before the fix (it would read 2100). + const ada = await getLearner(env, "42"); + assert.equal(ada.state.voice_usage.seconds_minted, 1800); +}); + test("budget bookkeeping merges into state — approved/visibility survive the usage write", async () => { const { env, token } = await voiceEnv(); seedLearner(env, "42", "Ada", { approved: true, visibility: "public", other_pref: "keep" }); From 4560221994a510f2e7b60909254e917725a07c77 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 18:56:39 +0300 Subject: [PATCH 24/24] docs: log the four Qodo/Sonar review fixes under 0.6.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40399c6..2c3f8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Launch-gate api-server.mjs no longer throws ERR_HTTP_HEADERS_SENT on Set-Cookie responses (consent accept / login / logout / delete), so those flows can be exercised end-to-end. - getConsent tiebreaks same-millisecond granted_at rows by rowid, removing a flaky re-consent race. +- Review fix (security): POST /api/delete now revokes EVERY session for the learner, not only the calling token — a per-uid revocation marker (`revoked_uid:`) that requireAuth checks, so "delete logs me out everywhere." +- Review fix (reliability): the monthly voice budget is now booked with a compare-and-swap retry on the learner row, so concurrent /api/voice/token mints can no longer both pass the cap check and exceed it. +- Review fix (performance): the voice-bridge Lambda's $connect concurrency gate queries session metadata under a constant partition key (Query COUNT) instead of a full-table Scan that read every frame item. +- Review fix (maintainability): the two new cloze conformance validators were refactored below SonarCloud's cognitive-complexity threshold (behavior-preserving helper extraction). ## [0.5.4] - 2026-07-11