diff --git a/.github/workflows/cloudflare-candidate.yml b/.github/workflows/cloudflare-candidate.yml
index 8f46388..1819c51 100644
--- a/.github/workflows/cloudflare-candidate.yml
+++ b/.github/workflows/cloudflare-candidate.yml
@@ -40,6 +40,8 @@ jobs:
//npm.pkg.github.com/:_authToken=${CAIL_PACKAGES_TOKEN}
NPMRC
bun install --frozen-lockfile
+ - name: Install the local audio validation oracle
+ run: bash .github/scripts/install-media-tools.sh ffmpeg
- name: Verify private identity and Gateway boundaries in Workerd
working-directory: cloudflare
run: bun run typecheck && bun run test
diff --git a/.github/workflows/yt-audio-candidate.yml b/.github/workflows/yt-audio-candidate.yml
new file mode 100644
index 0000000..cea4836
--- /dev/null
+++ b/.github/workflows/yt-audio-candidate.yml
@@ -0,0 +1,71 @@
+name: YouTube extractor candidate image
+
+on:
+ pull_request:
+ paths:
+ - 'replicate-yt-audio/**'
+ - 'scripts/lib/pin-check.mjs'
+ - '.github/workflows/yt-audio-candidate.yml'
+ push:
+ branches: [codex/stem-reliability-20261006]
+ paths:
+ - 'replicate-yt-audio/**'
+ - 'scripts/lib/pin-check.mjs'
+ - '.github/workflows/yt-audio-candidate.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: stem-youtube-image-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: CPU extractor build and offline smoke
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ env:
+ EXTRACTOR_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: '22.23.1'
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: '3.12.13'
+ - name: Verify native runner
+ run: |
+ set -euo pipefail
+ test "$(uname -s)" = Linux
+ test "$(uname -m)" = x86_64
+ test "$(docker info --format '{{.OSType}}/{{.Architecture}}')" = linux/x86_64
+ - name: Run free extractor fixtures
+ run: python3 -m unittest discover -s replicate-yt-audio -p 'test_*.py'
+ - name: Install pinned Cog without registry authentication
+ uses: replicate/setup-cog@8a5fea3c1818b6950dcf8027285f5a9b447c0c4a # v2
+ with:
+ cog-version: 'v0.23.0'
+ token: ''
+ install-cuda: false
+ - name: Build CPU image locally on the runner
+ working-directory: replicate-yt-audio
+ run: cog build --progress plain -t "stem-youtube-candidate:${EXTRACTOR_SOURCE_COMMIT}"
+ - name: Verify image dependencies, schema and setup without network access
+ run: >-
+ node replicate-yt-audio/verify-image.mjs
+ "stem-youtube-candidate:${EXTRACTOR_SOURCE_COMMIT}"
+ "$EXTRACTOR_SOURCE_COMMIT"
+ "$RUNNER_TEMP/yt-audio-candidate-evidence.json"
+ - name: Preserve sanitized image evidence
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: yt-audio-candidate-${{ github.event.pull_request.head.sha || github.sha }}
+ path: ${{ runner.temp }}/yt-audio-candidate-evidence.json
+ if-no-files-found: error
+ retention-days: 30
diff --git a/TODO.md b/TODO.md
index 9f61f81..7e129f9 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,7 +2,7 @@
Updated October 6, 2026. This is an implementation plan, not a release authorization or a claim that unchecked work is complete.
-**Execution update — October 6, 2026, 19:39 UTC:** the user has separately authorized implementing these three PR groups, publishing at most three actual draft PRs, and merging after an independent Astra High audit of the final commits. Implementation workers were started with explicit Astra Max settings, and a separate auditor with explicit Astra High settings; creation requests were accepted, but actual backend model/effort metadata is not independently exposed. Production deployment, database cutover, paid inference/provider jobs, credential changes and permission expansion remain outside that authorization. Portal `main` automatically releases changed Workers; separate deployment approval is pending. Stem's inspected GitHub target workflows check and dry-build; external auto-deployment remains unverified because the configured Cloudflare MCP sessions do not authorize the target account. No merges, deployments or remote database changes have run. Completion boxes remain unchecked until final-head evidence is recorded.
+**Execution update — October 6, 2026, 20:06 UTC:** the user has separately authorized implementing these three PR groups, publishing at most three actual draft PRs, and merging after an independent Astra High audit of the final commits. Implementation workers were started with explicit Astra Max settings, and a separate auditor with explicit Astra High settings; creation requests were accepted, but actual backend model/effort metadata is not independently exposed. Production deployment, database cutover, paid inference/provider jobs, credential changes and permission expansion remain outside that authorization. Portal `main` automatically releases changed Workers; separate deployment approval is pending. Stem's inspected GitHub target workflows check and dry-build; external auto-deployment remains unverified because the configured Cloudflare MCP sessions do not authorize the target account. No merges, deployments or remote database changes have run. The table records the dated implementation evidence. Full acceptance boxes remain open where live access, release authorization or unverified scenarios are still required; the linked PRs hold the current exact-head review and CI results.
**Resolved product decisions:** course visibility applies only to newly and explicitly assigned course work. Existing personal splits and private transcripts remain private, with no historical backfill or transcript import. Folders remain private or course-scoped. The Listening Guy allowance is 50 human inputs per student across courses per UTC day; failed work without a usable reply releases its reservation, while a delivered partial reply counts once. Split allowance settles against the UTC submission day. Reset removes conversation content; content-free accounting evidence may remain.
@@ -11,8 +11,8 @@ Updated October 6, 2026. This is an implementation plan, not a release authoriza
| Implementation checkpoint | Concrete evidence and remaining work |
| --- | --- |
| PR1 — [Portal draft #295](https://github.com/CUNY-AI-Lab/cail-tools-admission/pull/295) | Published and independently passed at `9b9d8ed299519fa32b2235bc6c15adfb4093376d`. Local pinned-Bun workspace gate: 1,210 passed, 13 optional skips, both dry builds; frozen install and dependency audit passed. Both account/owner P2s and the Linux narrow-screen failure are corrected. Independent review reran 45 focused tests plus account-switch/outage/long-name browser probes. GitHub's authoritative workspace check passed on this head. Merge/deployment remain held. |
-| PR2 — [Stem classroom draft #28](https://github.com/CUNY-AI-Lab/stem-splitter/pull/28) | Corrected source committed at `1f7e0205a4130bd46cf2180772671e57cc9387fe`: 50 Cloudflare, 36 Chrome, 25 Firefox and 25 WebKit cases pass; the Chromium-only native zoom case is explicitly skipped in the other engines. All 19 local-hosting cases, root suite and dry build pass. All three UI audit defects have negative regression tests: account-outage concealment, course-bound prompt requests and rapid selection/folder-write fencing. Every waveform uses the shared seek path; native 200% mouse and keyboard seeking passed with two synchronized stems after correcting the fixture byte-range responses. The shallow-history schema fixture and title/logo CI expectations are fixed locally. Exact-final-head independent review and refreshed remote CI remain pending; the previously published checkpoint is not approved. |
-| PR3 — [Stem reliability draft #29](https://github.com/CUNY-AI-Lab/stem-splitter/pull/29) | Published checkpoint `aa74797ce38d9b5dbfcc4849daf17b0cd41a4c96` had 66/66 local Cloudflare and 20/20 importer tests, but independent review reproduced five recovery defects: lost commit acknowledgement deleted committed audio, abandoned ingestion blocked the queue, stale importers failed newer work, missing pins held capacity and polling ignored Retry-After. Corrections now pass local negative tests; current working checks include 74 Cloudflare cases, importer21/21 and actual Workerd overlap of a ten-minute MP3 decode with a 12 MiB inline import. Guest 5/25 routed Worker/storage tests pass with mocked providers; guest browser integration, usage-event verification, final restack, CI and independent review remain open. Production cause of the YouTube report remains unconfirmed. No paid job, extractor publication or production change occurred. |
+| PR2 — [Stem classroom draft #28](https://github.com/CUNY-AI-Lab/stem-splitter/pull/28) | Final `f5506597f8fab2196d3f55119242d76210ff553d` is published, independently PASS and green on every remote check (runs `37522342154`, `37522342260`). Local evidence: shared321, server42, Cloudflare50, Chrome36, Firefox25 and WebKit25 plus the explicit native-Chromium-only skip; local-hosting19, migration, typechecks and dry build pass. Original account-outage, wrong-course prompt and rapid-selection defects are closed. Every waveform uses the shared seek path; actual native 200% pointer/keyboard seeking passed. Merge and live acceptance remain held. |
+| PR3 — [Stem reliability draft #29](https://github.com/CUNY-AI-Lab/stem-splitter/pull/29) | Integrated implementation `0bbabd44514a20bed6093e305942327a78b5d8d2` descends from final PR2. Local checks: shared325, server42, Python5/30/9, Cloudflare84, importer21, extractor4, Chrome38, Firefox23 and WebKit24; typechecks, adapter audit and candidate dry build pass. Guest5/25 is separate from member15/50 and remains OFF. Recovery corrections, bounded12MiB imports, chat settlement fencing and redacted actor-bound usage events are integrated. Two actual local Workers verify atomic admission; twenty students across two database coordinators verify fairness. These are not production capacity measurements. Independent exact-final review and refreshed hosted CI are tracked on this PR; earlier published `aa74797` is not approval. The live YouTube cause remains unconfirmed. No paid job, extractor publication or production change occurred. |
The current Gateway catalog lists both requested model IDs. Read-only deployment/version verification also matches serving Gateway source `36cd8cdb7d61e2aebc94a03171b5990cea00bf5e`, version `98a500ab-2ea7-49b1-9909-ddb06d60cff7` at 100%. Its documented retryable `upstream_rate_limited`, `upstream_unavailable` and `model_unavailable` codes inform local fallback tests; quota/authentication/payment/ambiguous-timeout outcomes do not qualify. This proves neither account entitlement nor a live inference result.
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 6dacd03..4ec9f48 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -1,5 +1,17 @@
# Cloudflare deployment
+## Reliability and guest candidate — October 6, 2026
+
+The PR3 source replaces historical attempt counting with 15 successful member
+splits and 50 human Listening Guy inputs per UTC submission day, durable
+reservations/recovery, bounded import validation and redacted usage evidence.
+The optional guest path has separate 5/25 allowances and private ownership;
+it remains disabled until separately approved bot verification and Gateway
+sponsor configuration are installed. See [the operation/migration runbook](../docs/reliability-operations.md),
+[guest access gates](../docs/guest-access.md) and [local verification](../docs/evidence/reliability-20261006/verification.md).
+These are candidate source semantics, not a deployment or permission change.
+The dated ten-attempt/visitor-disabled receipts below describe the older release.
+
## Classroom candidate — October 6, 2026
The prospective course implementation and migration `0021` are described in
diff --git a/cloudflare/account-rack.spec.mjs b/cloudflare/account-rack.spec.mjs
index 36c9810..75c9465 100644
--- a/cloudflare/account-rack.spec.mjs
+++ b/cloudflare/account-rack.spec.mjs
@@ -70,7 +70,7 @@ test('account rack recovers on a new browser; isolates people; handles paging, i
});
await page.locator('#yt-url').fill('https://www.youtube.com/watch?v=fixture1234');
await page.locator('#yt-form button').click();
- await expect(page.locator('#upload-message')).toContainText("You've used today's runs.");
+ await expect(page.locator('#upload-message')).toContainText("Today's 15 places are completed or in progress.");
await expect(page.locator('#upload-message')).not.toContainText('Saved to your account');
await page.unroute('**/api/jobs');
await page.getByRole('link', { name: 'My account', exact: true }).click();
diff --git a/cloudflare/account.spec.mjs b/cloudflare/account.spec.mjs
index a2c8804..669749e 100644
--- a/cloudflare/account.spec.mjs
+++ b/cloudflare/account.spec.mjs
@@ -1,11 +1,58 @@
import { test, expect } from '@playwright/test';
import { createTestHarness } from 'wrangler';
import { createTestIdentityIssuer, TEST_SUBJECTS } from '@cuny-ai-lab/cail-identity/testing';
-import { readFile, mkdir } from 'node:fs/promises';
+import { readFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
+import { createServer } from 'node:https';
+import { execFileSync } from 'node:child_process';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { schemaStatements } from '../tests/e2e/schema-statements.mjs';
import { handleAuth, SESSION_COOKIE, LOGIN_COOKIE } from './sso.ts';
+// Auth navigation needs a real HTTPS response: WebKit cannot fulfill redirects
+// through Playwright routing, and routed fake origins do not exercise its cookie
+// transport. Only this disposable loopback certificate is untrusted; HTTPS,
+// same-origin POSTs, 303 navigation and __Host- cookie handling remain real.
+test.use({ ignoreHTTPSErrors: true });
+async function fixtureFetch(server, request) {
+ return server.fetch(request.url, { method: request.method, headers: Object.fromEntries(request.headers),
+ ...(!['GET', 'HEAD'].includes(request.method) ? { body: await request.arrayBuffer() } : {}) });
+}
+async function localHttpsFixture(handler) {
+ const directory = await mkdtemp(join(tmpdir(), 'stem-account-browser-'));
+ let server, origin;
+ const errors = [];
+ try {
+ const key = join(directory, 'key.pem'), cert = join(directory, 'cert.pem');
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
+ '-subj', '/CN=localhost', '-keyout', key, '-out', cert], { stdio: 'ignore' });
+ server = createServer({ key: await readFile(key), cert: await readFile(cert) }, async (incoming, outgoing) => {
+ try {
+ const headers = new Headers();
+ for (let index = 0; index < incoming.rawHeaders.length; index += 2) headers.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1]);
+ const chunks = []; for await (const chunk of incoming) chunks.push(chunk);
+ const body = Buffer.concat(chunks);
+ const request = new Request(new URL(incoming.url, origin), { method: incoming.method, headers,
+ ...(body.length ? { body } : {}) });
+ const response = await handler(request);
+ for (const [name, value] of response.headers) if (name !== 'set-cookie') outgoing.setHeader(name, value);
+ const cookies = response.headers.getSetCookie();
+ if (cookies.length) outgoing.setHeader('set-cookie', cookies);
+ outgoing.writeHead(response.status); outgoing.end(Buffer.from(await response.arrayBuffer()));
+ } catch (error) { errors.push(error.message); outgoing.writeHead(500); outgoing.end('Local fixture request failed'); }
+ });
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
+ origin = `https://127.0.0.1:${server.address().port}`;
+ return { origin, errors, async close() {
+ server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
+ await rm(directory, { recursive: true, force: true });
+ } };
+ } catch (error) {
+ server?.closeAllConnections(); server?.close(); await rm(directory, { recursive: true, force: true }); throw error;
+ }
+}
+
test('Expired sign-in links give students readable recovery on desktop and mobile', async ({ page }) => {
const origin = 'https://stem-signin.test';
const server = createTestHarness({ workers: [{ configPath: fileURLToPath(new URL('./test-wrangler.jsonc', import.meta.url)) }] });
@@ -49,112 +96,98 @@ test('Expired sign-in links give students readable recovery on desktop and mobil
});
test('Verified sessions return to Splitter directly; unavailable verification offers Retry without guessing state', async ({ page, context }) => {
- const origin = 'https://stem-recovery.test';
const issuer = await createTestIdentityIssuer();
const appJwt = await issuer.mintIdentityJwt({ audience: 'cail:stem-splitter', subject: TEST_SUBJECTS.alice });
const token = '00000000-0000-4000-8000-000000000001.' + 's'.repeat(43);
const server = createTestHarness({ workers: [{ configPath: fileURLToPath(new URL('./test-wrangler.jsonc', import.meta.url)), vars: {
TEST_JWKS: issuer.jwksJson, TEST_BROWSER: 'true',
} }] });
- let available = false, identityChecks = 0;
+ let available = false, identityChecks = 0, frontend;
const errors = [];
page.on('pageerror', error => errors.push(error.message));
try {
- const { url } = await server.listen();
+ await server.listen();
await server.fetch('/__fixture/schema', { method: 'POST', headers: { 'x-fixture': 'local-only' },
body: JSON.stringify(schemaStatements(await readFile(new URL('../schema.sql', import.meta.url), 'utf8'))) });
+ frontend = await localHttpsFixture(async request => {
+ const { origin, pathname } = new URL(request.url);
+ return pathname.startsWith('/auth/') ? handleAuth(request, {
+ PUBLIC_BASE_URL: origin, CANONICAL_BASE_URL: origin, CAIL_IDENTITY_JWKS: issuer.jwksJson,
+ REQUEST_LIMIT: { limit: async () => ({ success: true }) },
+ IDENTITY: { identities: async received => {
+ expect(received).toBe(token); identityChecks++;
+ if (!available) throw new Error('fixture outage');
+ return { ok: true, appJwt, gatewayJwt: 'unused', workspaceJwt: null };
+ } },
+ }) : fixtureFetch(server, request);
+ });
+ const origin = frontend.origin;
await context.setExtraHTTPHeaders({ 'x-fixture-identity': appJwt });
await context.addCookies([{ name: SESSION_COOKIE, value: token, url: origin, secure: true, httpOnly: true, sameSite: 'Lax' }]);
- await page.route(origin + '/**', async route => {
- const request = route.request();
- const path = new URL(request.url()).pathname;
- const response = path.startsWith('/auth/')
- ? await handleAuth(new Request(request.url(), { headers: await request.allHeaders() }), {
- PUBLIC_BASE_URL: origin, CANONICAL_BASE_URL: origin, CAIL_IDENTITY_JWKS: issuer.jwksJson,
- REQUEST_LIMIT: { limit: async () => ({ success: true }) },
- IDENTITY: { identities: async received => {
- expect(received).toBe(token);
- identityChecks++;
- if (!available) throw new Error('fixture outage');
- return { ok: true, appJwt, gatewayJwt: 'unused', workspaceJwt: null };
- } },
- }) : await server.fetch(path);
- // Follow the actual handler's fixed home redirect into the local app,
- // never the real CUNY provider. This is a synthetic session fixture.
- if (response.status === 303) expect(response.headers.get('location')).toBe('/');
- await route.fulfill({ status: response.status,
- headers: { ...Object.fromEntries(response.headers), ...(response.status === 303 ? { location: url.href } : {}) },
- body: Buffer.from(await response.arrayBuffer()) });
- });
const expiredLink = origin + '/auth/callback?code=expired&state=expired&next=//attacker.test';
- await page.goto(expiredLink);
+ const unavailable = await page.goto(expiredLink);
+ expect(unavailable.status()).toBe(503);
await expect(page.getByRole('heading', { name: 'Sign-in is temporarily unavailable', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: 'Retry', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: /CUNY Login|My account/i })).toHaveCount(0);
+ expect(identityChecks).toBe(1);
available = true;
- await page.getByRole('link', { name: 'Retry', exact: true }).click();
- await expect(page).toHaveURL(url.href);
+ const [retry] = await Promise.all([
+ page.waitForResponse(response => response.url() === origin + '/auth/login'),
+ page.getByRole('link', { name: 'Retry', exact: true }).click(),
+ ]);
+ expect(retry.status()).toBe(303); expect(retry.headers().location).toBe('/');
+ await expect(page).toHaveURL(origin + '/');
await expect(page.getByRole('heading', { name: 'Stem Splitter', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: 'My account', exact: true })).toBeVisible();
await page.goto(expiredLink);
- await expect(page).toHaveURL(url.href);
+ await expect(page).toHaveURL(origin + '/');
await expect(page.getByRole('heading', { name: 'Stem Splitter', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: /sign.in/i })).toHaveCount(0);
expect(identityChecks).toBe(3);
expect((await context.cookies(origin)).find(cookie => cookie.name === SESSION_COOKIE)?.value).toBe(token);
- expect(errors).toEqual([]);
- } finally { await server.close(); }
+ expect(errors).toEqual([]); expect(frontend.errors).toEqual([]);
+ } finally { await frontend?.close(); await server.close(); }
});
test('Sign out submits a trusted origin, clears cookies and returns to Splitter', async ({ page, context }) => {
- const origin = 'https://stem-signout.test';
const server = createTestHarness({ workers: [{ configPath: fileURLToPath(new URL('./test-wrangler.jsonc', import.meta.url)) }] });
- let revoked = false;
- let submittedOrigin;
+ let revoked = false, submittedOrigin, frontend;
const errors = [];
page.on('pageerror', error => errors.push(error.message));
try {
- const { url } = await server.listen();
- await context.addCookies([SESSION_COOKIE, LOGIN_COOKIE].map(name => ({ name, value: name === SESSION_COOKIE ? '00000000-0000-4000-8000-000000000001.' + 'a'.repeat(43) : 'pending', url: origin, secure: true, httpOnly: true, sameSite: 'Lax' })));
- await page.route(origin + '/**', async route => {
- const request = route.request();
- const path = new URL(request.url()).pathname;
- if (path === '/auth/logout') {
- const headers = await request.allHeaders();
- submittedOrigin = headers.origin;
- const response = await handleAuth(new Request(request.url(), { method: request.method(), headers }), {
- PUBLIC_BASE_URL: origin, CANONICAL_BASE_URL: origin,
+ await server.listen();
+ frontend = await localHttpsFixture(async request => {
+ const { origin, pathname } = new URL(request.url);
+ if (pathname === '/auth/logout') {
+ submittedOrigin = request.headers.get('origin');
+ return handleAuth(request, { PUBLIC_BASE_URL: origin, CANONICAL_BASE_URL: origin,
IDENTITY: { revoke: async () => { revoked = true; }, identities: async () => ({ ok: false, status: 403 }) },
});
- // Redirect continuations bypass Playwright routing. Keep the destination
- // on the local harness, never a live service, after checking the handler.
- if (response.status === 303) expect(response.headers.get('location')).toBe('/');
- await route.fulfill({ status: response.status, headers: { ...Object.fromEntries(response.headers), ...(response.status === 303 ? { location: url.href } : {}), ...(response.headers.has('set-cookie') ? { 'set-cookie': response.headers.getSetCookie().join('\n') } : {}) }, body: await response.text() });
- } else if (path === '/api/account') {
- await route.fulfill({ json: { account: { subject: 'fixture', role: 'student' } } });
- } else if (path === '/api/model-quota') {
- await route.fulfill({ json: { quota: null } });
- } else if (path === '/') {
- await route.fulfill({ contentType: 'text/html', body: '
Stem Splitter
Stem Splitter
CUNY Login' });
- } else {
- const response = await server.fetch(path);
- const headers = Object.fromEntries(response.headers);
- // Only the fixture redirects to HTTP loopback after logout. Permit that
- // destination in its CSP while retaining the actual Referrer-Policy.
- if (headers['content-security-policy']) headers['content-security-policy'] = headers['content-security-policy'].replace("form-action 'self'", `form-action 'self' ${url.origin}`);
- await route.fulfill({ status: response.status, headers, body: Buffer.from(await response.arrayBuffer()) });
}
+ if (pathname === '/api/account') return revoked
+ ? Response.json({ error: { code: 'authentication_required', message: 'Sign in required' } }, { status: 401 })
+ : Response.json({ account: { subject: 'fixture', role: 'student' } });
+ if (pathname === '/api/model-quota') return Response.json({ quota: null });
+ return fixtureFetch(server, request);
});
+ const origin = frontend.origin;
+ await context.addCookies([SESSION_COOKIE, LOGIN_COOKIE].map(name => ({ name, value: name === SESSION_COOKIE ? '00000000-0000-4000-8000-000000000001.' + 'a'.repeat(43) : 'pending', url: origin, secure: true, httpOnly: true, sameSite: 'Lax' })));
await page.goto(origin + '/account.html');
await expect(page.getByRole('button', { name: 'Sign out', exact: true })).toBeVisible();
- await page.getByRole('button', { name: 'Sign out', exact: true }).click();
+ const [logout] = await Promise.all([
+ page.waitForResponse(response => response.url() === origin + '/auth/logout'),
+ page.getByRole('button', { name: 'Sign out', exact: true }).click(),
+ ]);
expect(submittedOrigin).toBe(origin);
- await expect(page).toHaveURL(url.href);
+ expect(logout.request().method()).toBe('POST'); expect(logout.status()).toBe(303); expect(logout.headers().location).toBe('/');
+ await expect(page).toHaveURL(origin + '/');
expect(revoked).toBe(true);
expect((await context.cookies()).filter(cookie => [SESSION_COOKIE, LOGIN_COOKIE].includes(cookie.name))).toEqual([]);
await expect(page.getByRole('heading', { name: 'Stem Splitter', exact: true })).toBeVisible();
- expect(errors).toEqual([]);
- } finally { await server.close(); }
+ await expect(page.getByRole('link', { name: 'CUNY Login', exact: true })).toBeVisible();
+ expect(errors).toEqual([]); expect(frontend.errors).toEqual([]);
+ } finally { await frontend?.close(); await server.close(); }
});
test('Account stays simple; administration is deliberate, responsive, and recoverable', async ({ page, context }) => {
@@ -167,6 +200,14 @@ test('Account stays simple; administration is deliberate, responsive, and recove
const errors = [];
page.on('pageerror', error => errors.push(error.message));
const consoleIssues = [];
+ const usageConflicts = [];
+ page.on('response', response => {
+ if (response.status() === 409 && new URL(response.url()).pathname === '/api/usage-events') {
+ usageConflicts.push(response.json().then(body => ({url:response.url(),body,
+ requestActor:response.request().headers()['x-stem-usage-actor'],responseActor:response.headers()['x-stem-account']}))
+ .catch(error => ({readError:error.message})));
+ }
+ });
page.on('console', message => {
if (['error', 'warning'].includes(message.type())) consoleIssues.push({ text: message.text(), url: message.location().url });
});
@@ -183,7 +224,7 @@ test('Account stays simple; administration is deliberate, responsive, and recove
await expect(page).toHaveTitle('Stem Splitter · Account');
await expect(page.getByRole('heading', { name: 'My account', exact: true })).toBeVisible();
await expect(page.locator('#account-role')).toHaveText('Administrator access');
- await expect(page.locator('#account-splits')).toContainText('10 of 10 runs left today. Resets');
+ await expect(page.locator('#account-splits')).toContainText('Splits: 0 completed, 0 in progress, 15 available of 15. Resets');
await expect(page.getByRole('link', { name: 'Guide instructions', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: '← Back to Splitter', exact: true })).toHaveAttribute('href', '/');
await expect(page.locator('#access-form')).toBeHidden();
@@ -255,9 +296,9 @@ test('Account stays simple; administration is deliberate, responsive, and recove
'Model access': 'https://tools.ailab.gc.cuny.edu/model-access',
'My classes': 'https://tools.ailab.gc.cuny.edu/my-classes',
})) await expect(resources.getByRole('link', { name, exact: true })).toHaveAttribute('href', href);
- await expect(page.locator('footer')).toContainText('Built through the Critical AI Literacy Institute.');
- await expect(page.locator('footer')).toContainText('Uploaded files are deleted after 90 days.');
- await expect(page.locator('#account-splits')).toContainText('10 of 10 runs left today. Resets');
+ await expect(page.locator('main > footer')).toContainText('Built through the Critical AI Literacy Institute.');
+ await expect(page.locator('main > footer')).toContainText('Uploaded files are deleted after 90 days.');
+ await expect(page.locator('#account-splits')).toContainText('Splits: 0 completed, 0 in progress, 15 available of 15. Resets');
await expect(page.locator('#account-admin')).toBeHidden();
await expect(page.getByRole('link', { name: 'Guide instructions', exact: true })).toBeHidden();
await page.getByText('Account ID', { exact: true }).click();
@@ -268,7 +309,12 @@ test('Account stays simple; administration is deliberate, responsive, and recove
const day = new Date().toISOString().slice(0, 10);
await seed(Array.from({ length: 10 }, (_, index) => `INSERT INTO app_request_reservations (id, subject, scope, day) VALUES ('account-split-${index}', '${TEST_SUBJECTS.alice}', 'split', '${day}')`));
await page.reload();
- await expect(page.locator('#account-splits')).toContainText('0 of 10 runs left today. Resets');
+ await expect(page.locator('#account-splits')).toContainText('15 available of 15'); // historical attempts are not manufactured successes
+ await expect(page.locator('#account-chat')).toContainText('50 available of 50');
+ const clock=Date.now();
+ await seed(Array.from({length:15},(_,index)=>`INSERT INTO app_operations(id,subject,kind,idempotency_key,fingerprint,day,state,phase,deadline,created_at,updated_at) VALUES('account-operation-${index}','${TEST_SUBJECTS.alice}','split','account-operation-${index}','fixture-${index}','${day}','${index<8?'succeeded':'queued'}','split',${clock+86400000},${clock},${clock})`));
+ await page.reload();
+ await expect(page.locator('#account-splits')).toContainText('Splits: 8 completed, 7 in progress, 0 available of 15. Resets');
await expect(page.getByRole('link', { name: '← Back to Splitter', exact: true })).toBeVisible();
if (receipts) await page.screenshot({ path: `${receipts}/account-exhausted-mobile-fixture.png`, fullPage: true });
// A counter outage is not zero usage, a failed sign-in, or a full allowance.
@@ -349,10 +395,22 @@ test('Account stays simple; administration is deliberate, responsive, and recove
await expect(page.locator('#account-footer')).toBeHidden();
await expect(page.locator('#account-id')).toBeEmpty();
expect(errors).toEqual([]);
+ // Deliberate fixture identity changes can race an old pagehide batch.
+ // Accept only a proven actor-fence rejection, never arbitrary409 failures.
+ const verifiedConflictUrls = new Set();
+ for (const conflict of await Promise.all(usageConflicts)) {
+ expect(conflict.readError).toBeUndefined();
+ expect(conflict.body).toEqual({error:'The account changed. Discard these observations.'});
+ expect([TEST_SUBJECTS.alice,TEST_SUBJECTS.carol]).toContain(conflict.requestActor);
+ expect([TEST_SUBJECTS.alice,TEST_SUBJECTS.carol]).toContain(conflict.responseActor);
+ expect(conflict.requestActor).not.toBe(conflict.responseActor);
+ verifiedConflictUrls.add(conflict.url);
+ }
// Quota fixture has no Gateway JWT (401); the deliberate management outage
// is 503. Do not allow unrelated console errors to disappear in that noise.
expect(consoleIssues.filter(message =>
!(/^Failed to load resource: the server responded with a status of (401|503)\b/.test(message.text) && /\/api\/(account|model-quota|admin\/users)$/.test(message.url)) &&
+ !(message.text === 'Failed to load resource: the server responded with a status of 409 (Conflict)' && verifiedConflictUrls.has(message.url)) &&
!(message.text === 'Failed to load resource: the server responded with a status of 404 (Not Found)' && message.url.endsWith('/favicon.ico'))
)).toEqual([]);
} finally { await server.close(); }
diff --git a/cloudflare/alias.test.ts b/cloudflare/alias.test.ts
index b14da3d..b334408 100644
--- a/cloudflare/alias.test.ts
+++ b/cloudflare/alias.test.ts
@@ -29,10 +29,10 @@ test('alias preserves denied responses and fails closed without exposing excepti
test('health identifies guide configuration without exposing credentials', async () => {
const env = { DB: { prepare: () => ({ first: async () => ({ ready: 1 }) }) },
- GATEWAY: { fetch: async () => new Response() }, GATEWAY_MODEL: 'glm-5.2', OPENROUTER_API_KEY: 'fixture-only' };
+ GATEWAY: { fetch: async () => new Response() }, GATEWAY_MODEL: 'glm-5.2', GATEWAY_FALLBACK_MODEL: 'deepseek-v4-flash-0731', OPENROUTER_API_KEY: 'fixture-only' };
const health = await worker.fetch(new Request('https://preview.test/healthz'), env, {});
const body = await health.json();
- assert.deepEqual(body.listeningGuide, { configured: true, fallbackConfigured: false, transport: 'cail-gateway' });
+ assert.deepEqual(body.listeningGuide, { configured: true, fallbackConfigured: true, transport: 'cail-gateway' });
assert.doesNotMatch(JSON.stringify(body), /fixture-only|fixture\/model/);
const unset = await worker.fetch(new Request('https://preview.test/healthz'), { ...env, GATEWAY: undefined }, {});
assert.equal((await unset.json()).listeningGuide.configured, false);
diff --git a/cloudflare/audio-validator.ts b/cloudflare/audio-validator.ts
new file mode 100644
index 0000000..615d574
--- /dev/null
+++ b/cloudflare/audio-validator.ts
@@ -0,0 +1,11 @@
+// The root entry also constructs browser WebWorker helpers. Use the pinned
+// main-thread entry so no browser Worker global is needed in workerd.
+// @ts-expect-error Upstream only types its root exports. The exact pin is tested below.
+import MPEGDecoder from './node_modules/mpg123-decoder/src/MPEGDecoder.js';
+import type { MPEGDecoder as DecoderType } from 'mpg123-decoder';
+import module from './vendor/mpg123-1.0.3.wasm';
+import { validateMp3Pcm } from '../src/reliability/media.ts';
+// The pinned package's module injection hook bypasses runtime compilation.
+// Wrangler emits a compiled Wasm module; no code or binaries are fetched.
+(MPEGDecoder as unknown as {module:WebAssembly.Module}).module=module;
+export const validateStemAudio=(data:ArrayBuffer)=>validateMp3Pcm(data,()=>new MPEGDecoder() as DecoderType);
diff --git a/cloudflare/bug-report-v1.json b/cloudflare/bug-report-v1.json
new file mode 100644
index 0000000..1903770
--- /dev/null
+++ b/cloudflare/bug-report-v1.json
@@ -0,0 +1,48 @@
+{
+ "schemaVersion": 1,
+ "entrypoint": "AdmissionBugReports",
+ "method": "submit",
+ "binding": {
+ "binding": "BUG_REPORTS",
+ "service": "cail-tools-admission",
+ "entrypoint": "AdmissionBugReports",
+ "props": {
+ "app": "stem-splitter",
+ "origins": ["https://stem-splitter.ailab-452.workers.dev"]
+ }
+ },
+ "input": {
+ "reportId": "11111111-1111-4111-8111-111111111111",
+ "name": "Fixture Reporter",
+ "email": "reporter@example.edu",
+ "description": "The split button remained unavailable after my upload.",
+ "origin": "https://stem-splitter.ailab-452.workers.dev",
+ "rateKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "rateDay": "2026-10-07"
+ },
+ "accepted": {
+ "ok": true,
+ "status": "accepted",
+ "reportId": "11111111-1111-4111-8111-111111111111"
+ },
+ "unknown": {
+ "ok": false,
+ "code": "delivery_unknown",
+ "retryable": false,
+ "reportId": "11111111-1111-4111-8111-111111111111"
+ },
+ "knownFailure": {
+ "ok": false,
+ "code": "unavailable",
+ "retryable": true,
+ "retryAfterSeconds": 60,
+ "reportId": "11111111-1111-4111-8111-111111111111"
+ },
+ "rateLimited": {
+ "ok": false,
+ "code": "rate_limited",
+ "retryable": true,
+ "retryAfterSeconds": 3600,
+ "reportId": "11111111-1111-4111-8111-111111111111"
+ }
+}
diff --git a/cloudflare/bug-report.spec.mjs b/cloudflare/bug-report.spec.mjs
new file mode 100644
index 0000000..da5de8d
--- /dev/null
+++ b/cloudflare/bug-report.spec.mjs
@@ -0,0 +1,102 @@
+import { test, expect } from '@playwright/test';
+import { createTestHarness } from 'wrangler';
+import { fileURLToPath } from 'node:url';
+import { mkdir } from 'node:fs/promises';
+
+async function setup(page) {
+ const harness = createTestHarness({ workers: [{ config: {
+ name: 'bug-report-test', main: fileURLToPath(new URL('./test-bug-report-worker.ts', import.meta.url)),
+ alias: { hono: 'hono-cloudflare', 'hono/factory': 'hono-cloudflare/factory' },
+ compatibility_date: '2026-09-06', compatibility_flags: ['nodejs_compat'],
+ assets: { directory: fileURLToPath(new URL('../public', import.meta.url)), binding: 'ASSETS', run_worker_first: true },
+ vars: { AUTH_MODE: 'cail', PUBLIC_BASE_URL: 'https://split.test' },
+ ratelimits: [{ name: 'REQUEST_LIMIT', namespace_id: '884476', simple: { limit: 1000, period: 60 } }],
+ } }] });
+ const { url } = await harness.listen();
+ const errors = []; page.on('pageerror', error => errors.push(error.message));
+ const control = mode => harness.fetch('/__bug-fixture', { method: 'POST', body: JSON.stringify({ mode }) });
+ const stats = async () => (await (await harness.fetch('/__bug-fixture')).json()).calls;
+ await page.goto(new URL('/account.html', url).href);
+ return { harness, url, errors, control, stats };
+}
+async function fill(page) {
+ await page.getByLabel('Name', { exact: true }).fill('Fixture Reporter');
+ await page.getByLabel('Email', { exact: true }).fill('reporter@example.edu');
+ await page.getByLabel('Description of the bug').fill('The button stayed unavailable after I tried to upload.');
+}
+test('signed-out footer expands by keyboard and submits once without private attachments', async ({ page }) => {
+ const f = await setup(page);
+ try {
+ for (const path of ['/account.html', '/teacher.html', '/classroom.html', '/']) {
+ await page.goto(new URL(path, f.url).href);
+ await expect(page.locator('.bug-report > summary')).toBeVisible();
+ await expect(page.getByRole('form', { name: 'Report a bug' })).toBeHidden();
+ }
+ await page.goto(new URL('/account.html', f.url).href);
+ const summary = page.locator('.bug-report > summary'); await summary.focus(); await summary.press('Enter');
+ await expect(page.getByLabel('Name', { exact: true })).toBeVisible(); await fill(page);
+ await page.getByRole('button', { name: 'Send bug report', exact: true }).click();
+ await expect(page.locator('.bug-report__status')).toContainText('accepted for delivery');
+ await expect(page.getByRole('button', { name: 'Report sent' })).toBeDisabled();
+ const calls = await f.stats(); expect(calls).toHaveLength(1);
+ expect(Object.keys(calls[0]).sort()).toEqual(['description', 'email', 'name', 'origin', 'rateDay', 'rateKey', 'reportId']);
+ expect(f.errors).toEqual([]);
+ } finally { await f.harness.close(); }
+});
+test('known failure preserves editable fields and explicit retry uses the same reference', async ({ page }) => {
+ const f = await setup(page);
+ try {
+ await f.control('unavailable'); await page.locator('.bug-report > summary').click(); await fill(page);
+ const sent = []; page.on('request', request => { if (request.url().endsWith('/api/bug-reports')) sent.push(request.postDataJSON()); });
+ await page.getByRole('button', { name: 'Send bug report' }).click();
+ await expect(page.locator('.bug-report__status')).toContainText('description is still here');
+ await expect(page.getByLabel('Name', { exact: true })).toBeEnabled();
+ await expect(page.getByRole('button', { name: 'Try again' })).toBeEnabled();
+ await f.control('accepted'); await page.getByRole('button', { name: 'Try again' }).click();
+ await expect(page.locator('.bug-report__status')).toContainText('accepted for delivery');
+ expect(sent).toHaveLength(2); expect(sent[1]).toEqual(sent[0]); expect(f.errors).toEqual([]);
+ } finally { await f.harness.close(); }
+});
+test('double submit is suppressed and a lost browser response checks the original report', async ({ page }) => {
+ const f = await setup(page);
+ try {
+ let pending; const sent = [];
+ await page.route('**/api/bug-reports', route => { pending = route; sent.push(route.request().postDataJSON()); });
+ await page.locator('.bug-report > summary').click(); await fill(page);
+ await page.getByRole('button', { name: 'Send bug report' }).click();
+ await expect(page.getByRole('form', { name: 'Report a bug' })).toHaveAttribute('aria-busy', 'true');
+ await expect(page.getByRole('button', { name: 'Send bug report' })).toBeDisabled();
+ await page.getByRole('form', { name: 'Report a bug' }).evaluate(form => form.requestSubmit());
+ await expect.poll(() => sent.length).toBe(1); await pending.abort();
+ await expect(page.getByRole('button', { name: 'Check report status' })).toBeEnabled();
+ await expect(page.getByLabel('Description of the bug')).toBeDisabled();
+ await page.unroute('**/api/bug-reports');
+ await page.getByRole('button', { name: 'Check report status' }).click();
+ await expect(page.locator('.bug-report__status')).toContainText('accepted for delivery');
+ const calls = await f.stats(); expect(calls).toHaveLength(1);
+ expect(calls[0].reportId).toBe(sent[0].reportId); expect(calls[0].description).toBe(sent[0].description);
+ expect(f.errors).toEqual([]);
+ } finally { await f.harness.close(); }
+});
+test('uncertain response locks content and checks the same reference; phone footer remains usable', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const f = await setup(page);
+ try {
+ await f.control('unknown'); await page.locator('.bug-report > summary').click(); await fill(page);
+ await page.getByRole('button', { name: 'Send bug report' }).click();
+ await expect(page.locator('.bug-report__status')).toContainText('could not confirm');
+ await expect(page.getByLabel('Description of the bug')).toBeDisabled();
+ await page.getByRole('button', { name: 'Check report status' }).click();
+ await expect(page.getByRole('button', { name: 'Check report status' })).toBeInViewport({ ratio: 1 });
+ const calls = await f.stats(); expect(calls).toHaveLength(2); expect(calls[1]).toEqual(calls[0]);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ const borders = await page.locator('.bug-report').evaluate(el => { const css = getComputedStyle(el); return [css.borderTopWidth, css.borderBottomWidth]; });
+ expect(borders).toEqual(['1px', '1px']);
+ if (process.env.BUG_REPORT_SCREENSHOTS) {
+ await mkdir(process.env.BUG_REPORT_SCREENSHOTS, { recursive: true });
+ await page.screenshot({ path: `${process.env.BUG_REPORT_SCREENSHOTS}/stem-phone-uncertain.png`, fullPage: true });
+ }
+ await page.locator('.bug-report > summary').click(); await expect(page.getByRole('form', { name: 'Report a bug' })).toBeHidden();
+ expect(f.errors).toEqual([]);
+ } finally { await f.harness.close(); }
+});
diff --git a/cloudflare/bug-reports.test.ts b/cloudflare/bug-reports.test.ts
new file mode 100644
index 0000000..d0d777c
--- /dev/null
+++ b/cloudflare/bug-reports.test.ts
@@ -0,0 +1,87 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { handleBugReport, type BugReportService } from './bug-reports.ts';
+const origin = 'https://stem-splitter.ailab-452.workers.dev';
+const contract = JSON.parse(readFileSync(new URL('./bug-report-v1.json', import.meta.url), 'utf8'));
+const { reportId, name, email, description } = contract.input;
+const report = { reportId, name, email, description };
+const request = (data: unknown = report, headers: Record = {}) => new Request(`${origin}/api/bug-reports`, {
+ method: 'POST', headers: { origin, 'content-type': 'application/json', 'cf-connecting-ip': '192.0.2.5', ...headers }, body: JSON.stringify(data),
+});
+function fixture() {
+ const submissions: Parameters[0][] = [];
+ const options = { app: 'stem-splitter' as const, origin, limiter: { limit: async () => ({ success: true }) },
+ service: { submit: async (input: Parameters[0]) => { submissions.push(input); return { ok: true as const, status: 'accepted' as const, reportId: input.reportId }; } } };
+ return { options, submissions };
+}
+test('anonymous form forwards only bounded fields plus server-owned origin/rate metadata', async () => {
+ const { options, submissions } = fixture();
+ const response = await handleBugReport(request(report, { cookie: 'private-session=never-forward', authorization: 'never-forward', 'x-cail-subject': 'never-forward' }), options);
+ assert.equal(response.status, 200); assert.match(response.headers.get('cache-control')!, /no-store/);
+ assert.deepEqual(await response.json(), contract.accepted);
+ assert.deepEqual(Object.keys(submissions[0]).sort(), ['description', 'email', 'name', 'origin', 'rateDay', 'rateKey', 'reportId']);
+ assert.match(submissions[0].rateKey, /^[0-9a-f]{64}$/); assert.equal(submissions[0].rateDay, new Date().toISOString().slice(0, 10));
+ assert.equal(submissions[0].origin, origin); assert.equal(JSON.stringify(submissions).includes('192.0.2.5'), false);
+ await handleBugReport(request(report, { 'x-forwarded-for': 'spoofed' }), options);
+ assert.equal(submissions[0].rateKey, submissions[1].rateKey);
+});
+test('foreign, missing, null and cross-site origins cannot dispatch', async () => {
+ const { options, submissions } = fixture();
+ for (const headers of [{ origin: 'https://foreign.example' }, { origin: 'null' }, { origin: '' }, { 'sec-fetch-site': 'cross-site' }]) {
+ assert.equal((await handleBugReport(request(report, headers), options)).status, 403);
+ }
+ assert.equal((await handleBugReport(new Request('https://foreign.example/api/bug-reports', { method: 'POST', headers: { origin, 'content-type': 'application/json' }, body: JSON.stringify(report) }), options)).status, 403);
+ assert.equal(submissions.length, 0);
+});
+test('only JSON POST is accepted and unavailable configuration fails closed', async () => {
+ const { options, submissions } = fixture();
+ const method = await handleBugReport(new Request(`${origin}/api/bug-reports`), options);
+ assert.equal(method.status, 405); assert.equal(method.headers.get('allow'), 'POST');
+ assert.equal((await handleBugReport(request(report, { 'content-type': 'text/plain' }), options)).status, 400);
+ assert.equal((await handleBugReport(request(), { ...options, service: undefined })).status, 503);
+ assert.equal((await handleBugReport(request(), { ...options, limiter: undefined })).status, 503);
+ assert.equal((await handleBugReport(request(report, { 'cf-connecting-ip': '' }), options)).status, 503);
+ assert.equal(submissions.length, 0);
+});
+test('header injection, control characters, extra metadata and oversized inputs are rejected before RPC', async () => {
+ const { options, submissions } = fixture();
+ for (const data of [null, [], { ...report, recipient: 'wrong@example.edu' }, { ...report, rateKey: 'a'.repeat(64) },
+ { ...report, name: 'A\r\nBcc: wrong@example.edu' }, { ...report, email: 'a@example.edu\r\nBcc: x@example.edu' },
+ { ...report, reportId: 'not-a-uuid' }, { ...report, description: 'short' }, { ...report, description: 'a'.repeat(4001) },
+ { ...report, name: 'a'.repeat(121) }, { ...report, description: 'null\u0000byte in report' }]) {
+ assert.equal((await handleBugReport(request(data), options)).status, 400);
+ }
+ const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(' '.repeat(17000))); controller.close(); } });
+ assert.equal((await handleBugReport(new Request(`${origin}/api/bug-reports`, { method: 'POST', headers: { origin, 'content-type': 'application/json', 'cf-connecting-ip': '192.0.2.5' }, body: stream, duplex: 'half' } as RequestInit), options)).status, 400);
+ assert.equal(submissions.length, 0);
+});
+test('plain-text markup stays data and ingress rate limits prevent dispatch', async () => {
+ const { options, submissions } = fixture();
+ const description = ' does not open.';
+ assert.equal((await handleBugReport(request({ ...report, description }), options)).status, 200);
+ assert.equal(submissions[0].description, description);
+ const limited = await handleBugReport(request(), { ...options, limiter: { limit: async () => ({ success: false }) } });
+ assert.equal(limited.status, 429); assert.equal(limited.headers.get('retry-after'), '60'); assert.equal(submissions.length, 1);
+ assert.equal((await handleBugReport(request(), { ...options, limiter: { limit: async () => { throw new Error('private details'); } } })).status, 503);
+});
+test('lost RPC acknowledgement and malformed acceptance preserve an uncertain receipt', async () => {
+ const { options } = fixture();
+ for (const service of [
+ { submit: async () => { throw new Error('private delivery details'); } },
+ { submit: async () => ({ ok: true, status: 'accepted', reportId: crypto.randomUUID() }) },
+ { submit: async () => ({ ok: true, status: 'unrecognized' }) },
+ ]) {
+ const response = await handleBugReport(request(), { ...options, service: service as BugReportService });
+ assert.equal(response.status, 503);
+ assert.deepEqual(await response.json(), { ok: false, code: 'delivery_unknown', retryable: false, reportId: report.reportId });
+ }
+});
+test('receiver status and safe retry delay survive without leaking private response fields', async () => {
+ const { options } = fixture();
+ for (const [code, status] of [['rate_limited', 429], ['unavailable', 503], ['idempotency_conflict', 409]] as const) {
+ const response = await handleBugReport(request(), { ...options, service: { submit: async () => ({ ok: false, code, retryable: true, retryAfterSeconds: 60, private: 'must disappear' }) } });
+ assert.equal(response.status, status); assert.equal(response.headers.get('retry-after'), '60');
+ assert.deepEqual(await response.json(), { ok: false, code, retryable: true, retryAfterSeconds: 60, reportId: report.reportId });
+ }
+});
diff --git a/cloudflare/bug-reports.ts b/cloudflare/bug-reports.ts
new file mode 100644
index 0000000..bb23190
--- /dev/null
+++ b/cloudflare/bug-reports.ts
@@ -0,0 +1,87 @@
+/** Vendored CAIL bug-report adapter v1. Keep identical in both PoC repositories.
+ * The private Admission entrypoint owns delivery, durable limits and receipts.
+ * Browser data never chooses an app, destination, subject, identity or attachment.
+ */
+export type BugReport = { reportId: string; name: string; email: string; description: string };
+type FailureCode = 'invalid' | 'forbidden' | 'idempotency_conflict' | 'rate_limited' | 'delivery_unknown' | 'unavailable';
+export type BugReportResult = { ok: true; status: 'accepted'; reportId: string } |
+ { ok: false; code: FailureCode; retryable: boolean; retryAfterSeconds?: number; reportId?: string };
+export type BugReportService = { submit(report: BugReport & { origin: string; rateKey: string; rateDay: string }): Promise };
+type Options = {
+ app: 'stem-splitter' | 'systems-mapping'; origin: string;
+ service?: BugReportService; limiter?: { limit(input: { key: string }): Promise<{ success: boolean }> };
+};
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+const CODES = new Set(['invalid', 'forbidden', 'idempotency_conflict', 'rate_limited', 'delivery_unknown', 'unavailable']);
+const statuses = { invalid: 400, forbidden: 403, idempotency_conflict: 409, rate_limited: 429, delivery_unknown: 503, unavailable: 503 };
+function response(result: BugReportResult, status?: number) {
+ return Response.json(result, { status: status ?? (result.ok ? 200 : statuses[result.code]), headers: {
+ 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff',
+ ...(!result.ok && result.retryAfterSeconds ? { 'Retry-After': String(result.retryAfterSeconds) } : {}),
+ } });
+}
+function failure(code: FailureCode, retryable = false, retryAfterSeconds?: number) {
+ return response({ ok: false, code, retryable, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) });
+}
+function within(promise: Promise, milliseconds: number): Promise {
+ let timer: ReturnType;
+ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('deadline')), milliseconds); })])
+ .finally(() => clearTimeout(timer));
+}
+async function readReport(request: Request): Promise {
+ if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json' || !request.body) return null;
+ const claimed = request.headers.get('content-length');
+ if (claimed && (!/^\d+$/.test(claimed) || Number(claimed) > 16384)) return null;
+ const reader = request.body.getReader();
+ try {
+ const text = await within((async () => {
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); let text = '', bytes = 0;
+ while (true) {
+ const part = await reader.read(); if (part.done) break;
+ bytes += part.value.byteLength; if (bytes > 16384) throw new Error('size');
+ text += decoder.decode(part.value, { stream: true });
+ }
+ return text + decoder.decode();
+ })(), 5000);
+ const data = JSON.parse(text);
+ if (!data || typeof data !== 'object' || Array.isArray(data) || Object.keys(data).sort().join(',') !== 'description,email,name,reportId') return null;
+ if (!['reportId', 'name', 'email', 'description'].every(key => typeof data[key] === 'string') || !UUID.test(data.reportId)) return null;
+ const name = data.name.trim(), email = data.email.trim(), description = data.description.trim();
+ if (!name || name.length > 120 || /[\x00-\x1f\x7f\u2028\u2029]/.test(name)) return null;
+ if (email.length > 254 || !/^[\x21-\x7e]+@[\x21-\x7e]+\.[\x21-\x7e]+$/.test(email) || /[<>(),;:\\"\[\]]/.test(email)) return null;
+ if (description.length < 10 || description.length > 4000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(description)) return null;
+ return { reportId: data.reportId, name, email, description };
+ } catch { return null; }
+ finally { void reader.cancel().catch(() => {}); }
+}
+
+export async function handleBugReport(request: Request, options: Options): Promise {
+ const url = new URL(request.url);
+ if (request.method !== 'POST') return new Response(null, { status: 405, headers: { Allow: 'POST', 'Cache-Control': 'private, no-store' } });
+ if (url.origin !== options.origin || request.headers.get('origin') !== options.origin || request.headers.get('sec-fetch-site') === 'cross-site') return failure('forbidden');
+ if (!options.service || !options.limiter) return failure('unavailable', true, 60);
+ // Cloudflare supplies this header. Never trust X-Forwarded-For or browser rate keys.
+ const ip = request.headers.get('cf-connecting-ip');
+ if (!ip || !/^[0-9a-fA-F:.]{3,45}$/.test(ip)) return failure('unavailable', true, 60);
+ try {
+ const limit = await within(options.limiter.limit({ key: `bug-report:${options.app}:${ip}` }), 3000);
+ if (!limit.success) return failure('rate_limited', true, 60);
+ } catch { return failure('unavailable', true, 60); }
+ const report = await readReport(request);
+ if (!report) return failure('invalid');
+ const rateDay = new Date().toISOString().slice(0, 10);
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`cail-bug-report-v1\n${options.app}\n${rateDay}\n${ip}`));
+ const rateKey = Array.from(new Uint8Array(hash), byte => byte.toString(16).padStart(2, '0')).join('');
+ try {
+ const result = await within(options.service.submit({ ...report, origin: options.origin, rateKey, rateDay }), 20000);
+ if (result?.ok === true && result.status === 'accepted' && result.reportId === report.reportId) {
+ return response({ ok: true, status: 'accepted', reportId: report.reportId });
+ }
+ if (result?.ok === false && CODES.has(result.code) && typeof result.retryable === 'boolean') {
+ const retryAfterSeconds = Number.isSafeInteger(result.retryAfterSeconds) && result.retryAfterSeconds! > 0 && result.retryAfterSeconds! <= 86400 ? result.retryAfterSeconds : undefined;
+ return response({ ok: false, code: result.code, retryable: result.retryable, reportId: report.reportId,
+ ...(retryAfterSeconds ? { retryAfterSeconds } : {}) });
+ }
+ } catch { /* The receiver may already have dispatched. Do not claim a safe retry. */ }
+ return response({ ok: false, code: 'delivery_unknown', retryable: false, reportId: report.reportId });
+}
diff --git a/cloudflare/bun.lock b/cloudflare/bun.lock
index 52b9dac..9827a52 100644
--- a/cloudflare/bun.lock
+++ b/cloudflare/bun.lock
@@ -9,6 +9,7 @@
"@cuny-ai-lab/cail-identity": "5.2.6",
"@cuny-ai-lab/cail-log": "0.6.4",
"hono-cloudflare": "npm:hono@4.13.11",
+ "mpg123-decoder": "1.0.3",
},
"devDependencies": {
"wrangler": "4.129.0",
@@ -92,6 +93,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
+ "@eshaz/web-worker": ["@eshaz/web-worker@1.2.2", "", {}, "sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw=="],
+
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
@@ -162,6 +165,8 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
+ "@wasm-audio-decoders/common": ["@wasm-audio-decoders/common@9.0.7", "", { "dependencies": { "@eshaz/web-worker": "1.2.2", "simple-yenc": "^1.0.4" } }, "sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag=="],
+
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -182,6 +187,8 @@
"miniflare": ["miniflare@5.20260903.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260903.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A=="],
+ "mpg123-decoder": ["mpg123-decoder@1.0.3", "", { "dependencies": { "@wasm-audio-decoders/common": "9.0.7" } }, "sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ=="],
+
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -190,6 +197,8 @@
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
+ "simple-yenc": ["simple-yenc@1.0.4", "", {}, "sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw=="],
+
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
diff --git a/cloudflare/classroom.spec.mjs b/cloudflare/classroom.spec.mjs
index f79edf1..203637e 100644
--- a/cloudflare/classroom.spec.mjs
+++ b/cloudflare/classroom.spec.mjs
@@ -64,7 +64,7 @@ test('instructor roster, course prompt, readonly conversations and folder grants
await page.getByRole('link',{name:'Course roster and conversations'}).click();await expect(page).toHaveTitle('Stem Splitter · Courses');await expect(page.locator('#roster-list')).toContainText('Zero split student · 0 splits');
await page.locator('#roster-list button').first().click();await expect(page.locator('#student-jobs')).toContainText('A long course recording');await page.getByRole('button',{name:'Read conversation',exact:true}).click();await expect(page.locator('#conversation-messages')).toContainText('No retained conversation');
await page.getByRole('button',{name:'Open folder',exact:true}).click();await expect(page.locator('#folder-items')).toContainText('Course recording');await page.locator('#folder-permission').selectOption('read');await page.getByRole('button',{name:'Save sharing',exact:true}).click();await expect(page.locator('#folder-status')).toContainText('Sharing updated');
- await expect(page.locator('.top-return a')).toHaveText('Back to Splitter');await expect(page.locator('.bottom-help a')).toHaveAttribute('href','mailto:ailab@gc.cuny.edu');expect(await page.locator('.bottom-help hr').count()).toBe(2);
+ await expect(page.locator('.top-return a')).toHaveText('Back to Splitter');await expect(page.locator('.bug-report a')).toHaveAttribute('href','mailto:ailab@gc.cuny.edu');await expect(page.locator('.bug-report > summary')).toBeVisible();expect(await page.locator('.bug-report').evaluate(el=>[getComputedStyle(el).borderTopWidth,getComputedStyle(el).borderBottomWidth])).toEqual(['1px','1px']);
for(const width of [320,360,390,414,540,768,1024,1440]){await page.setViewportSize({width,height:1000});expect((await page.locator('#classroom-course').boundingBox()).height).toBeGreaterThanOrEqual(44);expect((await page.locator('#folder-permission').boundingBox()).height).toBeGreaterThanOrEqual(44);const bounds=await page.evaluate(()=>({width:innerWidth,scroll:document.documentElement.scrollWidth,intrinsic:[...document.querySelectorAll('main *')].filter(e=>e.scrollWidth>e.clientWidth+1).map(e=>({tag:e.tagName,id:e.id,width:e.clientWidth,scroll:e.scrollWidth})),overflow:[...document.querySelectorAll('main *')].filter(e=>e.getBoundingClientRect().right>innerWidth+.5).map(e=>({tag:e.tagName,id:e.id,class:e.className,right:e.getBoundingClientRect().right}))}));expect(bounds.scroll,JSON.stringify(bounds)).toBeLessThanOrEqual(bounds.width);}
await expect(page.locator('main')).toHaveCount(1);await expect(page.locator('h1')).toHaveCount(1);
await page.evaluate(()=>window.scrollTo(0,0));await page.evaluate(()=>document.fonts.ready);
diff --git a/cloudflare/classroom.test.ts b/cloudflare/classroom.test.ts
index 8421c3a..4acfed9 100644
--- a/cloudflare/classroom.test.ts
+++ b/cloudflare/classroom.test.ts
@@ -14,6 +14,8 @@ import { hashSystemPromptFingerprint } from '../src/assistant/prompt.ts';
import { authorizeStoredCourseWork } from '../src/classroom/access.ts';
import { saveCoursePrompt } from '../src/classroom/prompts.ts';
import { purgeExpiredListeningConversations } from './retention.ts';
+import { assistantReceipt, reserveAssistant } from '../src/reliability/assistant.ts';
+import { recoverExpired } from '../src/reliability/queue.ts';
const B='another-reviewed-course';
const ids={alice:`cail-${'1'.repeat(32)}`,bob:`cail-${'2'.repeat(32)}`,ownerA:`cail-${'3'.repeat(32)}`,ownerB:`cail-${'4'.repeat(32)}`,outsider:`cail-${'5'.repeat(32)}`,admin:`cail-${'6'.repeat(32)}`};
type Person=keyof typeof ids;
@@ -97,6 +99,48 @@ test('course chat ignores forged assistant history, saves provenance and runs no
const page=await(await request(`/api/classroom/courses/${A}/jobs/a/conversation`,'ownerA')).json() as any;
assert.deepEqual(page.entries.map((m:any)=>m.provenance),['student','server-assistant']);assert.doesNotMatch(JSON.stringify(page),/Forged answer/);assert.equal(calls(),1);
});
+
+test('reset during an in-flight chat fences late deltas, receipts and notes without retaining a ledger transcript',async()=>{
+ const {env,request,job,db}=await setup();await job('reset-stream');
+ let release!:()=>void,started!:()=>void,calls=0;
+ const waiting=new Promise(resolve=>{release=resolve;});const ready=new Promise(resolve=>{started=resolve;});
+ env.assistantTransport=async(_env,_input,delta)=>{calls++;started();await waiting;await delta('Late private transcript');return {content:'Late private transcript',model:'fixture',toolCalls:[],finishReason:'stop'};};
+ const body={messageId:'reset-stream-message-0001',revision:0,messages:[{role:'user',content:'Private input'}]};
+ const response=await request('/api/jobs/reset-stream/chat','alice','POST',body);const text=response.text();await ready;
+ assert.equal((await request('/api/jobs/reset-stream/listening-conversation','alice','DELETE',{revision:1})).status,200);
+ release();assert.doesNotMatch(await text,/Late private transcript/);
+ const state=await conversationPage(env,'reset-stream',ids.alice);assert.deepEqual(state.entries,[]);
+ const operations=(await db.prepare('SELECT state,result_json,request_json FROM app_operations').all()).results;
+ assert.equal(operations[0].state,'failed');assert.doesNotMatch(JSON.stringify(operations),/Late private|Private input/);
+ assert.equal((await request('/api/jobs/reset-stream/chat','alice','POST',body)).status,409);assert.equal(calls,1);
+});
+test('the operation deadline fences course effects before the longer conversation lease expires',async()=>{
+ for(const recovered of [false,true]) {
+ const {env,job,db,principal}=await setup();await job('deadline');const p=await principal();
+ const {operation}=await reserveAssistant(env,p.subject,A,'chat','operation-fenced-turn-01','deadline',[]);
+ const receipt=assistantReceipt(env,operation),turn=await beginCourseTurn(env,'deadline',p,'Listen','operation-fenced-turn-01',0);
+ receipt.bindCourseClaim(turn.claimId);await receipt.effect();
+ if(recovered)await recoverExpired(db as unknown as D1Database,Date.now()+90001);
+ else await db.prepare('UPDATE app_operations SET lease_until=0 WHERE id=?').bind(operation.id).run();
+ const result={reply:'Late private result',toolCalls:[{name:'add_note' as const,args:{seconds:1,text:'Late note'}}],finishReason:'stop'};
+ assert.equal(await finishCourseTurn(env,'deadline',p.subject,turn.turnId,turn.claimId,result,false,p.displayName,operation.id),null);
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM annotations').first())!.n,0);
+ assert.deepEqual((await conversationPage(env,'deadline',p.subject)).entries.map(e=>e.text),['Listen']);
+ await assert.rejects(receipt.complete(result.reply),/input ended/);
+ await assert.rejects(receipt.delta('Late delta'),/input ended/);
+ }
+});
+test('reset between durable course completion and final delivery invalidates the completion receipt',async()=>{
+ const {env,job,principal}=await setup();await job('reset-finish');const p=await principal();
+ const {operation}=await reserveAssistant(env,p.subject,A,'chat','reset-finish-turn-0001','reset-finish',[]);
+ const receipt=assistantReceipt(env,operation),turn=await beginCourseTurn(env,'reset-finish',p,'Listen','reset-finish-turn-0001',0);
+ receipt.bindCourseClaim(turn.claimId);await receipt.effect();
+ const revision=await finishCourseTurn(env,'reset-finish',p.subject,turn.turnId,turn.claimId,{reply:'Private answer',toolCalls:[],finishReason:'stop'},false,p.displayName,operation.id);
+ assert.equal(revision,2);await resetCourseConversation(env,'reset-finish',p.subject,revision);
+ await assert.rejects(receipt.complete('Private answer'),/input ended/);
+ await assert.rejects(receipt.delta('Late delta'),/input ended/);
+ assert.deepEqual((await conversationPage(env,'reset-finish',p.subject)).entries,[]);
+});
test('course prompt writes conflict, preserve immutable history and invalidate only matching caches',async()=>{
const {env,job,db}=await setup();await job('a');await job('b','alice',B);await job('personal','alice',null);const empty=await hashSystemPromptFingerprint();
for(const [id,scope] of [['a',A],['b',B],['personal',null]] as const)assert.equal(await cacheGuideIfPromptCurrent({...env,ASSISTANT_COURSE_ID:scope},{jobId:id,text:'Old guide',model:'fixture',createdAt:new Date().toISOString()},0,empty),true);
@@ -134,6 +178,10 @@ test('fresh and additive migration preserve private history, no backfill; retain
const db=new SqliteD1(':memory:');db.applySchema(legacy);await db.prepare('INSERT INTO app_users(subject) VALUES(?)').bind(ids.alice).run();await db.prepare("INSERT INTO jobs(id,filename,source_key,status) VALUES('old','Old private','uploads/old/source.wav','done')").run();await db.prepare('INSERT INTO job_owners VALUES(?,?)').bind('old',ids.alice).run();await db.prepare("INSERT INTO listening_conversations(job_id,subject,entries,revision,expires_at) VALUES(?,?,'[{\"kind\":\"coach\",\"text\":\"Private history\"}]',3,datetime('now','+90 days'))").bind('old',ids.alice).run();
await db.prepare("INSERT INTO annotations(id,job_id,at_seconds,text) VALUES('legacy-note','old',1,'Legacy private note')").run();
const migration=readFileSync(new URL('../migrations/0021-classroom.sql',import.meta.url),'utf8');db.applySchema(migration);
+ await db.prepare("INSERT INTO app_request_reservations(id,subject,scope,day) VALUES('historical-attempt',?,'split','2026-10-05')").bind(ids.alice).run();
+ const reliability=readFileSync(new URL('../migrations/0022-reliable-operations.sql',import.meta.url),'utf8');db.applySchema(reliability);db.applySchema(reliability);
+ assert.equal((await db.prepare("SELECT COUNT(*) AS n FROM app_request_reservations WHERE id='historical-attempt'").first()).n,1);
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM app_operations').first()).n,0);
const clean=new SqliteD1(':memory:');clean.applySchema(fresh);clean.applySchema(fresh);
assert.throws(()=>db.applySchema(migration),/duplicate column name/);
assert.equal((await db.prepare("SELECT text,author_subject FROM annotations WHERE id='legacy-note'").first()).author_subject,null);
diff --git a/cloudflare/daily-allowance.test.ts b/cloudflare/daily-allowance.test.ts
index a207c7d..f456fc2 100644
--- a/cloudflare/daily-allowance.test.ts
+++ b/cloudflare/daily-allowance.test.ts
@@ -1,60 +1,81 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import { TEST_SUBJECTS } from '@cuny-ai-lab/cail-identity/testing';
+import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { SqliteD1 } from '../server/d1.ts';
-import { dailyWindow, reserveDailyRequest, splitAllowance } from '../src/daily-allowance.ts';
-
-async function setup() {
- const db = new SqliteD1(':memory:');
- db.applySchema(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));
- for (const subject of Object.values(TEST_SUBJECTS)) {
- await db.prepare('INSERT OR IGNORE INTO app_users (subject) VALUES (?)').bind(subject).run();
- }
- return db as unknown as D1Database;
+import { dailyWindow, operationAllowance, splitAllowance } from '../src/daily-allowance.ts';
+import { OperationError, readOperation, reserveOperation, settleOperation } from '../src/reliability/ledger.ts';
+import { claimNext, recoverExpired, transition, QUEUE_CAPS } from '../src/reliability/queue.ts';
+import { retryAt } from '../src/reliability/retry.ts';
+const alice='cail-'+'a'.repeat(32),bob='cail-'+'b'.repeat(32);
+const now=Date.parse('2026-12-31T23:59:59.999Z');
+function setup(t:{after:(f:()=>void)=>void}) {
+ const dir=mkdtempSync(join(tmpdir(),'stem-ledger-')),left=new SqliteD1(join(dir,'db.sqlite'));
+ left.applySchema(readFileSync(new URL('../schema.sql',import.meta.url),'utf8'));
+ const right=new SqliteD1(join(dir,'db.sqlite'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
+ return [left,right] as unknown as D1Database[];
}
+async function user(db:D1Database,subject:string){await db.prepare('INSERT OR IGNORE INTO app_users(subject) VALUES(?)').bind(subject).run();}
+const reserve=(db:D1Database,i:number,subject=alice,kind:'split'|'chat'='split',at=now)=>reserveOperation(db,{subject,courseId:i%2?'course-a':'course-b',kind,key:`request-number-${String(i).padStart(4,'0')}`,fingerprint:`fingerprint-${i}`,phase:kind==='split'?'split':'chat',now:at});
-test('ten signed-in requests each, with no shared split cap and no client-selected reset', async () => {
- const db = await setup();
- const today = new Date('2026-09-29T20:00:00Z');
- for (const subject of [TEST_SUBJECTS.alice, TEST_SUBJECTS.bob, TEST_SUBJECTS.carol]) {
- const reservations = await Promise.all(Array.from({ length: 30 }, () => reserveDailyRequest(db, subject, 'split', today)));
- assert.equal(reservations.filter(result => result.allowed).length, 10);
- assert.deepEqual(await splitAllowance(db, subject, today), { limit: 10, used: 10, remaining: 0, resetsAt: '2026-09-30T00:00:00.000Z' });
- }
- assert.equal((await db.prepare("SELECT COUNT(*) AS n FROM app_request_reservations WHERE scope = 'split'").first<{ n: number }>())?.n, 30);
+test('two connections atomically admit fifteen across courses; failures release exactly once',async t=>{
+ const db=setup(t);for(const u of [alice,bob])await user(db[0],u);
+ const results=await Promise.allSettled(Array.from({length:30},(_,i)=>reserve(db[i%2],i)));
+ const admitted=results.flatMap(r=>r.status==='fulfilled'?[r.value.operation]:[]);assert.equal(admitted.length,15);
+ await Promise.all(admitted.map((op,i)=>settleOperation(db[i%2],op.id,i<7?'succeeded':'failed')));
+ await Promise.all(admitted.slice(7).map(op=>settleOperation(db[1],op.id,'failed')));
+ assert.deepEqual(await splitAllowance(db[0],alice,new Date(now)),{limit:15,completed:7,inProgress:0,remaining:8,resetsAt:'2027-01-01T00:00:00.000Z'});
+ const replacements=await Promise.allSettled(Array.from({length:20},(_,i)=>reserve(db[i%2],100+i)));assert.equal(replacements.filter(r=>r.status==='fulfilled').length,8);
+ await reserve(db[1],400,bob);assert.equal((await splitAllowance(db[1],bob,new Date(now))).remaining,14);
+ assert.equal(await settleOperation(db[0],admitted[8].id,'succeeded'),false);
});
-
-test('resets at UTC midnight, independently of DST, month/year boundaries and old reservations', async () => {
- const db = await setup();
- const before = new Date('2026-12-31T23:59:59.999Z');
- assert.deepEqual(dailyWindow(before), { day: '2026-12-31', resetsAt: '2027-01-01T00:00:00.000Z', retryAfter: 1 });
- assert.equal(dailyWindow(new Date('2026-11-01T00:00:00Z')).retryAfter, 86400);
- await Promise.all(Array.from({ length: 10 }, () => reserveDailyRequest(db, TEST_SUBJECTS.alice, 'split', before)));
- assert.equal((await reserveDailyRequest(db, TEST_SUBJECTS.alice, 'split', before)).allowed, false);
- const after = new Date('2027-01-01T00:00:00Z');
- assert.equal((await splitAllowance(db, TEST_SUBJECTS.alice, after)).remaining, 10);
- assert.equal((await reserveDailyRequest(db, TEST_SUBJECTS.alice, 'split', after)).allowed, true);
- assert.equal((await splitAllowance(db, TEST_SUBJECTS.alice, after)).remaining, 9);
+test('sixty human messages admit fifty; one replay/partial/failure uses one reservation',async t=>{
+ const db=setup(t);await user(db[0],alice);
+ const results=await Promise.allSettled(Array.from({length:60},(_,i)=>reserve(db[i%2],i,alice,'chat')));assert.equal(results.filter(r=>r.status==='fulfilled').length,50);
+ const first=await reserve(db[1],0,alice,'chat');assert.equal(first.created,false);
+ await assert.rejects(reserveOperation(db[1],{subject:alice,courseId:'course-b',kind:'chat',key:'request-number-0000',fingerprint:'different',phase:'chat',now}),OperationError);
+ await settleOperation(db[0],first.operation.id,'partial',null,{text:'Partial answer'});
+ const second=await reserve(db[0],1,alice,'chat');await settleOperation(db[0],second.operation.id,'failed');
+ const count=await operationAllowance(db[0],alice,'chat',new Date(now));assert.equal(count.completed,1);assert.equal(count.inProgress,48);assert.equal(count.remaining,1);
+ await reserve(db[1],70,alice,'chat');await assert.rejects(reserve(db[0],71,alice,'chat'),OperationError);
});
-
-test('legacy guide guards remain independent of splits, including its shared ceiling', async () => {
- const db = await setup();
- const now = new Date('2026-09-29T20:00:00Z');
- const day = dailyWindow(now).day;
- for (let i = 0; i < 499; i++) {
- await db.prepare('INSERT INTO app_request_reservations (id, subject, scope, day) VALUES (?, ?, ?, ?)')
- .bind(`fixture-${i}`, TEST_SUBJECTS.bob, 'guide', day).run();
- }
- assert.equal((await reserveDailyRequest(db, TEST_SUBJECTS.bob, 'guide', now)).allowed, false);
- assert.equal((await reserveDailyRequest(db, TEST_SUBJECTS.alice, 'guide', now)).allowed, true);
- assert.equal((await reserveDailyRequest(db, TEST_SUBJECTS.alice, 'guide', now)).allowed, false);
- assert.equal((await reserveDailyRequest(db, TEST_SUBJECTS.alice, 'split', now)).allowed, true);
- assert.equal((await splitAllowance(db, TEST_SUBJECTS.alice, now)).remaining, 9);
+test('submission-day accounting survives UTC midnight/year rollover and DST',async t=>{
+ const db=setup(t);await user(db[0],alice);
+ assert.deepEqual(dailyWindow(new Date(now)),{day:'2026-12-31',resetsAt:'2027-01-01T00:00:00.000Z',retryAfter:1});assert.equal(dailyWindow(new Date('2026-11-01T00:00:00Z')).retryAfter,86400);
+ const op=await reserve(db[0],0);await settleOperation(db[1],op.operation.id,'succeeded');
+ assert.equal((await splitAllowance(db[0],alice,new Date(now))).completed,1);assert.equal((await splitAllowance(db[0],alice,new Date(now+1))).remaining,15);
+ await assert.rejects(db[0].prepare("UPDATE app_operations SET day='2027-01-01' WHERE id=?").bind(op.operation.id).run());
});
-
-test('counter/storage failures never grant or report unused requests', async () => {
- const db = { prepare: () => { throw new Error('fixture unavailable'); } } as unknown as D1Database;
- await assert.rejects(reserveDailyRequest(db, TEST_SUBJECTS.alice, 'split'));
- await assert.rejects(splitAllowance(db, TEST_SUBJECTS.alice));
+test('twenty students with two coordinators make fair progress within global/per-user caps',async t=>{
+ const db=setup(t),seen=new Set();
+ for(let i=0;i<20;i++){const subject='cail-'+String(i).padStart(32,'0');await user(db[0],subject);await reserve(db[i%2],i,subject);await reserve(db[(i+1)%2],i+30,subject);}
+ for(let i=0;i<20;i++){
+ const claims=(await Promise.all([claimNext(db[0],'split',now+1),claimNext(db[1],'split',now+1)])).filter(Boolean);assert.equal(claims.length,QUEUE_CAPS.split);
+ const op=claims[0]!;assert.equal(seen.has(op.subject),false);seen.add(op.subject);await settleOperation(db[i%2],op.id,'succeeded');
+ }
+ assert.equal(seen.size,20);
+});
+test('lost starts retain capacity; expiry fences old Worker completions without another POST',async t=>{
+ const db=setup(t);await user(db[0],alice);await reserve(db[0],1);
+ const op=(await claimNext(db[0],'split',now+1))!;await transition(db[0],op,'starting');
+ await db[0].prepare("INSERT INTO operation_attempts(id,operation_id,phase,provider,outcome,created_at,updated_at) VALUES('attempt',?,'split','replicate','starting',?,?)").bind(op.id,now,now).run();
+ await recoverExpired(db[1],now+600001);assert.equal((await readOperation(db[1],op.id))?.state,'reconciling');assert.equal(await transition(db[0],op,'succeeded'),false);
+ assert.equal(await claimNext(db[0],'split',now+600002),null);assert.equal((await splitAllowance(db[0],alice,new Date(now))).inProgress,1);
+});
+test('Retry-After 90/date never shortens server floor; malformed headers use bounded jitter',()=>{
+ assert.equal(retryAt('90',now),now+90000);const date=new Date(now+120001).toUTCString();assert.equal(retryAt(date,now),Date.parse(date));
+ assert.equal(retryAt(null,now,1,()=>0),now+2000);assert.equal(retryAt('garbage',now,1,()=>0.5),now+3000);assert.equal(retryAt('-1',now,0,()=>0),now+1000);
+});
+test('quota rejection/duplicate key create no orphan jobs; late success cannot revive failure',async t=>{
+ const db=setup(t);await user(db[0],alice);
+ const submit=(id:string,key:string)=>reserveOperation(db[0],{subject:alice,courseId:null,kind:'split',key,fingerprint:key,phase:'split',jobId:id,now,statements:operationId=>[db[0].prepare("INSERT INTO jobs(id,filename,source_key) SELECT id,'fixture','uploads/fixture/a.mp3' FROM app_operations WHERE id=?").bind(operationId)]});
+ await submit('job-a','request-duplicate-0001');await submit('job-b','request-duplicate-0001');for(let i=0;i<14;i++)await reserve(db[i%2],i+100);
+ await assert.rejects(submit('job-full','request-quota-full-01'),OperationError);assert.equal((await db[0].prepare('SELECT COUNT(*) AS n FROM jobs').first<{n:number}>())?.n,1);
+ await db[0].prepare("UPDATE jobs SET status='failed' WHERE id='job-a'").run();await assert.rejects(db[0].prepare("UPDATE jobs SET status='done' WHERE id='job-a'").run());
+});
+test('migration keeps old attempt evidence separate and never invents successful charges',async t=>{
+ const db=setup(t);await user(db[0],alice);await db[0].prepare("INSERT INTO app_request_reservations(id,subject,scope,day) VALUES('old',?,'split','2026-12-31')").bind(alice).run();
+ (db[0] as unknown as SqliteD1).applySchema(readFileSync(new URL('../migrations/0022-reliable-operations.sql',import.meta.url),'utf8'));
+ assert.equal((await splitAllowance(db[0],alice,new Date(now))).completed,0);assert.equal((await db[0].prepare('SELECT COUNT(*) AS n FROM app_request_reservations').first<{n:number}>())?.n,1);
});
diff --git a/cloudflare/gateway.test.ts b/cloudflare/gateway.test.ts
index 37fbd6f..518c484 100644
--- a/cloudflare/gateway.test.ts
+++ b/cloudflare/gateway.test.ts
@@ -61,3 +61,37 @@ test('unavailable aggregate quota stays unavailable and is never a local permiss
const transport = gatewayForRequest({ fetch: async () => { calls++; return Response.json({ error: { code: 'quota_unavailable', message: 'Unavailable' } }, { status: 503 }); } } as unknown as Fetcher, 'gateway-leg', request(), 'test');
await assert.rejects(transport.quota()); assert.equal(calls, 1);
});
+
+test('ordered fallback uses only documented capacity rejections; policy/quota/auth/unknown do not fall back',async()=>{
+ for(const [code,status,shouldRetry,fallback] of [
+ ['upstream_rate_limited',429,true,true],['upstream_unavailable',503,true,true],['model_unavailable',503,true,true],
+ ['quota_exceeded',429,false,false],['rate_limit_unknown',429,false,false],['upstream_auth_error',401,false,false],
+ ['upstream_payment_required',402,false,false],['upstream_timeout',504,false,false],['upstream_unavailable',503,false,false],
+ ] as const) {
+ const models:string[]=[];
+ const transport=gatewayForRequest({fetch:async(outbound:Request)=>{
+ assert.equal(outbound.headers.get('x-cail-identity-jwt'),'same-verified-subject');
+ const body=await outbound.json() as {model:string;messages:unknown[]};models.push(body.model);assert.deepEqual(body.messages,params.messages);
+ if(models.length===1)return Response.json({error:{code,type:code,param:null,message:'DO NOT DISPLAY',cail:{should_retry:shouldRetry}}},{status,headers:{'x-should-retry':String(shouldRetry)}});
+ return sse([{choices:[{delta:{content:'A calm backup answer.'},finish_reason:null}]},finished]);
+ }} as unknown as Fetcher,'same-verified-subject',request(),'test');
+ const pending=transport.stream({...env,ASSISTANT_FALLBACK_MODELS:'deepseek-v4-flash-0731'},params,()=>{});
+ if(fallback){assert.equal((await pending).model,'deepseek-v4-flash-0731');assert.deepEqual(models,['glm-5.2','deepseek-v4-flash-0731']);}
+ else {await assert.rejects(pending,AssistantError);assert.deepEqual(models,['glm-5.2']);}
+ }
+});
+test('both failures stop at two, stream/tool output forbids fallback, and mis-mapped backup fails closed',async()=>{
+ for(const mode of ['both','partial','tools','trailing']) {
+ let calls=0;
+ const error={error:{code:'upstream_unavailable',type:'upstream_unavailable',param:null,message:'secret',cail:{should_retry:true}}};
+ const transport=gatewayForRequest({fetch:async()=>{calls++;
+ if(mode==='both')return Response.json(error,{status:503,headers:{'x-should-retry':'true'}});
+ const delta=mode==='tools'?{tool_calls:[{index:0,id:'a',function:{name:'solo',arguments:'{"stem":"vocals"}'}}]}:{content:'Partial.'};
+ return sse([{choices:[{delta,finish_reason:null}]},...(mode==='trailing'?[finished]:[]),error]);
+ }} as unknown as Fetcher,'identity',request(),'test');
+ await assert.rejects(transport.stream({...env,ASSISTANT_FALLBACK_MODELS:'deepseek-v4-flash-0731'},params,()=>{}),AssistantError);
+ assert.equal(calls,mode==='both'?2:1);
+ }
+ let calls=0;const transport=gatewayForRequest({fetch:async()=>{calls++;return sse([finished]);}} as unknown as Fetcher,'identity',request(),'test');
+ await assert.rejects(transport.stream({...env,ASSISTANT_FALLBACK_MODELS:'deepseek-flash'},params,()=>{}),AssistantError);assert.equal(calls,0);
+});
diff --git a/cloudflare/gateway.ts b/cloudflare/gateway.ts
index b4c441b..797078c 100644
--- a/cloudflare/gateway.ts
+++ b/cloudflare/gateway.ts
@@ -4,9 +4,18 @@ import { AssistantError, COACH_DOWN, COACH_UNCONFIGURED, type OpenRouterParams,
import type { WireStreamChunk, WireToolCall } from '../src/assistant/types.ts';
import type { Env } from '../src/env.ts';
import { boundedText, cancel, deadline, withSignal } from './bounded.ts';
+import { beginAttempt, finishAttempt, readOperation } from '../src/reliability/ledger.ts';
+import { cooldown, retryAt, setCooldown } from '../src/reliability/retry.ts';
export const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
export const canonicalModel = (value: unknown): value is string => typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value);
+export const GATEWAY_MODELS = ['glm-5.2', 'deepseek-v4-flash-0731'] as const;
+export const gatewayModelsConfigured = (primary: unknown, backup: unknown) => primary === GATEWAY_MODELS[0] && backup === GATEWAY_MODELS[1];
+export function eligibleGatewayFallback(error: unknown) {
+ return error instanceof CailError && error.extras.should_retry === true &&
+ (error.code==='upstream_rate_limited' && error.status===429 ||
+ ['upstream_unavailable','model_unavailable'].includes(error.code) && error.status===503);
+}
const catalog = defineEventCatalog({
'stem-splitter.gateway.completed': { source: 'tenant', severity: 'outcome', required: ['request_id', 'terminal'], optional: [] },
});
@@ -22,7 +31,7 @@ function safeFailure(error: unknown, fallback: string): AssistantError {
return new AssistantError(error instanceof CailError && error.status === 429 ? 503 : 502, message, { requestId, code, shouldRetry });
}
-export function gatewayForRequest(binding: Fetcher | undefined, token: string | null, request: Request, release: string) {
+export function gatewayForRequest(binding: Fetcher | undefined, token: string | null, request: Request, release: string, credentialKind: 'jwt' | 'key' = 'jwt') {
const headers = new Headers(request.headers);
const incoming = headers.get('x-cail-request-id') ?? headers.get('x-request-id');
if (incoming && REQUEST_ID.test(incoming)) headers.set('x-cail-request-id', incoming);
@@ -45,16 +54,27 @@ export function gatewayForRequest(binding: Fetcher | undefined, token: string |
const stream = async (env: Env, params: OpenRouterParams, onDelta: (text: string) => void | Promise): Promise =>
deadline(async signal => {
+ if (!binding || !token || !canonicalModel(env.ASSISTANT_MODEL) ||
+ (env.ASSISTANT_FALLBACK_MODELS !== undefined && !gatewayModelsConfigured(env.ASSISTANT_MODEL, env.ASSISTANT_FALLBACK_MODELS))) throw new AssistantError(503,COACH_UNCONFIGURED);
+ const models = env.ASSISTANT_FALLBACK_MODELS ? GATEWAY_MODELS : [env.ASSISTANT_MODEL];
+ for (const [modelIndex, model] of models.entries()) {
let reader: ReadableStreamDefaultReader | undefined;
+ let accepted = false, outputStarted = false;
+ let attemptId: string | null = null, usage: unknown;
+ const operation = env.ASSISTANT_OPERATION_ID ? await readOperation(env.DB,env.ASSISTANT_OPERATION_ID) : null;
let requestId = correlation.request_id;
let outcome: 'ok' | 'cancelled' | 'error' = 'error';
try {
- if (!binding || !token || !canonicalModel(env.ASSISTANT_MODEL)) throw new AssistantError(503, COACH_UNCONFIGURED);
+ if (operation) {
+ if (await cooldown(env.DB,`gateway:${model}`)>Date.now()) throw new AssistantError(503,COACH_DOWN,{code:'model_cooldown',shouldRetry:true});
+ attemptId=await beginAttempt(env.DB,operation,'cail-gateway',model);
+ }
const response = await client.chatCompletions({
- model: env.ASSISTANT_MODEL, messages: params.messages.map(({ role, content }) => ({ role, content })),
+ model, messages: params.messages.map(({ role, content }) => ({ role, content })),
...(params.tools?.length ? { tools: params.tools.map(tool => ({ type: tool.type, function: { ...tool.function, parameters: JSON.parse(JSON.stringify(tool.function.parameters)) } })), tool_choice: 'auto' } : {}),
stream: true, stream_options: { include_usage: true }, max_tokens: params.maxTokens, temperature: params.temperature,
- }, { kind: 'jwt', token }, { signal, correlation });
+ }, { kind: credentialKind, token }, { signal, correlation });
+ accepted = true;
const responseId = response.headers.get('x-request-id') ?? response.headers.get('x-cail-request-id');
if (responseId && REQUEST_ID.test(responseId)) requestId = responseId;
reader = response.body?.getReader();
@@ -71,16 +91,19 @@ export function gatewayForRequest(binding: Fetcher | undefined, token: string |
if (error) throw error;
if (!value || typeof value !== 'object' || 'error' in value) throw new AssistantError(502, COACH_DOWN);
const chunk = value as WireStreamChunk;
+ if ('usage' in value) usage=(value as {usage:unknown}).usage;
const choice = chunk.choices?.[0];
if (!choice) return; // Trailing accounting events still get drained.
if (choice.finish_reason === 'error') throw new AssistantError(502, COACH_DOWN);
if (choice.finish_reason) finishReason = choice.finish_reason;
if (choice.delta?.content) {
if (typeof choice.delta.content !== 'string') throw new AssistantError(502, COACH_DOWN);
+ outputStarted = true;
content += choice.delta.content;
await withSignal(Promise.resolve(onDelta(choice.delta.content)), signal);
}
for (const delta of choice.delta?.tool_calls ?? []) {
+ outputStarted = true;
if (!Number.isSafeInteger(delta.index) || delta.index! < 0 || delta.index! >= 32) throw new AssistantError(502, COACH_DOWN);
const slot = calls.get(delta.index!) ?? { type: 'function', function: { name: '', arguments: '' } };
if (delta.id) slot.id = delta.id;
@@ -101,9 +124,17 @@ export function gatewayForRequest(binding: Fetcher | undefined, token: string |
}
if (buffer.trim()) await consume(buffer);
if (!finishReason) throw new AssistantError(502, COACH_DOWN);
+ const toolIds=[...calls.values()].map(call=>call.id).filter(Boolean);
+ if (new Set(toolIds).size!==toolIds.length) throw new AssistantError(502,COACH_DOWN,{code:'invalid_tool_calls'});
outcome = 'ok';
- return { content: content.trim(), model: env.ASSISTANT_MODEL, toolCalls: [...calls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call), finishReason };
+ if (attemptId) await finishAttempt(env.DB,attemptId,'succeeded',{usage});
+ return { content: content.trim(), model, toolCalls: [...calls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call), finishReason };
} catch (error) {
+ if (attemptId) await finishAttempt(env.DB,attemptId,outputStarted?'partial':accepted||!(error instanceof CailError)?'uncertain':'rejected',{code:error instanceof CailError?error.code:'gateway_failure',status:error instanceof CailError?error.status:undefined,usage});
+ if (!signal.aborted && !accepted && !outputStarted && modelIndex===0 && models.length===2 && eligibleGatewayFallback(error)) {
+ if (operation) await setCooldown(env.DB,`gateway:${model}`,retryAt(error instanceof CailError ? String(error.extras.retry_after ?? error.extras.retry_after_seconds ?? '30') : '30'));
+ continue;
+ }
if (signal.aborted) {
if (signal.reason?.name === 'TimeoutError') throw new AssistantError(503, COACH_DOWN, { requestId, code: 'upstream_timeout', shouldRetry: true });
outcome = 'cancelled'; throw signal.reason;
@@ -117,8 +148,12 @@ export function gatewayForRequest(binding: Fetcher | undefined, token: string |
: { outcome: 'error', reason: 'upstream_failure' } as const;
log.emit('stem-splitter.gateway.completed', { request_id: requestId, terminal });
}
+ }
+ throw new AssistantError(503,COACH_DOWN);
}, env.ASSISTANT_ABORT_SIGNAL ?? request.signal, 60000);
return { stream, quota: () => deadline(async signal => {
+ // Anonymous guests must never inspect the sponsor application's balance.
+ if (credentialKind === 'key') throw new AssistantError(503,COACH_UNCONFIGURED);
if (!binding || !token) throw new AssistantError(503, COACH_UNCONFIGURED);
// cail-client validates the bounded quota envelope; it never grants access.
const quotaClient = createCailClient({ app: 'stem-splitter', fetchImpl: async (input, init) => {
diff --git a/cloudflare/guest.spec.mjs b/cloudflare/guest.spec.mjs
new file mode 100644
index 0000000..8775135
--- /dev/null
+++ b/cloudflare/guest.spec.mjs
@@ -0,0 +1,106 @@
+import { test,expect } from '@playwright/test';
+import { createTestHarness } from 'wrangler';
+import { createTestIdentityIssuer } from '@cuny-ai-lab/cail-identity/testing';
+import { readFile,mkdir } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { setupServer } from 'msw/node';
+import { http,HttpResponse } from 'msw';
+import { schemaStatements } from '../tests/e2e/schema-statements.mjs';
+test.use({ignoreHTTPSErrors:true});
+
+for(const chatAvailable of [true,false])test(`guest browser splits and resumes with Listening Guy ${chatAvailable?'available':'unavailable'}`,async({page,context})=>{
+ let origin='';const issuer=await createTestIdentityIssuer();
+ const mp3=await readFile(new URL('../tests/fixtures/audio/vocals.mp3',import.meta.url));
+ let providerStarts=0,courseRequests=0,holdAccount=false,releaseAccount;
+ const network=setupServer(
+ http.post('https://challenges.cloudflare.com/turnstile/v0/siteverify',()=>HttpResponse.json({success:true,hostname:new URL(origin).hostname,action:'stem_guest',challenge_ts:new Date().toISOString()})),
+ http.post('https://api.replicate.com/v1/predictions',()=>{providerStarts++;return HttpResponse.json({id:'browser-guest-prediction',status:'starting'});}),
+ http.get('https://api.replicate.com/v1/predictions/browser-guest-prediction',()=>HttpResponse.json({id:'browser-guest-prediction',status:'succeeded',output:Object.fromEntries(['vocals','drums','bass','other'].map(name=>[name,`https://fixtures.replicate.delivery/${name}.mp3`]))})),
+ http.get('https://fixtures.replicate.delivery/:name.mp3',()=>new HttpResponse(mp3,{headers:{'Content-Type':'audio/mpeg'}})),
+ );
+ network.listen({onUnhandledRequest:'error'});
+ const config=JSON.parse(await readFile(new URL('./test-wrangler.jsonc',import.meta.url),'utf8'));
+ const harness=createTestHarness({root:fileURLToPath(new URL('./',import.meta.url)),workers:[{config:{...config,dev:{local_protocol:'https'},
+ vars:{...config.vars,TEST_BROWSER:'true',TEST_JWKS:issuer.jwksJson,GUEST_ENABLED:'true',GUEST_COOKIE_SECRET:'browser-fixture-cookie-secret-0000000000',GUEST_TURNSTILE_SITE_KEY:'fixture-site-key',
+ GUEST_TURNSTILE_SECRET:'fixture-turnstile-secret-only',GUEST_GATEWAY_API_KEY:chatAvailable?'sk-cail-fixture-sponsor-key-only-000000':'',
+ REPLICATE_API_TOKEN:'guest-fixture-token',REPLICATE_MODEL_VERSION:'guest-fixture-pin',WEBHOOK_SECRET:'guest-fixture-webhook'},
+ }}]});
+ const errors=[];page.on('pageerror',error=>errors.push(error.message));
+ page.on('request',request=>{if(new URL(request.url()).pathname.startsWith('/api/classroom/'))courseRequests++;});
+ try {
+ const {url}=await harness.listen();origin=url.origin;expect(url.protocol).toBe('https:');const worker=harness.getWorker('stem-preview-contract-test');
+ await worker.fetch('/__fixture/schema',{method:'POST',headers:{'x-fixture':'local-only'},body:JSON.stringify(schemaStatements(await readFile(new URL('../schema.sql',import.meta.url),'utf8')))});
+ await page.route('https://fonts.googleapis.com/**',route=>route.fulfill({body:'',contentType:'text/css'}));
+ await page.route('https://fonts.gstatic.com/**',route=>route.abort());
+ await page.route('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',route=>route.fulfill({contentType:'text/javascript',body:`window.turnstile={render(root,options){const button=document.createElement('button');button.textContent='Complete fixture guest check';button.onclick=()=>options.callback('browser-fixture-'+crypto.randomUUID());root.append(button);return 'fixture';},reset(){}};`}));
+ await page.route('**/api/account',async route=>{
+ if(holdAccount){await new Promise(resolve=>{releaseAccount=resolve;});holdAccount=false;await route.fulfill({status:503,contentType:'application/json',body:'{"error":"fixture unavailable"}'});return;}
+ await route.continue();
+ });
+ await page.goto(origin);
+ await expect(page.getByRole('button',{name:'Continue as guest',exact:true})).toBeVisible();
+ await page.getByRole('button',{name:'Continue as guest',exact:true}).click();
+ await page.getByRole('button',{name:'Complete fixture guest check'}).click();
+ await expect(page.getByRole('link',{name:'My guest session',exact:true})).toBeVisible();
+ expect(courseRequests).toBe(0);
+ const uploaded=page.waitForResponse(response=>response.url().includes('/api/local-uploads/'));
+ await page.locator('#file-input').setInputFiles(fileURLToPath(new URL('../tests/fixtures/audio/source.wav',import.meta.url)));
+ const upload=await uploaded;expect(upload.status()).toBe(204);
+ await expect(page.locator('.console-title')).toHaveText('source.wav',{timeout:20000});
+ await expect(page.locator('.badge.ready')).toBeVisible({timeout:20000});
+ if(chatAvailable)await expect(page.locator('.coach-toggle')).toBeVisible();
+ else {
+ await expect(page.locator('.coach')).toBeHidden();
+ await expect(page.locator('.guest-coach-notice')).toContainText('Listening Guy is available with CUNY Login.');
+ }
+ expect(providerStarts).toBe(1);
+ await expect(page.locator('.share-btn:not(.to-remix-btn):not(.refresh-btn)')).toBeHidden();
+ await page.reload();
+ await expect(page.locator('.console-title')).toHaveText('source.wav');
+ await page.getByRole('link',{name:'My guest session',exact:true}).click();
+ await expect(page.locator('#account-splits')).toContainText('1 completed');
+ await expect(page.locator('#account-splits')).toContainText('of 5');
+ if(chatAvailable)await expect(page.locator('#account-chat')).toContainText('of 25');
+ else {
+ await expect(page.locator('#account-chat')).toContainText('Listening Guy is available with CUNY Login.');
+ await expect(page.locator('body')).not.toContainText('25 Listening Guy questions');
+ }
+ await expect(page.locator('#account-admin')).toBeHidden();
+ await expect(page.locator('#account-reference')).toBeHidden();
+ for(const width of [320,390,768,1280]){await page.setViewportSize({width,height:900});expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);}
+ await page.goto(origin);
+ await expect(page.locator('.console-title')).toHaveText('source.wav');
+ holdAccount=true;
+ await page.evaluate(()=>window.dispatchEvent(new Event('focus')));
+ await expect(page.locator('main')).toBeHidden();
+ await expect(page.locator('#session-check')).toContainText('Checking');
+ releaseAccount();
+ await expect(page.locator('#session-check')).toContainText('hidden until access is checked');
+ await expect(page.locator('main')).toBeHidden();
+ await page.getByRole('button',{name:'Retry',exact:true}).click();
+ await expect(page.locator('main')).toBeVisible();
+ await expect(page.locator('.console-title')).toHaveText('source.wav');
+ for(const width of [320,390,768,1280]){await page.setViewportSize({width,height:900});expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);}
+ await expect(page.locator('main')).toHaveCount(1);await expect(page.locator('h1')).toHaveCount(1);
+ if(process.env.STEM_SCREENSHOT_DIR){
+ await mkdir(process.env.STEM_SCREENSHOT_DIR,{recursive:true});
+ await page.evaluate(()=>{window.scrollTo(0,0);return new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
+ await page.screenshot({path:process.env.STEM_SCREENSHOT_DIR+'/guest-resumed-work-'+(process.env.STEM_BROWSER||'chrome')+'.png',animations:'disabled'});
+ await page.locator('.console').screenshot({path:process.env.STEM_SCREENSHOT_DIR+'/guest-owned-split-'+(process.env.STEM_BROWSER||'chrome')+'.png',animations:'disabled'});
+ }
+ const oldCookie=(await context.cookies()).find(cookie=>cookie.name==='__Host-stem-guest');
+ await context.clearCookies();
+ const second=await worker.fetch(origin+'/auth/guest',{method:'POST',headers:{Origin:origin,'Content-Type':'application/json'},body:JSON.stringify({token:'second-browser-guest'})});
+ expect(second.status).toBe(200);const cookie=second.headers.get('set-cookie').split(';')[0].split('=');
+ await context.addCookies([{name:cookie[0],value:cookie[1],url:origin,secure:true,httpOnly:true,sameSite:'Lax'}]);
+ await page.evaluate(()=>window.dispatchEvent(new Event('focus')));
+ await expect(page.locator('.console-title')).toHaveCount(0);
+ await expect(page.getByRole('link',{name:'My guest session',exact:true})).toBeVisible();
+ await page.getByRole('link',{name:'My guest session',exact:true}).click();
+ await expect(page.locator('#account-splits')).toContainText('0 completed');
+ await page.getByRole('button',{name:'End guest session',exact:true}).click();
+ await expect(page.getByRole('button',{name:'Continue as guest',exact:true})).toBeVisible();
+ expect((await context.cookies()).some(cookie=>cookie.name==='__Host-stem-guest')).toBe(false);
+ expect(oldCookie.value).not.toBe(cookie[1]);expect(errors).toEqual([]);
+ }finally{releaseAccount?.();await page.unrouteAll({behavior:'wait'});await page.close();await harness.close();network.close();}
+});
diff --git a/cloudflare/guest.test.ts b/cloudflare/guest.test.ts
new file mode 100644
index 0000000..282cb1f
--- /dev/null
+++ b/cloudflare/guest.test.ts
@@ -0,0 +1,235 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { createTestHarness } from 'wrangler';
+import { createTestIdentityIssuer, TEST_SUBJECTS } from '@cuny-ai-lab/cail-identity/testing';
+import { setupServer } from 'msw/node';
+import { http, HttpResponse } from 'msw';
+import { schemaStatements } from '../tests/e2e/schema-statements.mjs';
+import { GUEST_COOKIE, guestConfigured, guestChatConfigured, startGuestSession } from './guest.ts';
+import { readGuestSession } from './guest.ts';
+import { mkdtempSync,readFileSync,rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { SqliteD1 } from '../server/d1.ts';
+import { reserveOperation,settleOperation } from '../src/reliability/ledger.ts';
+import { dailyWindow,splitAllowance,operationAllowance } from '../src/daily-allowance.ts';
+import type { Env } from '../src/env.ts';
+
+const origin='https://split.test';
+const settings={GUEST_ENABLED:'true',GUEST_COOKIE_SECRET:'fixture-cookie-secret-'.repeat(3),GUEST_TURNSTILE_SITE_KEY:'fixture-site-key',
+ GUEST_TURNSTILE_SECRET:'fixture-turnstile-secret-only',GUEST_GATEWAY_API_KEY:'sk-cail-fixture-sponsor-key-only-000000'};
+
+test('guest splitting requires verification and ingress protection while Listening Guy is optional',()=>{
+ const env={...settings,GATEWAY:{},GATEWAY_MODEL:'glm-5.2',GATEWAY_FALLBACK_MODEL:'deepseek-v4-flash-0731',REQUEST_LIMIT:{}} as never;
+ assert.equal(guestConfigured(env),true);
+ assert.equal(guestChatConfigured(env),true);
+ for(const field of ['GUEST_COOKIE_SECRET','GUEST_TURNSTILE_SITE_KEY','GUEST_TURNSTILE_SECRET','REQUEST_LIMIT']) {
+ assert.equal(guestConfigured({...env,[field]:undefined,OPENROUTER_API_KEY:'old-provider-key'}),false,field);
+ }
+ assert.equal(guestConfigured({...env,GUEST_ENABLED:'false'}),false);
+ for(const field of ['GUEST_GATEWAY_API_KEY','GATEWAY','GATEWAY_MODEL','GATEWAY_FALLBACK_MODEL']) {
+ assert.equal(guestConfigured({...env,[field]:undefined}),true,field);
+ assert.equal(guestChatConfigured({...env,[field]:undefined,OPENROUTER_API_KEY:'old-provider-key'}),false,field);
+ }
+ assert.equal(guestChatConfigured({...env,GUEST_GATEWAY_API_KEY:'invalid'}),false);
+});
+
+test('guest-start rate rejection performs no database, verification or provider work',async t=>{
+ let databaseCalls=0,externalCalls=0;const ingressKeys:string[]=[];
+ t.mock.method(globalThis,'fetch',async()=>{externalCalls++;throw new Error('No external call expected');});
+ const env={...settings,GATEWAY:{},GATEWAY_MODEL:'glm-5.2',GATEWAY_FALLBACK_MODEL:'deepseek-v4-flash-0731',PUBLIC_BASE_URL:origin,
+ REQUEST_LIMIT:{limit:async({key}:{key:string})=>{ingressKeys.push(key);return {success:false};}},
+ DB:{prepare:()=>{databaseCalls++;throw new Error('No database call expected');}},
+ } as unknown as Parameters[1];
+ const response=await startGuestSession(new Request(origin+'/auth/guest',{method:'POST',headers:{Origin:origin,'Content-Type':'application/json','cf-connecting-ip':'192.0.2.1'},body:'{"token":"unused-fixture-proof"}'}),env);
+ assert.equal(response.status,429);assert.equal(response.headers.get('Retry-After'),'60');
+ assert.equal(response.headers.get('Set-Cookie'),null);assert.equal((await response.json() as any).error.code,'guest_rate_limited');
+ assert.deepEqual(ingressKeys,['stem-guest-start:192.0.2.1']);assert.equal(databaseCalls,0);assert.equal(externalCalls,0);
+});
+
+for(const chatAvailable of [true,false])test(`workerd guests: five successful splits, refunds and isolation with Listening Guy ${chatAvailable?'available':'unavailable'}`,async()=>{
+ const issuer=await createTestIdentityIssuer();
+ const member=TEST_SUBJECTS.alice;
+ const jwt=await issuer.mintIdentityJwt({audience:'cail:stem-splitter',subject:member});
+ const mp3=await readFile(new URL('../tests/fixtures/audio/vocals.mp3',import.meta.url));
+ const wav=await readFile(new URL('../tests/fixtures/audio/source.wav',import.meta.url));
+ let challengeMode='ok',predictionMode='success',starts=0,verifications=0;
+ const predictions=new Map();
+ const network=setupServer(
+ http.post('https://challenges.cloudflare.com/turnstile/v0/siteverify',async({request})=>{
+ const body=await request.json() as {secret:string;response:string};
+ assert.equal(body.secret,settings.GUEST_TURNSTILE_SECRET);verifications++;
+ return HttpResponse.json({success:challengeMode!=='failed',hostname:challengeMode==='wrong-host'?'other.test':'split.test',
+ action:challengeMode==='wrong-action'?'other':'stem_guest',challenge_ts:new Date(Date.now()-(challengeMode==='expired'?400000:0)).toISOString()});
+ }),
+ http.post('https://api.replicate.com/v1/predictions',async({request})=>{
+ const input=await request.json() as {version:string};assert.equal(input.version,'guest-fixture-pin');
+ const id=`guest-prediction-${++starts}`;predictions.set(id,predictionMode);return HttpResponse.json({id,status:'starting'});
+ }),
+ http.get('https://api.replicate.com/v1/predictions/:id',({params})=>HttpResponse.json({id:params.id,
+ status:predictions.get(String(params.id))==='failed'?'failed':'succeeded',output:Object.fromEntries(['vocals','drums','bass','other'].map(name=>[name,`https://fixtures.replicate.delivery/${name}.mp3`]))})),
+ http.get('https://fixtures.replicate.delivery/:name.mp3',()=>new HttpResponse(mp3,{headers:{'Content-Type':'audio/mpeg'}})),
+ );
+ network.listen({onUnhandledRequest:'error'});
+ const harness=createTestHarness({workers:[{configPath:fileURLToPath(new URL('./test-wrangler.jsonc',import.meta.url)),
+ vars:{TEST_JWKS:issuer.jwksJson,...settings,GUEST_GATEWAY_API_KEY:chatAvailable?settings.GUEST_GATEWAY_API_KEY:''},secrets:{REPLICATE_API_TOKEN:'guest-fixture-token',REPLICATE_MODEL_VERSION:'guest-fixture-pin',WEBHOOK_SECRET:'guest-fixture-webhook'}}]});
+ try {
+ await harness.listen();const worker=harness.getWorker('stem-preview-contract-test');
+ async function sql(statements:string[]){assert.equal((await worker.fetch('/__fixture/schema',{method:'POST',headers:{'x-fixture':'local-only'},body:JSON.stringify(statements)})).status,200);}
+ await sql(schemaStatements(await readFile(new URL('../schema.sql',import.meta.url),'utf8')));
+ const call=(path:string,cookie='',init:RequestInit={})=>worker.fetch(origin+path,{redirect:'manual',...init,headers:{Origin:origin,'Content-Type':'application/json',...(cookie?{Cookie:cookie}:{}),...init.headers}});
+ const begin=(token:string,cookie='',headers={})=>call('/auth/guest',cookie,{method:'POST',body:JSON.stringify({token}),headers});
+ assert.equal((await call('/api/account')).status,401);
+ assert.equal((await begin('cross-site','',{Origin:'https://attacker.test'})).status,403);
+ assert.equal(verifications,0);
+ for(challengeMode of ['failed','wrong-host','wrong-action','expired']){const check=await begin(challengeMode);assert.equal(check.status,403,`${challengeMode}: ${await check.text()}`);}
+ challengeMode='ok';
+ const first=await begin('unique-guest-token-a');assert.equal(first.status,200,await first.clone().text());
+ assert.equal((await first.clone().json() as any).chatAvailable,chatAvailable);
+ const runtime=await(await call('/api/runtime')).json() as any;
+ assert.equal(runtime.guest.enabled,true);assert.equal(runtime.guest.chatAvailable,chatAvailable);
+ assert.equal(runtime.guest.chatLimit,chatAvailable?25:null);
+ assert.match(first.headers.get('set-cookie')!,/Secure; HttpOnly; SameSite=Lax/);
+ const cookieA=first.headers.get('set-cookie')!.split(';')[0];
+ assert.equal((await begin('unique-guest-token-a')).status,403,'challenge replay');
+ const cookieB=(await begin('unique-guest-token-b')).headers.get('set-cookie')!.split(';')[0];
+ const account=await(await call('/api/account',cookieA)).json() as any;
+ assert.equal(account.account.role,'guest');assert.equal(account.account.quotaClass,'guest');assert.equal(account.account.course,null);
+ assert.equal(account.splitAllowance.limit,5);assert.equal(account.guest.chatAvailable,chatAvailable);
+ if(chatAvailable)assert.equal(account.chatAllowance.limit,25);else assert.equal(account.chatAllowance,null);
+ assert.equal((await(await call('/api/account',cookieA)).json() as any).account.subject,account.account.subject,'resumed guest');
+ const macOffset=cookieA.lastIndexOf('.')+1;
+ assert.equal((await call('/api/account',cookieA.slice(0,macOffset)+(cookieA[macOffset]==='a'?'b':'a')+cookieA.slice(macOffset+1))).status,401,'tampered MAC');
+ assert.equal((await call('/api/account',cookieA+'; '+cookieB)).status,401,'duplicate cookie');
+ const memberCookie='__Host-stem-session=invalid';
+ assert.equal((await call('/api/account',cookieA+'; '+memberCookie)).status,401,'invalid member cannot become guest');
+ assert.equal((await begin('member-downgrade',cookieA+'; '+memberCookie)).status,403);
+ for(const path of ['/api/classroom/courses','/api/teacher/prompt','/api/teacher/folders','/api/admin/users','/api/model-quota'])assert.equal((await call(path,cookieA)).status,403,path);
+ assert.equal((await call('/api/account',cookieA,{headers:{'x-stem-course':'msh-245-the-american-musical-experience-fall-2026-01'}})).status,403);
+ assert.deepEqual(await(await call('/api/teacher/me',cookieA)).json(),{teacher:null});
+
+ await sql([`INSERT INTO app_users(subject) VALUES('${member}')`,
+ `INSERT INTO jobs(id,filename,source_key,status,model,stems) VALUES('member-private','Private member lesson','uploads/private.wav','done','htdemucs_ft','[{"name":"vocals","key":"stems/member-private/vocals.mp3"}]')`,
+ `INSERT INTO job_owners(job_id,subject) VALUES('member-private','${member}')`,
+ `INSERT INTO listening_conversations(job_id,subject,entries,revision,expires_at) VALUES('member-private','${member}','[{"kind":"you","text":"Private member transcript"}]',1,datetime('now','+1 day'))`]);
+ for(const path of ['/api/jobs/member-private','/api/jobs/member-private/listening-conversation','/api/files/stems/member-private/vocals.mp3']){
+ const response=await call(path,cookieA);assert.equal(response.status,404,path);assert.doesNotMatch(await response.text(),/Private member/);
+ }
+ assert.doesNotMatch(await(await call('/api/jobs',cookieA)).text(),/Private member/);
+
+ const grant=await(await call('/api/uploads',cookieA,{method:'POST',body:'{"filename":"guest.wav"}'})).json() as any;
+ assert.equal((await call(grant.uploadUrl,cookieB,{method:'PUT',headers:{'Content-Type':'audio/wav','Content-Length':String(wav.length)},body:wav})).status,404);
+ assert.equal((await call(grant.uploadUrl,cookieA,{method:'PUT',headers:{'Content-Type':'audio/wav','Content-Length':String(wav.length)},body:wav})).status,204);
+ const body=JSON.stringify({key:grant.key,filename:'Guest owned fixture',model:'htdemucs_ft'});
+ const submit=(key:string)=>call('/api/jobs',cookieA,{method:'POST',headers:{'Idempotency-Key':key},body});
+ async function finish(id:string){for(let i=0;i<50;i++){const response=await call(`/api/jobs/${id}`,cookieA);assert.equal(response.status,200,await response.clone().text());const job=await response.json() as any;if(['done','failed'].includes(job.status))return job;await new Promise(resolve=>setTimeout(resolve,20));}assert.fail('fixture job did not settle');}
+ predictionMode='failed';let response=await submit('guest-split-failed-0001');assert.equal(response.status,202,await response.clone().text());
+ const failed=await response.json() as any;assert.equal((await finish(failed.id)).status,'failed');
+ assert.equal((await(await call('/api/account',cookieA)).json() as any).splitAllowance.remaining,5);
+ predictionMode='success';let job:any;
+ for(let i=0;i<5;i++){
+ const key=`guest-split-success-${String(i).padStart(4,'0')}`;response=await submit(key);assert.equal(response.status,202,await response.clone().text());
+ const created=await response.json() as any;const repeated=await submit(key);assert.equal(repeated.status,200);assert.equal((await repeated.json() as any).id,created.id);
+ job=await finish(created.id);assert.equal(job.status,'done');
+ }
+ assert.equal(starts,6,'failed plus five successful predictions, no replay start');
+ assert.equal((await submit('guest-split-sixth-0006')).status,429);
+ const count=await(await call('/api/account',cookieA)).json() as any;assert.equal(count.splitAllowance.completed,5);assert.equal(count.splitAllowance.inProgress,0);
+ for(const path of [`/api/jobs/${job.id}`,`/api/jobs/${job.id}/listening-conversation`,new URL(job.stems[0].url,origin).pathname])assert.equal((await call(path,cookieB)).status,404,path);
+ assert.equal((await call(`/api/jobs/${job.id}`,cookieA,{headers:{'x-fixture-identity':jwt}})).status,404,'member does not inherit guest work');
+ assert.equal((await call(`/api/jobs/${job.id}/share`,cookieA,{method:'POST',body:'{}'})).status,403);
+ const usage=JSON.stringify({events:[{id:'guest-playback-event-0001',type:'playback_start',jobId:job.id}]});
+ const accountB=await(await call('/api/account',cookieB)).json() as any;
+ assert.equal((await call('/api/usage-events',cookieA,{method:'POST',headers:{'X-Stem-Usage-Actor':account.account.subject},body:usage})).status,200,'own playback telemetry');
+ assert.equal((await call('/api/usage-events',cookieB,{method:'POST',headers:{'X-Stem-Usage-Actor':accountB.account.subject},body:usage})).status,404,'other guest cannot attach a job to telemetry');
+ assert.equal((await call('/api/usage-events',cookieB,{method:'POST',headers:{'X-Stem-Usage-Actor':account.account.subject},body:JSON.stringify({events:[{id:'old-guest-page-event-001',type:'page_view'}]})})).status,409,'guest switch rejects old queued observations');
+ assert.equal((await call('/api/admin/usage-events',cookieA)).status,403);
+ const note=await call(`/api/jobs/${job.id}/annotations`,cookieA,{method:'POST',body:JSON.stringify({atSeconds:1,text:'Guest listening note'})});
+ assert.equal(note.status,200);const noteId=(await note.json() as any).id;
+ assert.equal((await call(`/api/jobs/${job.id}/annotations/${noteId}`,cookieB,{method:'DELETE'})).status,404);
+ assert.equal((await call(`/api/jobs/${job.id}/annotations/${noteId}`,cookieA,{method:'DELETE'})).status,200);
+ assert.equal((await call(`/api/jobs/${job.id}/listening-conversation`,cookieA,{method:'PUT',body:JSON.stringify({revision:0,entries:[{kind:'you',text:'Only guest A'}]})})).status,200);
+ assert.match(await(await call(`/api/jobs/${job.id}/listening-conversation`,cookieA)).text(),/Only guest A/);
+ const chat=(key:string,mode='ok')=>call(`/api/jobs/${job.id}/chat`,cookieA,{method:'POST',headers:{'x-fixture-gateway-mode':mode},body:JSON.stringify({messageId:key,messages:[{role:'user',content:'What should I listen for?'}]})});
+ if(chatAvailable){
+ response=await chat('guest-chat-empty-0001','empty');await response.text();
+ assert.equal((await(await call('/api/account',cookieA)).json() as any).chatAllowance.completed,0);
+ assert.equal((await(await call('/api/account',cookieA)).json() as any).chatAllowance.inProgress,0);
+ for(let i=0;i<25;i++){response=await chat(`guest-chat-human-${String(i).padStart(4,'0')}`);assert.equal(response.status,200);assert.match(await response.text(),/Listen for the bass/);}
+ assert.equal((await chat('guest-chat-human-0000')).status,409,'human replay has no extra inference');
+ assert.equal((await chat('guest-chat-human-0026')).status,429);
+ assert.equal((await(await call('/api/account',cookieA)).json() as any).chatAllowance.completed,25);
+ const stats=await worker.fetch('/__fixture/gateway-stats',{headers:{'x-fixture':'local-only'}});assert.equal((await stats.json() as any).gatewayCalls,26);
+ }else{
+ for(const path of [`/api/jobs/${job.id}/guide`,`/api/jobs/${job.id}/chat`]) {
+ const denied=await call(path,cookieA,{method:'POST',body:'{}'});
+ assert.equal(denied.status,503);assert.equal((await denied.json() as any).error.code,'guest_assistant_unavailable');
+ }
+ const stats=()=>worker.fetch('/__fixture/gateway-stats',{headers:{'x-fixture':'local-only'}}).then(response=>response.json() as any);
+ assert.equal((await stats()).gatewayCalls,0,'unavailable guest chat performs no Gateway request');
+ const operations=await(await worker.fetch('/__fixture/operations',{headers:{'x-fixture':'local-only'}})).json() as Array<{phase:string}>;
+ assert.equal(operations.some(operation=>['chat','guide'].includes(operation.phase)),false,'unavailable guest chat reserves no allowance');
+ const gatewayJwt=await issuer.mintIdentityJwt({audience:'cail:gateway',subject:member});
+ const memberResponse=await call('/api/jobs/member-private/chat','',{method:'POST',headers:{'x-fixture-identity':jwt,'x-fixture-gateway-identity':gatewayJwt},
+ body:JSON.stringify({messageId:'member-chat-without-guest-key',messages:[{role:'user',content:'What should I listen for?'}]})});
+ assert.equal(memberResponse.status,200);assert.match(await memberResponse.text(),/Listen for the bass/);
+ assert.equal((await stats()).gatewayCalls,1,'member chat retains its own Gateway credential');
+ }
+ await sql([`UPDATE guest_sessions SET verified_day='2000-01-01' WHERE subject='${account.account.subject}'`]);
+ assert.equal((await call('/api/jobs',cookieA)).status,200,'old proof preserves own reads');
+ assert.equal((await submit('guest-needs-proof-0001')).status,403);
+ assert.equal((await begin('guest-proof-refresh',cookieA)).status,200);
+ assert.equal((await(await call('/api/account',cookieA)).json() as any).splitAllowance.completed,5,'proof refresh does not reset budget');
+ assert.equal((await call('/auth/logout',cookieA,{method:'POST'})).status,303);
+ assert.equal((await call(`/api/jobs/${job.id}`,cookieA)).status,401,'logout revokes copied cookie');
+ assert.equal((await call('/api/jobs',cookieB)).status,200,'other guest remains isolated and active');
+ } finally {await harness.close();network.close();}
+});
+
+test('guest expiry, signing scope, immutable quota class, atomic reservations and midnight reset',async t=>{
+ const directory=mkdtempSync(join(tmpdir(),'stem-guest-'));t.after(()=>rmSync(directory,{recursive:true,force:true}));
+ const local=new SqliteD1(join(directory,'db'));local.applySchema(readFileSync(new URL('../schema.sql',import.meta.url),'utf8'));
+ const db=local as unknown as D1Database,now=Date.now(),id='a'.repeat(64),subject=`guest-${id}`,expires=now+60000;
+ await db.prepare('INSERT INTO guest_sessions(subject,created_at,expires_at,verified_day) VALUES(?,?,?,?)').bind(subject,now,expires,dailyWindow().day).run();
+ const env={...settings,DB:db} as Env;
+ const key=await crypto.subtle.importKey('raw',new TextEncoder().encode(settings.GUEST_COOKIE_SECRET),{name:'HMAC',hash:'SHA-256'},false,['sign']);
+ const sign=async(exp:number,scope=origin)=>Buffer.from(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`stem-splitter-guest-v1\n${scope}\n${id}.${exp}`))).toString('base64url');
+ const cookie=`${GUEST_COOKIE}=${id}.${expires}.${await sign(expires)}`;
+ assert.equal((await readGuestSession(new Request(origin,{headers:{Cookie:cookie}}),env))?.subject,subject);
+ const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',signature=await sign(expires);
+ const noncanonical=signature.slice(0,-1)+alphabet[alphabet.indexOf(signature.at(-1)!)+1];
+ assert.deepEqual(Buffer.from(noncanonical,'base64url'),Buffer.from(signature,'base64url'),'only ignored padding bits changed');
+ assert.equal(await readGuestSession(new Request(origin,{headers:{Cookie:`${GUEST_COOKIE}=${id}.${expires}.${noncanonical}`}}),env),null,'noncanonical MAC encoding');
+ await assert.rejects(readGuestSession(new Request(origin,{headers:{Cookie:cookie}}),{...env,DB:{prepare(){throw new Error('fixture database unavailable');}} as unknown as D1Database}),/fixture database unavailable/,'storage outage preserves the distinction from an invalid cookie');
+ assert.equal(await readGuestSession(new Request('https://other.test',{headers:{Cookie:cookie}}),env),null);
+ const past=now-1000;assert.equal(await readGuestSession(new Request(origin,{headers:{Cookie:`${GUEST_COOKIE}=${id}.${past}.${await sign(past)}`}}),env),null,'correctly signed expired cookie');
+ assert.equal(await readGuestSession(new Request(origin,{headers:{Cookie:`${GUEST_COOKIE}=${id}.${expires+1000}.${await sign(expires+1000)}`}}),env),null,'database expiry must match');
+ const reserve=(n:number,at=now)=>reserveOperation(db,{subject,quotaClass:'guest',courseId:null,kind:'split',key:`guest-parallel-${String(n).padStart(4,'0')}`,fingerprint:`${n}`,phase:'split',now:at});
+ const first=await reserve(0);await settleOperation(db,first.operation.id,'failed');
+ for(let i=1;i<=3;i++){const value=await reserve(i);await settleOperation(db,value.operation.id,'succeeded');}
+ const concurrent=await Promise.allSettled([4,5,6,7].map(n=>reserve(n)));
+ assert.equal(concurrent.filter(result=>result.status==='fulfilled').length,2);
+ assert.equal((await splitAllowance(db,subject,new Date(now),'guest')).remaining,0);
+ await assert.rejects(db.prepare("UPDATE app_operations SET quota_class='member' WHERE id=?").bind(first.operation.id).run(),/immutable/);
+ await assert.rejects(db.prepare("INSERT INTO app_users(subject) VALUES(?)").bind(subject).run(),/CHECK/);
+ await assert.rejects(reserveOperation(db,{subject,quotaClass:'member',courseId:null,kind:'chat',key:'guest-member-forgery',fingerprint:'x',phase:'chat'}),/FOREIGN KEY/);
+ await assert.rejects(reserveOperation(db,{subject,quotaClass:'guest',courseId:'course-a',kind:'chat',key:'guest-course-forgery',fingerprint:'x',phase:'chat'}),/course/);
+ const tomorrow=now+86400000;
+ assert.equal((await splitAllowance(db,subject,new Date(tomorrow),'guest')).remaining,5,'UTC-day allowance is separate');
+ const chats=await Promise.allSettled(Array.from({length:30},(_,i)=>reserveOperation(db,{subject,quotaClass:'guest',courseId:null,
+ kind:'chat',phase:'chat',key:`guest-parallel-chat-${String(i).padStart(4,'0')}`,fingerprint:String(i),now})));
+ const admitted=chats.flatMap(result=>result.status==='fulfilled'?[result.value.operation]:[]);
+ assert.equal(admitted.length,25,'parallel 25th/26th input cannot oversubscribe');
+ for(const [index,operation] of admitted.entries())await settleOperation(db,operation.id,index===0?'failed':index===1?'partial':'succeeded');
+ assert.equal((await operationAllowance(db,subject,'chat',new Date(now),'guest')).completed,24);
+ assert.equal((await operationAllowance(db,subject,'chat',new Date(tomorrow),'guest')).completed,0,'late settlement remains on submission day');
+ const replacement=await reserveOperation(db,{subject,quotaClass:'guest',courseId:null,kind:'chat',phase:'chat',key:'guest-chat-replacement-01',fingerprint:'replacement',now});
+ await settleOperation(db,replacement.operation.id,'succeeded');
+ await settleOperation(db,admitted[1].id,'failed');
+ assert.equal((await operationAllowance(db,subject,'chat',new Date(now),'guest')).completed,25,'partial replay cannot refund a charged input');
+ await db.prepare('UPDATE guest_sessions SET revoked_at=? WHERE subject=?').bind(now,subject).run();
+ assert.equal(await readGuestSession(new Request(origin,{headers:{Cookie:cookie}}),env),null,'revoked copied cookie');
+ await assert.rejects(db.prepare('UPDATE guest_sessions SET revoked_at=NULL WHERE subject=?').bind(subject).run(),/immutable/);
+});
diff --git a/cloudflare/guest.ts b/cloudflare/guest.ts
new file mode 100644
index 0000000..1dac2bf
--- /dev/null
+++ b/cloudflare/guest.ts
@@ -0,0 +1,102 @@
+import type { Env } from '../src/env.ts';
+import { activeGuest, guestFailure, GUEST_SESSION_MS, GUEST_SUBJECT, type GuestSession } from '../src/guest/access.ts';
+import { dailyWindow } from '../src/daily-allowance.ts';
+import { readBoundedJsonRequest } from '../src/http/bounded-request.ts';
+import { readBoundedResponse } from '../src/http/bounded-response.ts';
+import { uniqueCookie } from './session-contract/session.js';
+import { gatewayModelsConfigured } from './gateway.ts';
+
+export const GUEST_COOKIE = '__Host-stem-guest';
+const MEMBER_COOKIE = '__Host-stem-session';
+const TOKEN = /^([0-9a-f]{64})\.(\d{13})\.([A-Za-z0-9_-]{43})$/;
+const encode = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replaceAll('+','-').replaceAll('/','_').replaceAll('=','');
+const cookie = (value: string, seconds: number) => `${GUEST_COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${seconds}`;
+export const clearGuestCookie = () => cookie('',0);
+export const hasMemberCookie = (request: Request) => (request.headers.get('cookie') ?? '').split(';').some(part=>part.trim().split('=',1)[0]===MEMBER_COOKIE);
+export interface GuestAdapterEnv extends Env {
+ GATEWAY?: Fetcher;
+ GATEWAY_MODEL?: string;
+ GATEWAY_FALLBACK_MODEL?: string;
+ CANONICAL_BASE_URL?: string;
+}
+/** Public splitting uses the existing audio provider. Listening Guy has its
+ * own optional credential and must not gate a verified guest session. */
+export function guestConfigured(env: GuestAdapterEnv): boolean {
+ return env.GUEST_ENABLED === 'true' && typeof env.GUEST_COOKIE_SECRET === 'string' && env.GUEST_COOKIE_SECRET.length >= 32 && env.GUEST_COOKIE_SECRET.length <= 512
+ && /^[A-Za-z0-9_-]{10,128}$/.test(env.GUEST_TURNSTILE_SITE_KEY ?? '')
+ && /^[A-Za-z0-9_-]{20,512}$/.test(env.GUEST_TURNSTILE_SECRET ?? '')
+ && Boolean(env.REQUEST_LIMIT);
+}
+/** Presence enables the guest chat surface; Gateway still checks current
+ * Registry authorization and spending rules for each model request. */
+export function guestChatConfigured(env: GuestAdapterEnv): boolean {
+ return guestConfigured(env) && /^sk-cail-[A-Za-z0-9_-]{20,256}$/.test(env.GUEST_GATEWAY_API_KEY ?? '')
+ && Boolean(env.GATEWAY && gatewayModelsConfigured(env.GATEWAY_MODEL,env.GATEWAY_FALLBACK_MODEL));
+}
+async function signingKey(env: Env) {
+ if (!env.GUEST_COOKIE_SECRET || env.GUEST_COOKIE_SECRET.length < 32) throw new Error('Guest signing unavailable');
+ return crypto.subtle.importKey('raw',new TextEncoder().encode(env.GUEST_COOKIE_SECRET),{name:'HMAC',hash:'SHA-256'},false,['sign','verify']);
+}
+const message = (origin: string,id: string,expires: number) => new TextEncoder().encode(`stem-splitter-guest-v1\n${origin}\n${id}.${expires}`);
+export async function readGuestSession(request: Request,env: Env): Promise {
+ const match = TOKEN.exec(uniqueCookie(request,GUEST_COOKIE));
+ if (!match || !Number.isSafeInteger(Number(match[2])) || Number(match[2]) <= Date.now() || Number(match[2]) > Date.now()+GUEST_SESSION_MS+1000) return null;
+ try {
+ const signature = Uint8Array.from(atob(match[3].replaceAll('-','+').replaceAll('_','/')+'='),c=>c.charCodeAt(0));
+ if (encode(signature) !== match[3]) return null;
+ if (!await crypto.subtle.verify('HMAC',await signingKey(env),signature,message(new URL(request.url).origin,match[1],Number(match[2])))) return null;
+ } catch { return null; }
+ // An unavailable database is not an invalid cookie. Preserve the browser's
+ // session and let the adapter report 503 while keeping private content hidden.
+ const session=await activeGuest(env.DB,`guest-${match[1]}`);
+ return session?.expiresAt === Number(match[2]) ? session : null;
+}
+export async function revokeGuestSession(request: Request,env: Env): Promise {
+ const session=await readGuestSession(request,env);
+ if (session) await env.DB.prepare('UPDATE guest_sessions SET revoked_at=? WHERE subject=? AND revoked_at IS NULL').bind(Date.now(),session.subject).run();
+}
+export async function startGuestSession(request: Request,env: GuestAdapterEnv): Promise {
+ const url=new URL(request.url);
+ if (request.method !== 'POST') return new Response(null,{status:405,headers:{Allow:'POST'}});
+ if (url.protocol !== 'https:' || ![env.PUBLIC_BASE_URL,env.CANONICAL_BASE_URL].includes(url.origin)
+ || request.headers.get('origin') !== url.origin || request.headers.get('sec-fetch-site') === 'cross-site') return guestFailure('guest_origin_invalid',403,'Open Stem Splitter directly to start a guest session.');
+ if (hasMemberCookie(request)) return guestFailure('guest_signout_required',403,'Sign out of your existing account before starting a separate guest session.');
+ if (!guestConfigured(env)) return guestFailure('guest_unavailable',503,'Guest access is not available. You can use CUNY Login.');
+ const limited=await env.REQUEST_LIMIT!.limit({key:`stem-guest-start:${request.headers.get('cf-connecting-ip') || 'unknown'}`});
+ if (!limited.success) return new Response(JSON.stringify({error:{code:'guest_rate_limited',message:'Please wait a minute before trying again.'}}),{status:429,headers:{'Content-Type':'application/json','Retry-After':'60'}});
+ let token: unknown;
+ try { token=(await readBoundedJsonRequest(request,4096) as {token?:unknown})?.token; } catch { return guestFailure('guest_verification_invalid',403,'Complete the guest check and try again.'); }
+ if (typeof token !== 'string' || token.length < 1 || token.length > 2048) return guestFailure('guest_verification_invalid',403,'Complete the guest check and try again.');
+ let result: {success?:unknown;hostname?:unknown;action?:unknown;challenge_ts?:unknown};
+ try {
+ const response=await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({secret:env.GUEST_TURNSTILE_SECRET,response:token}),redirect:'manual',signal:AbortSignal.timeout(5000)});
+ if (!response.ok) throw new Error('Verification unavailable');
+ const failed=()=>new Error('Verification unavailable');
+ result=JSON.parse(new TextDecoder().decode(await readBoundedResponse(response,{maximumBytes:8192,timeoutMs:5000,errors:{tooLarge:failed,timedOut:failed,unreadable:failed}})));
+ } catch { return guestFailure('guest_verification_unavailable',503,'The guest check is temporarily unavailable. Try again shortly.'); }
+ const time=typeof result.challenge_ts==='string'?Date.parse(result.challenge_ts):NaN;
+ if (result.success !== true || result.hostname !== url.hostname || result.action !== 'stem_guest' || !Number.isFinite(time) || time>Date.now()+30000 || timeb.toString(16).padStart(2,'0')).join('');
+ const now=Date.now(), day=dailyWindow().day;
+ const previous=await readGuestSession(request,env);
+ const id=previous?.subject.slice(6) ?? Array.from(crypto.getRandomValues(new Uint8Array(32)),b=>b.toString(16).padStart(2,'0')).join('');
+ const subject=`guest-${id}`,expiresAt=previous?.expiresAt ?? now+GUEST_SESSION_MS;
+ if (!GUEST_SUBJECT.test(subject)) throw new Error('Guest subject invalid');
+ const signature=encode(new Uint8Array(await crypto.subtle.sign('HMAC',await signingKey(env),message(url.origin,id,expiresAt))));
+ try {
+ await env.DB.batch([
+ env.DB.prepare('INSERT INTO guest_challenges(token_hash,expires_at) VALUES(?,?)').bind(tokenHash,now+600000),
+ previous ? env.DB.prepare('UPDATE guest_sessions SET verified_day=? WHERE subject=? AND revoked_at IS NULL AND expires_at>?').bind(day,subject,now)
+ : env.DB.prepare('INSERT INTO guest_sessions(subject,created_at,expires_at,verified_day) VALUES(?,?,?,?)').bind(subject,now,expiresAt,day),
+ ]);
+ } catch {
+ try {
+ if(await env.DB.prepare('SELECT 1 FROM guest_challenges WHERE token_hash=?').bind(tokenHash).first()) return guestFailure('guest_verification_replayed',403,'This guest check was already used. Complete a new check.');
+ } catch { /* Keep storage failures distinct from rejected verification. */ }
+ return guestFailure('guest_session_unavailable',503,'The guest session could not be saved. Try again shortly.');
+ }
+ const chatAvailable=guestChatConfigured(env);
+ return Response.json({ok:true,expiresAt,splitLimit:5,chatAvailable,chatLimit:chatAvailable?25:null},{headers:{'Cache-Control':'private, no-store','Set-Cookie':cookie(`${id}.${expiresAt}.${signature}`,Math.floor((expiresAt-now)/1000))}});
+}
diff --git a/cloudflare/intake-pause.test.ts b/cloudflare/intake-pause.test.ts
new file mode 100644
index 0000000..c6a3641
--- /dev/null
+++ b/cloudflare/intake-pause.test.ts
@@ -0,0 +1,110 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { intakeEnvironment, intakePauseResponse } from './intake-pause.ts';
+import worker from './worker.ts';
+import alias from './alias.ts';
+import { createTestHarness } from 'wrangler';
+import { fileURLToPath } from 'node:url';
+
+const paused = { INTAKE_PAUSED: 'true' };
+const origins = ['https://stem-splitter.ailab-452.workers.dev', 'https://cail-stem-splitter-preview.ailab-452.workers.dev'];
+
+test('Workerd maintenance blocks starts before an application schema exists and preserves health and static assets', async () => {
+ const server = createTestHarness({ workers: [{
+ configPath: fileURLToPath(new URL('./test-wrangler.jsonc', import.meta.url)),
+ vars: { INTAKE_PAUSED: 'true' },
+ }] });
+ try {
+ await server.listen();
+ const runtime = server.getWorker('stem-preview-contract-test');
+ for (const [method, path] of [
+ ['POST', '/api/jobs'], ['POST', '/api/uploads'], ['PUT', '/api/local-uploads/pending.wav'],
+ ['POST', '/api/jobs/existing/chat'], ['POST', '/api/jobs/existing/guide'],
+ ]) {
+ const response = await runtime.fetch(path, { method, body: '{}' });
+ assert.equal(response.status, 503);
+ assert.equal((await response.json() as { code: string }).code, 'intake_paused');
+ }
+ const health = await runtime.fetch('/healthz');
+ assert.equal(health.status, 200);
+ const healthBody = await health.json() as { intakePaused: boolean; courseAccess: unknown };
+ assert.equal(healthBody.intakePaused, true);
+ assert.equal(healthBody.courseAccess, null);
+ assert.equal((await runtime.fetch('/app.js')).status, 200);
+ } finally {
+ await server.close();
+ }
+});
+
+test('maintenance rejects new compute and uploads through both entry paths before identity or storage', async () => {
+ let downstreamCalls = 0;
+ const forbidden = new Proxy({}, { get() { downstreamCalls++; throw new Error('must not touch existing data'); } });
+ const env = { ...paused, DB: forbidden, AUDIO: forbidden, IDENTITY: forbidden, PREVIEW_IDENTITY: forbidden };
+ const starts = [
+ ['POST', '/api/jobs'], ['POST', '/api/jobs/'], ['POST', '/api/uploads'],
+ ['PUT', '/api/local-uploads/new.wav'], ['POST', '/api/remix/archive-audio'],
+ ['POST', '/api/jobs/owned-song/chat'], ['POST', '/api/jobs/owned-song/guide'],
+ ['POST', '/api/jobs/encoded%2Fname/chat'], ['POST', '/api/teacher/jobs/owned-song/isolations'],
+ ];
+ for (const origin of origins) for (const [method, path] of starts) {
+ const request = new Request(origin + path, { method, headers: { Origin: origin }, body: '{}' });
+ const response = origin === origins[0]
+ ? await alias.fetch(request, { STEM_APP: { fetch: (forwarded: Request) => worker.fetch(forwarded, env as never, {} as never) } } as never)
+ : await worker.fetch(request, env as never, {} as never);
+ assert.equal(response.status, 503, `${method} ${origin}${path}`);
+ assert.equal((await response.json() as any).code, 'intake_paused');
+ assert.equal(response.headers.get('Retry-After'), '120');
+ assert.equal(response.headers.get('Cache-Control'), 'private, no-store');
+ assert.equal(response.headers.get('X-Content-Type-Options'), 'nosniff');
+ }
+ assert.equal(downstreamCalls, 0);
+});
+
+test('maintenance preserves reads, session transitions, notes, cancellation and signed provider callbacks', () => {
+ for (const [method, path] of [
+ ['GET', '/'], ['GET', '/healthz'], ['GET', '/api/jobs'],
+ ['GET', '/api/jobs/owned-song'], ['GET', '/api/shared-jobs/song/stems/0'],
+ ['GET', '/api/files/stems/song/vocals.mp3'], ['GET', '/auth/login'],
+ ['GET', '/auth/callback'], ['POST', '/auth/logout'],
+ ['POST', '/api/jobs/song/cancel'], ['POST', '/api/webhooks/separation'],
+ ['POST', '/api/jobs/song/annotations'], ['PUT', '/api/jobs/song/annotations/note'],
+ ['POST', '/api/bug-reports'], ['GET', '/api/jobs/song/listening-conversation'],
+ ['OPTIONS', '/api/jobs'],
+ ]) assert.equal(intakePauseResponse(new Request(origins[0] + path, { method }), paused), null, `${method} ${path}`);
+});
+
+test('pause flag propagates to queued-start control without mutating bindings or disabling recovery', () => {
+ const DB = {}, AUDIO = {};
+ const env = { ...paused, DB, AUDIO, SPLIT_STARTS_DISABLED: 'false' };
+ const requestEnv = intakeEnvironment(env);
+ assert.equal(requestEnv.SPLIT_STARTS_DISABLED, 'true');
+ assert.equal(requestEnv.DB, DB); assert.equal(requestEnv.AUDIO, AUDIO);
+ assert.equal(env.SPLIT_STARTS_DISABLED, 'false');
+ assert.equal(intakeEnvironment(requestEnv).SPLIT_STARTS_DISABLED, 'true');
+ for (const unpaused of [{}, { INTAKE_PAUSED: 'false' }, { SPLIT_STARTS_DISABLED: 'true' }]) {
+ assert.equal(intakeEnvironment(unpaused), unpaused);
+ assert.equal(intakePauseResponse(new Request(origins[0] + '/api/jobs', { method: 'POST' }), unpaused), null);
+ }
+});
+
+test('health exposes the pause state while static playback assets remain available', async () => {
+ const env = { ...paused, DB: { prepare: () => ({ first: async () => ({ ready: 1 }) }) },
+ AUTH_MODE: 'cail', ASSETS: { fetch: async () => new Response('existing playback UI') } };
+ const ctx = { waitUntil: (promise: Promise) => promise.catch(() => undefined) };
+ const health = await worker.fetch(new Request(origins[0] + '/healthz'), env as never, ctx as never);
+ assert.equal(health.status, 200); assert.equal((await health.json() as any).intakePaused, true);
+ const script = await worker.fetch(new Request(origins[0] + '/app.js'), env as never, ctx as never);
+ assert.equal(script.status, 200); assert.equal(await script.text(), 'existing playback UI');
+});
+
+test('Doorway course readiness follows the classroom pause independently of the intake pause', async () => {
+ const DB = { prepare: (sql: string) => ({ first: async () => sql.includes('sqlite_master') ? { present: 2 } : { ready: 1 } }) };
+ for (const classroom of [undefined, 'true', 'false']) {
+ const response = await worker.fetch(new Request(origins[0] + '/healthz'), {
+ DB, AUTH_MODE: 'cail', CAIL_CLASSROOM_ENABLED: classroom,
+ } as never, {} as never);
+ const body = await response.json() as { courseAccess: unknown; intakePaused: boolean };
+ assert.equal(body.intakePaused, false);
+ assert.deepEqual(body.courseAccess, classroom === 'false' ? null : { contract: 'stem-course-v1' });
+ }
+});
diff --git a/cloudflare/intake-pause.ts b/cloudflare/intake-pause.ts
new file mode 100644
index 0000000..d1dc0f5
--- /dev/null
+++ b/cloudflare/intake-pause.ts
@@ -0,0 +1,27 @@
+/** One runtime gate covers the canonical service alias and direct Worker URL.
+ * It does not use storage or identity and can be installed before migrations.
+ */
+export type IntakePauseEnv = { INTAKE_PAUSED?: string };
+
+export function intakeEnvironment(env: T): T {
+ // The durable coordinator reconciles known work before honoring this flag.
+ // Carry it on every request and scheduled invocation, not just POST routes.
+ return env.INTAKE_PAUSED === 'true' ? { ...env, SPLIT_STARTS_DISABLED: 'true' } : env;
+}
+
+export function intakePauseResponse(request: Request, env: IntakePauseEnv): Response | null {
+ if (env.INTAKE_PAUSED !== 'true') return null;
+ const path = new URL(request.url).pathname;
+ const startsWork = request.method === 'POST' && (
+ /^\/api\/(?:jobs|uploads|remix\/archive-audio)\/?$/.test(path) ||
+ /^\/api\/jobs\/[^/]+\/(?:chat|guide)\/?$/.test(path) ||
+ /^\/api\/teacher\/jobs\/[^/]+\/isolations\/?$/.test(path)
+ );
+ const uploadsAudio = request.method === 'PUT' && /^\/api\/local-uploads(?:\/|$)/.test(path);
+ if (!startsWork && !uploadsAudio) return null;
+ return Response.json({
+ error: 'New uploads, splits and Listening Guy requests are temporarily paused for maintenance. Saved audio remains available. Please try again shortly.',
+ code: 'intake_paused',
+ retryable: true,
+ }, { status: 503, headers: { 'Retry-After': '120', 'Cache-Control': 'private, no-store' } });
+}
diff --git a/cloudflare/observability.test.ts b/cloudflare/observability.test.ts
new file mode 100644
index 0000000..670b8c4
--- /dev/null
+++ b/cloudflare/observability.test.ts
@@ -0,0 +1,66 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {SqliteD1} from '../server/d1.ts';
+import {parseClientUses,recordClientUses,recordServerUse,usageSummary} from '../src/reliability/observability.ts';
+import {reserveOperation,beginAttempt,finishAttempt,settleOperation} from '../src/reliability/ledger.ts';
+import {purgeOperationContent} from '../src/reliability/retention.ts';
+import type {AppPrincipal} from '../src/identity.ts';
+import type {Env} from '../src/env.ts';
+function setup(){const db=new SqliteD1(':memory:');db.applySchema(readFileSync(new URL('../schema.sql',import.meta.url),'utf8'));return db;}
+const principal={subject:'cail-'+ 'e'.repeat(32),role:'student',displayName:'Do not store me',course:null,courseId:null} as AppPrincipal;
+test('content-free usage validation, deduplication, bounded volume and retention',async()=>{
+ const db=setup();const event={id:'client-event-0000001',type:'seek' as const,jobId:'fixture-job',positionBucket:7};
+ assert.deepEqual(parseClientUses({events:[event]}),[event]);
+ for(const value of [{events:[{...event,text:'private'}]},{events:[{...event,positionBucket:91}]},{events:[{...event,jobId:'https://private.test'}]},{events:Array(11).fill(event)},{events:[{...event,type:'unknown'}]}])assert.equal(parseClientUses(value),null);
+ await Promise.all([recordClientUses(db,principal,[event]),recordClientUses(db,principal,[event])]);
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM usage_events').first())!.n,1);
+ for(let i=1;i<510;i++)await recordClientUses(db,principal,[{...event,id:`bounded-event-${String(i).padStart(8,'0')}`}]);
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM usage_events').first())!.n,500);
+ const rows=JSON.stringify((await db.prepare('SELECT * FROM usage_events').all()).results);
+ for(const secret of [principal.subject,principal.displayName,'https://'])assert.equal(rows.includes(secret),false);
+ await db.prepare('UPDATE usage_events SET at=?').bind(Date.now()-31*86400000).run();
+ await purgeOperationContent(db);assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM usage_events').first())!.n,0);
+});
+test('operation attempts, fallback and settlement emit one durable receipt without arbitrary usage strings',async()=>{
+ const db=setup();await db.prepare('INSERT INTO app_users(subject) VALUES(?)').bind(principal.subject).run();
+ const {operation}=await reserveOperation(db,{subject:principal.subject,courseId:null,kind:'chat',phase:'chat',key:'durable-observed-001',fingerprint:'hash'});
+ const first=await beginAttempt(db,operation,'cail-gateway','glm-5.2');
+ await finishAttempt(db,first,'rejected',{status:503,code:'upstream_unavailable',usage:{total_tokens:4,private_text:'never retain'}});
+ const backup=await beginAttempt(db,operation,'cail-gateway','deepseek-v4-flash-0731');
+ await finishAttempt(db,backup,'succeeded',{usage:{total_tokens:12,private_text:'never retain'}});
+ await settleOperation(db,operation.id,'partial','partial_response');
+ await settleOperation(db,operation.id,'succeeded');
+ await finishAttempt(db,backup,'succeeded',{usage:{total_tokens:12}});
+ const {results}=await db.prepare('SELECT * FROM operation_events ORDER BY id').all();
+ assert.equal(results!.filter(r=>r.quota_effect==='reserved').length,1);
+ assert.equal(results!.filter(r=>r.quota_effect==='charged').length,1);
+ assert.equal(results!.filter(r=>r.attempt_id===backup&&r.event_type==='attempt_end').length,1);
+ assert.ok(results!.some(r=>r.model==='deepseek-v4-flash-0731'&&r.fallback===1));
+ assert.equal(JSON.stringify((await db.prepare('SELECT usage_json FROM operation_attempts').all()).results).includes('private_text'),false);
+ const summary=await usageSummary(db,7);assert.equal(JSON.stringify(summary).includes(principal.subject),false);
+ assert.ok(summary.operations.length>0);assert.ok(summary.operations.every(row=>(row as any).actor_class==='member'));
+ await db.prepare('UPDATE operation_events SET at=?').bind(Date.now()-31*86400000).run();
+ await purgeOperationContent(db);assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM operation_events').first())!.n,0);
+ assert.ok(await db.prepare('SELECT 1 FROM app_operations').first());
+});
+test('guest and member usage summaries keep immutable accounting classes separate without exposing identities',async()=>{
+ const db=setup(),now=Date.now(),guest={...principal,subject:'guest-'+ 'a'.repeat(64),role:'guest' as const,quotaClass:'guest' as const};
+ await db.prepare('INSERT INTO guest_sessions(subject,created_at,expires_at,verified_day) VALUES(?,?,?,?)')
+ .bind(guest.subject,now,now+86400000,new Date(now).toISOString().slice(0,10)).run();
+ const {operation}=await reserveOperation(db,{subject:guest.subject,quotaClass:'guest',courseId:null,kind:'chat',phase:'chat',key:'guest-observed-input-01',fingerprint:'hash'});
+ await settleOperation(db,operation.id,'partial','partial_response');
+ for(let i=0;i<260;i++)await recordClientUses(db,guest,[{id:`guest-usage-${String(i).padStart(12,'0')}`,type:'page_view'}]);
+ const summary=await usageSummary(db,7);
+ assert.ok(summary.operations.some(row=>(row as any).actor_class==='guest'&&(row as any).quota_effect==='charged'));
+ assert.equal(summary.uses.reduce((count,row)=>count+Number((row as any).count),0),250);
+ assert.ok(summary.uses.every(row=>(row as any).actor_class==='guest'));
+ assert.equal(JSON.stringify(summary).includes(guest.subject),false);
+});
+test('server request rejection retries coalesce and never retain request contents',async()=>{
+ const db=setup(),env={AUTH_MODE:'cail',DB:db} as Env;
+ const request=new Request('https://app.test/api/jobs?source=private',{method:'POST',headers:{'Idempotency-Key':'rejection-request-00001'},body:'private source name'});
+ for(let i=0;i<2;i++)await recordServerUse(env,request,Response.json({error:'Do not store',code:'split_daily_limit'},{status:429}),principal,Date.now()-5);
+ const rows=(await db.prepare('SELECT * FROM usage_events').all()).results!;assert.equal(rows.length,1);assert.equal(rows[0].code,'split_daily_limit');
+ assert.equal(JSON.stringify(rows).includes('private'),false);
+});
diff --git a/cloudflare/package.json b/cloudflare/package.json
index 832a451..dc934a2 100644
--- a/cloudflare/package.json
+++ b/cloudflare/package.json
@@ -5,15 +5,16 @@
"packageManager": "bun@1.4.0",
"scripts": {
"typecheck": "../node_modules/.bin/tsc -p tsconfig.json --noEmit",
- "test": "node --import ../node_modules/tsx/dist/loader.mjs --test *.test.ts",
+ "test": "node --import ./test-wasm-register.mjs --import ../node_modules/tsx/dist/loader.mjs --test *.test.ts",
"db:migrate:19": "wrangler d1 execute cail-stem-splitter-preview --remote --file=../migrations/0019-listening-conversations.sql",
"db:migrate:19:local": "wrangler d1 execute cail-stem-splitter-preview --local --file=../migrations/0019-listening-conversations.sql"
},
"dependencies": {
- "hono-cloudflare": "npm:hono@4.13.11",
"@cuny-ai-lab/cail-client": "6.2.2",
"@cuny-ai-lab/cail-identity": "5.2.6",
- "@cuny-ai-lab/cail-log": "0.6.4"
+ "@cuny-ai-lab/cail-log": "0.6.4",
+ "hono-cloudflare": "npm:hono@4.13.11",
+ "mpg123-decoder": "1.0.3"
},
"devDependencies": {
"wrangler": "4.129.0"
diff --git a/cloudflare/playback.spec.mjs b/cloudflare/playback.spec.mjs
index ec8f77b..52385d2 100644
--- a/cloudflare/playback.spec.mjs
+++ b/cloudflare/playback.spec.mjs
@@ -637,3 +637,30 @@ test('waveforms support native touch input without moving the narrow page or con
expect(errors).toEqual([]);
} finally { await server.close(); await context.close(); }
});
+
+test('usage observations retry once with stable IDs and exclude private content and clock ticks',async({page})=>{
+ const batches=[],actors=[];
+ await page.route('**/api/usage-events',async route=>{batches.push(route.request().postDataJSON());actors.push(route.request().headers()['x-stem-usage-actor']);await route.fulfill({status:batches.length===1?503:200,contentType:'application/json',body:'{"accepted":true}'});});
+ const server=await fixture(page,['vocals','drums','bass','other'],{owned:true});
+ try {
+ await expect.poll(()=>batches.length).toBeGreaterThanOrEqual(2);
+ expect(batches[0]).toEqual(batches[1]);
+ expect(actors[0]).toBe(TEST_SUBJECTS.alice);expect(actors[1]).toBe(actors[0]);
+ await page.locator('.play-btn').click();
+ await expect.poll(()=>page.evaluate(()=>mixers.get('remix-fixture').playing)).toBe(true);
+ await page.evaluate(()=>{const m=mixers.get('remix-fixture');m.seekTo(42);m.seekTo(8,'loop');});
+ await page.locator('.play-btn').click();
+ await page.evaluate(()=>mixers.get('remix-fixture').pause());
+ await expect.poll(()=>batches.flatMap(b=>b.events).filter(e=>e.type==='playback_stop').length).toBe(1);
+ const events=batches.slice(1).flatMap(b=>b.events);
+ expect(events.filter(e=>e.type==='seek')).toHaveLength(1);
+ expect(events.find(e=>e.type==='seek').positionBucket).toBe(4);
+ expect(events.filter(e=>e.type==='playback_start')).toHaveLength(1);
+ expect(JSON.stringify(batches)).not.toContain('Playback review');
+ expect(JSON.stringify(batches)).not.toContain(TEST_SUBJECTS.alice);
+ expect(events.every(e=>Object.keys(e).every(k=>['id','type','jobId','durationMs','positionBucket'].includes(k)))).toBe(true);
+ const before=batches.length;
+ await page.evaluate(()=>{window.StemUsage.record('seek',{jobId:'remix-fixture',position:40});window.StemUsage.clear();});
+ await page.waitForTimeout(1500);expect(batches.length).toBe(before);
+ }finally{await server.close();}
+});
diff --git a/cloudflare/playwright.config.mjs b/cloudflare/playwright.config.mjs
index 43fbf83..ec3fe10 100644
--- a/cloudflare/playwright.config.mjs
+++ b/cloudflare/playwright.config.mjs
@@ -1,3 +1,3 @@
import { defineConfig } from '@playwright/test';
-export default defineConfig({ testDir: '.', testMatch: ['browser.spec.mjs', 'remix.spec.mjs', 'layout.spec.mjs', 'account.spec.mjs', 'account-rack.spec.mjs', 'gateway.spec.mjs', 'sharing.spec.mjs', 'playback.spec.mjs', 'classroom.spec.mjs'], workers: 1, timeout: 45000,
+export default defineConfig({ testDir: '.', testMatch: ['bug-report.spec.mjs', 'guest.spec.mjs', 'browser.spec.mjs', 'remix.spec.mjs', 'layout.spec.mjs', 'account.spec.mjs', 'account-rack.spec.mjs', 'gateway.spec.mjs', 'sharing.spec.mjs', 'playback.spec.mjs', 'classroom.spec.mjs'], workers: 1, timeout: 45000,
use: { browserName: process.env.STEM_BROWSER || 'chromium', channel: process.env.STEM_BROWSER ? undefined : 'chrome', headless: true, screenshot: 'only-on-failure' }, reporter: 'line' });
diff --git a/cloudflare/queue-workers.test.ts b/cloudflare/queue-workers.test.ts
new file mode 100644
index 0000000..d7f4770
--- /dev/null
+++ b/cloudflare/queue-workers.test.ts
@@ -0,0 +1,45 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { createTestHarness } from 'wrangler';
+import { setupServer } from 'msw/node';
+import { http, HttpResponse } from 'msw';
+import { createTestIdentityIssuer, TEST_SUBJECTS } from '@cuny-ai-lab/cail-identity/testing';
+import { schemaStatements } from '../tests/e2e/schema-statements.mjs';
+import { STEM_COURSE_ID } from '../src/classroom/contract.ts';
+
+test('two actual Workerd Workers sharing D1 admit fifteen simultaneous requests across courses and keep each student independent',async()=>{
+ const issuer=await createTestIdentityIssuer();const people=[TEST_SUBJECTS.alice,TEST_SUBJECTS.bob];
+ const tokens=await Promise.all(people.map(subject=>issuer.mintIdentityJwt({audience:'cail:stem-splitter',subject})));
+ const config=JSON.parse(await readFile(new URL('./test-wrangler.jsonc',import.meta.url),'utf8'));
+ config.main=fileURLToPath(new URL('./test-worker.ts',import.meta.url));config.assets.directory=fileURLToPath(new URL('../public',import.meta.url));
+ config.vars={...config.vars,TEST_JWKS:issuer.jwksJson,TEST_ADMIN:TEST_SUBJECTS.carol,CAIL_COURSE_IDS:`${STEM_COURSE_ID},second-fixture-course`,
+ REPLICATE_API_TOKEN:'fixture-only',REPLICATE_MODEL_VERSION:'fixture-pin',WEBHOOK_SECRET:'fixture-secret'};
+ delete config.$schema;
+ let starts=0;
+ const network=setupServer(http.post('https://api.replicate.com/v1/predictions',()=>{starts++;return HttpResponse.json({id:'shared-prediction'});}),
+ http.get('https://api.replicate.com/v1/predictions/shared-prediction',()=>HttpResponse.json({id:'shared-prediction',status:'processing'})));
+ network.listen({onUnhandledRequest:'error'});
+ const harness=createTestHarness({workers:[{config:{...config,name:'queue-worker-one'}},{config:{...config,name:'queue-worker-two'}}]});
+ try {
+ await harness.listen();const workers=[harness.getWorker('queue-worker-one'),harness.getWorker('queue-worker-two')];
+ const schema=schemaStatements(await readFile(new URL('../schema.sql',import.meta.url),'utf8'));
+ assert.equal((await workers[0].fetch('/__fixture/schema',{method:'POST',headers:{'x-fixture':'local-only'},body:JSON.stringify(schema)})).status,200);
+ const call=(worker:number,who:number,path:string,init:RequestInit={})=>workers[worker].fetch(path,{...init,headers:{origin:'https://split.test','content-type':'application/json','x-fixture-identity':tokens[who],...init.headers}});
+ const wav=await readFile(new URL('../tests/fixtures/audio/source.wav',import.meta.url));const keys:string[]=[];
+ for(let i=0;i<2;i++){
+ const grant=await(await call(i,i,'/api/uploads',{method:'POST',body:'{"filename":"fixture.wav"}'})).json();keys.push(grant.key);
+ assert.equal((await call(i,i,new URL(grant.uploadUrl,'https://split.test').pathname,{method:'PUT',headers:{'content-length':String(wav.length)},body:wav})).status,204);
+ }
+ const submit=(who:number,i:number)=>call(i%2,who,'/api/jobs',{method:'POST',headers:{'Idempotency-Key':`person-${who}-operation-${String(i).padStart(4,'0')}`,'X-Stem-Course':i%2?STEM_COURSE_ID:'second-fixture-course'},body:JSON.stringify({key:keys[who],filename:'fixture.wav',model:'htdemucs_ft',coursePolicy:'course-work-v1'})});
+ const responses=await Promise.all(Array.from({length:30},(_,i)=>submit(0,i)));
+ assert.equal(responses.filter(r=>r.status===202).length,15);assert.equal(responses.filter(r=>r.status===429).length,15);
+ await Promise.all(responses.map(r=>r.arrayBuffer()));
+ const bob=await Promise.all(Array.from({length:15},(_,i)=>submit(1,i)));assert.ok(bob.every(r=>r.status===202));await Promise.all(bob.map(r=>r.arrayBuffer()));
+ for(let who=0;who<2;who++){
+ const account=await(await call((who+1)%2,who,'/api/account')).json();assert.equal(account.splitAllowance.inProgress,15);assert.equal(account.splitAllowance.completed,0);assert.equal(account.splitAllowance.remaining,0);
+ }
+ const rows=await(await workers[1].fetch('/__fixture/operations',{headers:{'x-fixture':'local-only'}})).json();assert.equal(rows.length,30);assert.equal(starts,1);
+ } finally {await harness.close();network.close();}
+});
diff --git a/cloudflare/reliability.test.ts b/cloudflare/reliability.test.ts
new file mode 100644
index 0000000..9eb1ea3
--- /dev/null
+++ b/cloudflare/reliability.test.ts
@@ -0,0 +1,244 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { SqliteD1 } from '../server/d1.ts';
+import { FsR2Bucket } from '../server/r2.ts';
+import type { Env } from '../src/env.ts';
+import { beginAttempt, finishAttempt, readOperation, reserveOperation, settleOperation } from '../src/reliability/ledger.ts';
+import { cancelOperation, claimNext, recoverExpired, transition } from '../src/reliability/queue.ts';
+import { drainSplitQueue, operationWebhook, recoverCallback, reconcileSplit } from '../src/reliability/splits.ts';
+import { purgeOperationContent } from '../src/reliability/retention.ts';
+import { assistantReceipt, reserveAssistant } from '../src/reliability/assistant.ts';
+import { MPEGDecoder } from 'mpg123-decoder';
+import { validMp3Frames, mp3FrameInfo, validateMp3Pcm } from '../src/reliability/media.ts';
+import { splitAllowance, operationAllowance } from '../src/daily-allowance.ts';
+import { courseFixture } from './course-fixture.ts';
+import { runReliableJobs } from '../src/index.ts';
+
+const subject='cail-'+ 'e'.repeat(32);
+async function setup(t:{after:(f:()=>void)=>void}) {
+ const directory=mkdtempSync(join(tmpdir(),'stem-recovery-'));t.after(()=>rmSync(directory,{recursive:true,force:true}));
+ const local=new SqliteD1(join(directory,'db'));local.applySchema(readFileSync(new URL('../schema.sql',import.meta.url),'utf8'));
+ const db=local as unknown as D1Database;await db.prepare('INSERT INTO app_users(subject) VALUES(?)').bind(subject).run();
+ const env={DB:db,AUDIO:new FsR2Bucket(join(directory,'audio')),AUTH_MODE:'cail',SEPARATION_BACKEND:'replicate',LOCAL_HOSTING:'true',
+ PUBLIC_BASE_URL:'https://split.test',WEBHOOK_SECRET:'fixture-only',REPLICATE_API_TOKEN:'mock-only',REPLICATE_MODEL_VERSION:'separator-pin',REPLICATE_YT_MODEL_VERSION:'import-pin',
+ validateStemAudio:(data:ArrayBuffer)=>validateMp3Pcm(data,()=>new MPEGDecoder()),
+ ADMISSION_RESOLVER:{resolveCourseAccess:({subject,classId}:{subject:string;classId:string})=>courseFixture(subject,classId)}} as unknown as Env;
+ async function split(key='test-operation-0001',phase:'fetch'|'split'='split') {
+ const id=crypto.randomUUID();
+ return (await reserveOperation(db,{subject,courseId:null,kind:'split',key,fingerprint:key,phase,jobId:id,
+ request:{sourceType:phase==='fetch'?'youtube':'upload',youtubeUrl:'https://www.youtube.com/watch?v=abcdefghijk',filename:'private fixture',model:'htdemucs_ft',key:'uploads/fixture/audio.wav'},
+ statements:op=>[db.prepare("INSERT INTO jobs(id,filename,source_key,status,model) SELECT id,'private fixture','uploads/fixture/audio.wav','queued','htdemucs_ft' FROM app_operations WHERE id=?").bind(op)]})).operation;
+ }
+ return {db,env,split,directory};
+}
+
+test('expiry before attempt fences the stale runner; accepted response after expiry is adopted without another start',async t=>{
+ const {db,split}=await setup(t);await split();const old=(await claimNext(db,'split'))!;
+ await transition(db,old,'starting');await recoverExpired(db,old.lease_until+1);
+ await assert.rejects(beginAttempt(db,old,'replicate','separator-pin'));
+ const next=(await claimNext(db,'split'))!;await transition(db,next,'starting');
+ const attempt=await beginAttempt(db,next,'replicate','separator-pin');await recoverExpired(db,next.lease_until+1);
+ await finishAttempt(db,attempt,'accepted',{externalId:'paid-start'});
+ assert.equal(await transition(db,next,'processing',{providerId:'paid-start'}),false);
+ await recoverExpired(db,next.lease_until+2);const recovered=(await readOperation(db,next.id))!;
+ assert.equal(recovered.provider_id,'paid-start');assert.equal(recovered.state,'reconciling');assert.equal(await claimNext(db,'split'),null);
+});
+
+test('queued cancellation releases once; unknown cancellation prevents deletion and rejects late success',async t=>{
+ const {db,split}=await setup(t);const queued=await split();await cancelOperation(db,queued.id,subject);await cancelOperation(db,queued.id,subject);
+ assert.equal((await readOperation(db,queued.id))!.state,'cancelled');await db.prepare('DELETE FROM jobs WHERE id=?').bind(queued.id).run();
+ const accepted=await split('test-operation-0002');const lease=(await claimNext(db,'split'))!;await transition(db,lease,'starting');await beginAttempt(db,lease,'replicate','separator-pin');
+ await recoverExpired(db,lease.lease_until+1);await cancelOperation(db,accepted.id,subject);
+ await assert.rejects(db.prepare('DELETE FROM jobs WHERE id=?').bind(accepted.id).run());
+ await assert.rejects(db.prepare("UPDATE jobs SET status='done' WHERE id=?").bind(accepted.id).run());
+ assert.equal((await splitAllowance(db,subject)).inProgress,1);
+ await db.prepare("UPDATE jobs SET status='failed' WHERE id=?").bind(accepted.id).run();
+ await db.prepare("UPDATE jobs SET status='failed' WHERE id=?").bind(accepted.id).run();assert.equal((await splitAllowance(db,subject)).remaining,15);
+});
+
+test('provider 429 observes full cooldown, confirmed 402 releases, 5xx/network stay uncertain and cannot replay',async t=>{
+ for(const status of [429,402,503,0]) {
+ const {db,env,split}=await setup(t);const op=await split();let posts=0;
+ const mock=t.mock.method(globalThis,'fetch',async()=>{posts++;if(!status)throw new Error('lost response');return new Response('',{status,headers:{'Retry-After':'90'}});});
+ const before=Date.now();await drainSplitQueue(env,async()=>{});const state=(await readOperation(db,op.id))!;
+ assert.equal(state.state,status===429?'queued':status===402?'failed':'reconciling');
+ if(status===429){assert.ok(state.not_before>=before+90000);assert.equal(await claimNext(db,'split',before+89999),null);}
+ await drainSplitQueue(env,async()=>{});assert.equal(posts,1);
+ const count=await splitAllowance(db,subject);assert.equal(count.completed,0);assert.equal(count.inProgress,status===402?0:1);mock.mock.restore();
+ }
+});
+
+test('unknown callback requires matching phase/version and durable attempt; duplicate callback and polls settle once',async t=>{
+ const {db,env,split}=await setup(t);const op=await split();const lease=(await claimNext(db,'split'))!;
+ await transition(db,lease,'starting');await beginAttempt(db,lease,'replicate','separator-pin');await recoverExpired(db,lease.lease_until+1);
+ let version='import-pin',phase:'fetch'|'split'='fetch',reads=0;
+ t.mock.method(globalThis,'fetch',async()=>{reads++;return Response.json({id:'candidate',webhook:operationWebhook(env,op.id,phase),version,status:'succeeded',output:{}});});
+ await recoverCallback(env,(await readOperation(db,op.id))!,'candidate');assert.equal((await readOperation(db,op.id))!.provider_id,null);
+ phase='split';await recoverCallback(env,(await readOperation(db,op.id))!,'candidate');assert.equal((await readOperation(db,op.id))!.provider_id,null);
+ version='separator-pin';await recoverCallback(env,(await readOperation(db,op.id))!,'candidate');const accepted=(await readOperation(db,op.id))!;assert.equal(accepted.provider_id,'candidate');
+ await recoverCallback(env,accepted,'candidate');assert.equal(reads,3);
+ let completed=0;const finish=async()=>{completed++;await db.prepare("UPDATE jobs SET status='done' WHERE id=?").bind(op.id).run();};
+ await Promise.all([reconcileSplit(env,accepted,finish),reconcileSplit(env,accepted,finish)]);
+ assert.equal(completed,1);assert.equal((await splitAllowance(db,subject)).completed,1);
+});
+
+test('ninety-day purge redacts private request/cache content but preserves an uncertain reservation and cost evidence',async t=>{
+ const {db,split}=await setup(t);const op=await split();const lease=(await claimNext(db,'split'))!;await transition(db,lease,'starting');
+ const attempt=await beginAttempt(db,lease,'replicate','separator-pin');await finishAttempt(db,attempt,'uncertain',{code:'lost_response'});
+ await recoverExpired(db,lease.lease_until+1);
+ await db.prepare("INSERT INTO import_cache(scope,source,operation_id,object_key,metadata_json,expires_at) VALUES('scope','source',?,'private/key','private title',?)").bind(op.id,Date.now()+1000).run();
+ await purgeOperationContent(db,op.created_at+90*86400000+1);const remaining=(await readOperation(db,op.id))!;
+ assert.equal(remaining.request_json,'{}');assert.equal(remaining.result_json,null);assert.equal(remaining.cancel_requested,1);assert.equal(remaining.state,'reconciling');
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM import_cache').first<{n:number}>())!.n,0);
+ assert.equal((await db.prepare('SELECT outcome FROM operation_attempts WHERE id=?').bind(attempt).first<{outcome:string}>())!.outcome,'uncertain');
+});
+
+test('chat receipts never copy content; failure releases, partial/unknown count once, settled replay cannot call a model',async t=>{
+ const {db,env}=await setup(t);
+ const first=await reserveAssistant(env,subject,null,'chat','human-message-0001','job',[]);const receipt=assistantReceipt(env,first.operation);
+ await receipt.delta('Secret transcript');await receipt.failed();await assert.rejects(receipt.complete('Late secret'),/input ended/);
+ assert.equal((await readOperation(db,first.operation.id))!.state,'partial');assert.doesNotMatch((await readOperation(db,first.operation.id))!.result_json!,/Secret|Late/);
+ await assert.rejects(reserveAssistant(env,subject,null,'chat','human-message-0001','job',[]),/already accepted/);
+ const second=await reserveAssistant(env,subject,null,'chat','human-message-0002','job',[]);await assistantReceipt(env,second.operation).failed();
+ const third=await reserveAssistant(env,subject,null,'chat','human-message-0003','job',[]);await recoverExpired(db,Date.now()+90001);
+ assert.equal((await readOperation(db,third.operation.id))!.error_code,'no_usable_response');
+ await assert.rejects(assistantReceipt(env,third.operation).delta('Late output'));
+ const fourth=await reserveAssistant(env,subject,null,'chat','human-message-0004','job',[]);await assistantReceipt(env,fourth.operation).effect();await recoverExpired(db,Date.now()+90001);
+ assert.equal((await readOperation(db,fourth.operation.id))!.error_code,'delivery_uncertain');assert.equal((await operationAllowance(db,subject,'chat')).completed,2);
+ await assert.rejects(assistantReceipt(env,fourth.operation).complete('Late usable result'),/input ended/);
+ const fifth=await reserveAssistant(env,subject,null,'chat','human-message-0005','job',[]);
+ await assert.rejects(assistantReceipt(env,fifth.operation).complete(' '),/could not finish/);
+ assert.equal((await readOperation(db,fifth.operation.id))!.state,'failed');
+});
+
+test('mocked lifecycle rejects empty/corrupt audio, failed storage and cancelled/failed predictions without a successful charge',async t=>{
+ const fixture=readFileSync(new URL('../tests/fixtures/audio/vocals.mp3',import.meta.url));
+ for(const mode of ['empty','corrupt','storage','failed','cancelled','success']) {
+ const {db,env,split}=await setup(t);const operation=await split();let posts=0;
+ const mock=t.mock.method(globalThis,'fetch',async(input:RequestInfo|URL,init?:RequestInit)=>{
+ if(init?.method==='POST'){posts++;return Response.json({id:'prediction'});}
+ if(String(input).startsWith('https://api.replicate.com/'))return Response.json({id:'prediction',status:mode==='failed'?'failed':'succeeded',output:Object.fromEntries(['vocals','drums','bass','other'].map(name=>[name,`https://fixtures.replicate.delivery/${name}.mp3`]))});
+ return new Response(mode==='empty'?new Uint8Array():mode==='corrupt'?new Uint8Array([255,251,144,0]):fixture,{headers:{'Content-Type':'audio/mpeg'}});
+ });
+ let storage:ReturnType|undefined;
+ if(mode==='storage')storage=t.mock.method(env.AUDIO,'put',async()=>{throw new Error('simulated R2 unavailable');});
+ await runReliableJobs(env);if(mode==='cancelled')await cancelOperation(db,operation.id,subject);await runReliableJobs(env);await runReliableJobs(env);
+ assert.equal(posts,1);const op=(await readOperation(db,operation.id))!;
+ assert.equal(op.state,mode==='success'?'succeeded':mode==='cancelled'?'cancelled':'failed',mode);
+ const usage=await splitAllowance(db,subject);assert.equal(usage.completed,mode==='success'?1:0,mode);assert.equal(usage.inProgress,0,mode);
+ mock.mock.restore();storage?.mock.restore();
+ }
+});
+
+test('frame validation rejects truncation but documents its inability to establish PCM decodability',async t=>{
+ const {directory}=await setup(t);const audio=readFileSync(new URL('../tests/fixtures/audio/vocals.mp3',import.meta.url));
+ assert.ok(validMp3Frames(audio.buffer.slice(audio.byteOffset,audio.byteOffset+audio.length) as ArrayBuffer));
+ assert.equal(validMp3Frames(audio.buffer.slice(audio.byteOffset,audio.byteOffset+audio.length-17) as ArrayBuffer),false);
+ // Valid MPEG-1 Layer III 128kbps/44.1kHz headers; deliberately impossible
+ // side information. The structural checker accepts it; a real decoder fails.
+ const corrupt=Buffer.alloc(417*8,255);for(let i=0;i<8;i++)corrupt.set([255,251,144,0],417*i);
+ assert.equal(validMp3Frames(corrupt.buffer.slice(corrupt.byteOffset,corrupt.byteOffset+corrupt.length) as ArrayBuffer),true);
+ assert.equal(mp3FrameInfo(corrupt.buffer.slice(corrupt.byteOffset,corrupt.byteOffset+corrupt.length) as ArrayBuffer),null);
+ assert.equal(await validateMp3Pcm(corrupt.buffer.slice(corrupt.byteOffset,corrupt.byteOffset+corrupt.length) as ArrayBuffer,()=>new MPEGDecoder()),false);
+ const path=join(directory,'corrupt.mp3');writeFileSync(path,corrupt);
+ const result=spawnSync('ffmpeg',['-hide_banner','-loglevel','error','-xerror','-i',path,'-f','null','-'],{encoding:'utf8'});
+ assert.ifError(result.error);assert.notEqual(result.status,0);assert.match(result.stderr,/invalid|error|Error|decode|backstep|big_values/);
+});
+
+test('cron recovers abandoned ingestion and admits the next job without browser polling',async t=>{
+ const {db,env,split}=await setup(t),first=await split('orphan-ingestion-001'),next=await split('queued-next-work-002');
+ await db.prepare("UPDATE app_operations SET state='processing',provider_id='prediction' WHERE id=?").bind(first.id).run();
+ await db.prepare("UPDATE jobs SET status='ingesting',external_id='prediction',error=? WHERE id=?").bind(`ingesting:${Date.now()-600000}:old-worker`,first.id).run();
+ let posts=0;t.mock.method(globalThis,'fetch',async(_input:RequestInfo|URL,init?:RequestInit)=>{if(init?.method==='POST'){posts++;return Response.json({id:'next-prediction'});}return Response.json({id:'prediction',status:'succeeded',output:{}});});
+ await runReliableJobs(env);
+ assert.equal((await readOperation(db,first.id))?.state,'failed');
+ assert.equal((await readOperation(db,next.id))?.state,'processing');assert.equal(posts,1);
+});
+
+test('stale imported audio cleans up only its own object and cannot cancel a newer fenced operation',async t=>{
+ const {db,env,split}=await setup(t),op=await split('stale-import-work-01','fetch');
+ Object.assign(env,{REPLICATE_API_TOKEN:'fixture-replicate-token',REPLICATE_YT_MODEL:'test/importer',REPLICATE_YT_MODEL_VERSION:'a'.repeat(64)});
+ await db.prepare("UPDATE app_operations SET state='processing',provider_id='import-prediction' WHERE id=?").bind(op.id).run();
+ await db.prepare("UPDATE jobs SET status='importing' WHERE id=?").bind(op.id).run();
+ const data=Buffer.alloc(65536);data.write('ftyp',4);
+ t.mock.method(globalThis,'fetch',async(input:RequestInfo|URL)=>String(input).includes('api.replicate.com')?Response.json({id:'import-prediction',status:'succeeded',output:{audio:'https://fixtures.replicate.delivery/audio.m4a',title:'Fixture',duration:2}}):new Response(data,{headers:{'Content-Type':'audio/mp4'}}));
+ const put=env.AUDIO.put.bind(env.AUDIO);let staleKey='';
+ t.mock.method(env.AUDIO,'put',async(key:string,...args:any[])=>{staleKey=key;const saved=await (put as any)(key,...args);await db.prepare("UPDATE app_operations SET fence=fence+1,phase='split',state='processing',provider_id='new-separator',lease_owner='new-worker' WHERE id=?").bind(op.id).run();await db.prepare("UPDATE jobs SET status='processing',external_id='new-separator' WHERE id=?").bind(op.id).run();return saved;});
+ await reconcileSplit(env,(await readOperation(db,op.id))!,async()=>{});
+ const current=(await readOperation(db,op.id))!;assert.equal(current.state,'processing');assert.equal(current.provider_id,'new-separator');assert.equal(current.error_code,null);
+ assert.equal(await env.AUDIO.head(staleKey),null);
+});
+
+test('missing provider configuration creates no attempt or paid start and releases the reservation',async t=>{
+ const {db,env,split}=await setup(t);env.REPLICATE_MODEL_VERSION='';const op=await split('no-provider-pin-0001');let calls=0;
+ t.mock.method(globalThis,'fetch',async()=>{calls++;throw new Error('must not fetch');});await runReliableJobs(env);
+ assert.equal(calls,0);assert.equal((await readOperation(db,op.id))?.state,'failed');assert.equal((await readOperation(db,op.id))?.error_code,'provider_configuration');
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM operation_attempts').first())?.n,0);assert.equal((await splitAllowance(db,subject)).remaining,15);
+});
+
+test('separation status429 shares the complete Retry-After floor across queue drains and callbacks',async t=>{
+ const {db,env,split}=await setup(t),op=await split('poll-rate-limit-0001');
+ await db.prepare("UPDATE app_operations SET state='processing',provider_id='prediction' WHERE id=?").bind(op.id).run();
+ let calls=0;t.mock.method(globalThis,'fetch',async()=>{calls++;return new Response(null,{status:429,headers:{'Retry-After':'120'}});});
+ const before=Date.now();await runReliableJobs(env);const current=(await readOperation(db,op.id))!;
+ assert.ok(current.not_before>=before+120000);assert.ok((await db.prepare("SELECT until_ms FROM provider_cooldowns WHERE provider='replicate'").first())!.until_ms>=before+120000);
+ await runReliableJobs(env);assert.equal(calls,1);
+ const {default:app}=await import('../src/index.ts');
+ const response=await app.fetch(new Request(operationWebhook(env,op.id,'split'),{method:'POST',body:'{}'}),env);
+ assert.equal(response.status,200);assert.equal(calls,1);assert.ok((await readOperation(db,op.id))!.not_before>=before+120000);
+});
+
+test('lost final database acknowledgement preserves committed stem objects and charges once',async t=>{
+ for(const mode of ['before_commit','lost_ack','lost_ack_read_unavailable']) {
+ const {db,env,split}=await setup(t),op=await split(`ingest-commit-${mode}`);const fixture=readFileSync(new URL('../tests/fixtures/audio/vocals.mp3',import.meta.url));
+ const mock=t.mock.method(globalThis,'fetch',async(input:RequestInfo|URL,init?:RequestInit)=>init?.method==='POST'?Response.json({id:'prediction'}):String(input).includes('api.replicate.com')?Response.json({id:'prediction',status:'succeeded',output:Object.fromEntries(['vocals','drums','bass','other'].map(name=>[name,`https://fixtures.replicate.delivery/${name}.mp3`]))}):new Response(fixture));
+ const prepare=db.prepare.bind(db);let injected=false;
+ const dbMock=t.mock.method(db,'prepare',(sql:string)=>{
+ if(mode==='lost_ack_read_unavailable'&&injected&&sql.startsWith('SELECT status,stems,error FROM jobs'))throw new Error('reread unavailable');
+ const statement=prepare(sql);
+ if(sql.includes('SET status = ?, stems = ?')) {
+ const bind=statement.bind.bind(statement);statement.bind=(...args:any[])=>{const bound=bind(...args),run=bound.run.bind(bound);bound.run=async()=>{if(!injected){injected=true;if(mode==='before_commit')throw new Error('before commit');await run();throw new Error('lost acknowledgement');}return run();};return bound;};
+ }
+ return statement;
+ });
+ await runReliableJobs(env);await runReliableJobs(env);
+ const job=await db.prepare('SELECT status,stems FROM jobs WHERE id=?').bind(op.id).first();assert.equal(injected,true);
+ assert.equal(job.status,mode.startsWith('lost_ack')?'done':'failed');assert.equal((await splitAllowance(db,subject)).completed,mode.startsWith('lost_ack')?1:0);
+ if(mode.startsWith('lost_ack'))for(const stem of JSON.parse(job.stems))assert.ok(await env.AUDIO.head(stem.key),stem.key);
+ mock.mock.restore();dbMock.mock.restore();
+ }
+});
+
+test('a shared provider cooldown fences reconciliation of other operations in both phases',async t=>{
+ const {db,env,split}=await setup(t),fetch=await split('shared-floor-fetch-01','fetch'),separate=await split('shared-floor-split-01');
+ for(const op of [fetch,separate])await db.prepare("UPDATE app_operations SET state='processing',provider_id='known-provider-id',not_before=0 WHERE id=?").bind(op.id).run();
+ await db.prepare("INSERT INTO provider_cooldowns(provider,until_ms) VALUES('replicate',?)").bind(Date.now()+120000).run();
+ let calls=0;t.mock.method(globalThis,'fetch',async()=>{calls++;throw new Error('shared floor must prevent any network');});
+ await runReliableJobs(env);assert.equal(calls,0);
+ for(const op of [fetch,separate])assert.equal((await readOperation(db,op.id))!.fence,0);
+});
+
+test('a crash after chat reservation but before its running transition releases without usable output',async t=>{
+ const {db}=await setup(t);const now=Date.now();
+ const {operation}=await reserveOperation(db,{subject,courseId:null,kind:'chat',phase:'chat',key:'queued-chat-crash-01',fingerprint:'input',now});
+ await recoverExpired(db,now+89999);assert.equal((await readOperation(db,operation.id))!.state,'queued');
+ await recoverExpired(db,now+90001);assert.equal((await readOperation(db,operation.id))!.state,'failed');
+ assert.equal((await operationAllowance(db,subject,'chat')).remaining,50);
+});
+test('a confirmed oversize import releases its allowance and shows the supported limit and upload alternative',async t=>{
+ const {db,env,split}=await setup(t),op=await split('oversize-import-0001','fetch');
+ env.REPLICATE_YT_MODEL='fixture/import';env.REPLICATE_YT_MODEL_VERSION='a'.repeat(64);env.REPLICATE_API_TOKEN='mock-only-import-token-0000';
+ await db.prepare("UPDATE app_operations SET state='processing',provider_id='oversize-fixture' WHERE id=?").bind(op.id).run();
+ t.mock.method(globalThis,'fetch',async(input:RequestInfo|URL)=>String(input).startsWith('https://api.replicate.com/')
+ ?Response.json({id:'oversize-fixture',status:'succeeded',output:{audio:'https://fixtures.replicate.delivery/source.m4a',title:'Fixture',duration:240}})
+ :new Response(new Uint8Array([0,0,0,24]),{headers:{'Content-Length':String(12*1024*1024+1),'Content-Type':'audio/mp4'}}));
+ await reconcileSplit(env,(await readOperation(db,op.id))!,async()=>assert.fail('Import must not reach separation'));
+ assert.equal((await readOperation(db,op.id))!.error_code,'audio_too_large');
+ const job=await db.prepare('SELECT status,error FROM jobs WHERE id=?').bind(op.id).first();
+ assert.equal(job!.status,'failed');assert.match(job!.error,/12 MiB/);assert.match(job!.error,/upload an original or licensed file/);
+ assert.equal((await splitAllowance(db,subject)).completed,0);assert.equal((await splitAllowance(db,subject)).inProgress,0);
+});
diff --git a/cloudflare/retention.ts b/cloudflare/retention.ts
index ef1314e..5d198f0 100644
--- a/cloudflare/retention.ts
+++ b/cloudflare/retention.ts
@@ -1,6 +1,7 @@
export async function purgeExpiredListeningConversations(db: D1Database): Promise {
const result = await db.prepare("DELETE FROM listening_conversations WHERE expires_at <= datetime('now')").run();
await db.batch([
+ db.prepare("DELETE FROM guest_listening_conversations WHERE expires_at<=datetime('now') OR EXISTS(SELECT 1 FROM guest_sessions s WHERE s.subject=guest_listening_conversations.subject AND (s.revoked_at IS NOT NULL OR s.expires_at<=CAST(strftime('%s','now') AS INTEGER)*1000))"),
db.prepare("DELETE FROM course_messages WHERE EXISTS(SELECT 1 FROM course_conversations c WHERE c.job_id=course_messages.job_id AND c.subject=course_messages.subject AND c.expires_at<=datetime('now')) OR NOT EXISTS(SELECT 1 FROM jobs WHERE id=course_messages.job_id AND created_at>datetime('now','-90 days'))"),
db.prepare("DELETE FROM course_turns WHERE EXISTS(SELECT 1 FROM course_conversations c WHERE c.job_id=course_turns.job_id AND c.subject=course_turns.subject AND c.expires_at<=datetime('now')) OR NOT EXISTS(SELECT 1 FROM jobs WHERE id=course_turns.job_id AND created_at>datetime('now','-90 days'))"),
db.prepare("DELETE FROM course_conversations WHERE expires_at<=datetime('now') OR NOT EXISTS(SELECT 1 FROM jobs WHERE id=course_conversations.job_id AND created_at>datetime('now','-90 days'))"),
diff --git a/cloudflare/scripts/extract-mp3-wasm.mjs b/cloudflare/scripts/extract-mp3-wasm.mjs
new file mode 100644
index 0000000..ba3ea4f
--- /dev/null
+++ b/cloudflare/scripts/extract-mp3-wasm.mjs
@@ -0,0 +1,15 @@
+// Reproduce the static module from the exact installed npm pin; no network.
+import {createRequire} from 'node:module';
+import {dirname,join} from 'node:path';
+import {writeFileSync,readFileSync} from 'node:fs';
+import {createHash} from 'node:crypto';
+import {WASMAudioDecoderCommon as Common} from '@wasm-audio-decoders/common';
+const root=dirname(createRequire(import.meta.url).resolve('mpg123-decoder'));
+if(JSON.parse(readFileSync(join(root,'package.json'),'utf8')).version!=='1.0.3')throw Error('Unexpected decoder version');
+const {default:Wasm}=await import(join(root,'src/EmscriptenWasm.js'));
+new Common();new Wasm(Common);
+const bytes=await Common.inflateDynEncodeString(Wasm.wasm);
+const sha256=createHash('sha256').update(bytes).digest('hex');
+if(sha256!=='f0e9e8848367320ccb757e4092f0d6809668cfe38f43139d47b447151d7dff6b')throw Error('Unexpected Wasm digest');
+writeFileSync(new URL('../vendor/mpg123-1.0.3.wasm',import.meta.url),bytes);
+console.log(JSON.stringify({bytes:bytes.length,sha256}));
diff --git a/cloudflare/sharing.spec.mjs b/cloudflare/sharing.spec.mjs
index ea8a605..8717a8c 100644
--- a/cloudflare/sharing.spec.mjs
+++ b/cloudflare/sharing.spec.mjs
@@ -28,7 +28,7 @@ test('shared split opens signed out, plays, and keeps private tools and data ina
await expect(page.locator('#stem-choice')).not.toContainText(/parts/i);
await expect(page.getByRole('radio', { name: '4 splits: voice, percussion, low end, the rest', exact: true })).toBeVisible();
await expect(page.locator('footer .project-credit')).toContainText('Critical AI Literacy Institute');
- expect(await page.locator('footer').evaluate(el => getComputedStyle(el).borderTopWidth)).toBe('1px');
+ expect(await page.locator('main > footer').evaluate(el => getComputedStyle(el).borderTopWidth)).toBe('1px');
expect(await page.locator('.project-credit').evaluate(el => getComputedStyle(el).borderBottomWidth)).toBe('1px');
await page.locator('.share-btn:not(.to-remix-btn)').click();
await expect.poll(async () => (await server.fetch('/api/shared-jobs/remix-fixture')).status).toBe(200);
diff --git a/cloudflare/sso.test.ts b/cloudflare/sso.test.ts
index f939f8e..66a678e 100644
--- a/cloudflare/sso.test.ts
+++ b/cloudflare/sso.test.ts
@@ -209,14 +209,14 @@ test('logout clears browser cookies during identity outages and without an activ
for (const client of [undefined, { ...f.client, revoke: async () => { throw new Error('private'); } }]) {
const response = await handleAuth(request, { ...f.env, IDENTITY: client });
assert.equal(response.status, 503);
- assert.equal(response.headers.getSetCookie().length, 2);
+ assert.equal(response.headers.getSetCookie().length, 3);
assert.ok(response.headers.getSetCookie().every(value => value.includes('Max-Age=0')));
assert.match(await response.text(), /Signed out of this browser/);
}
const anonymous = await handleAuth(f.request('/auth/logout', { method: 'POST', headers: { Origin: origin } }), { ...f.env, IDENTITY: undefined });
assert.equal(anonymous.status, 303);
assert.equal(anonymous.headers.get('location'), '/');
- assert.equal(anonymous.headers.getSetCookie().length, 2);
+ assert.equal(anonymous.headers.getSetCookie().length, 3);
const unsafe = await handleAuth(f.request('/auth/logout', { method: 'POST', headers: { Origin: 'null' } }), f.env);
assert.equal(unsafe.status, 403);
assert.equal(unsafe.headers.has('set-cookie'), false);
diff --git a/cloudflare/sso.ts b/cloudflare/sso.ts
index bff9574..b64ab3b 100644
--- a/cloudflare/sso.ts
+++ b/cloudflare/sso.ts
@@ -3,6 +3,7 @@ import { authFailure } from '../src/identity.ts';
import { CAIL_CANONICAL_ISSUER, loadIdentityVerifierConfig, verifyIdentityJwt } from '@cuny-ai-lab/cail-identity';
import { REQUEST_ID } from './gateway.ts';
import { validClassId } from '../src/classroom/contract.ts';
+import { clearGuestCookie } from './guest.ts';
// Doorway owns CUNY OIDC, one-use PKCE grants, session revocation and Admission.
// These RPC capabilities have deployment-pinned audiences and callback hosts.
@@ -19,6 +20,8 @@ export interface SsoEnv {
PREVIEW_IDENTITY?: WorkerIdentity;
REQUEST_LIMIT?: { limit(input: { key: string }): Promise<{ success: boolean }> };
CAIL_IDENTITY_JWKS?: string;
+ /** Private adapter callback; never sent to Doorway or supplied by a browser. */
+ guestSignout?: () => Promise;
}
export const SESSION_COOKIE = '__Host-stem-session';
export const LOGIN_COOKIE = '__Host-stem-login';
@@ -122,9 +125,10 @@ export async function handleAuth(request: Request, env: SsoEnv): Promise {
+export function sanitizedRequest(request: Request): Request {
const headers = new Headers(request.headers);
const requestId = headers.get('x-cail-request-id') ?? headers.get('x-request-id');
// Even a valid app JWT submitted by the browser is not authority here.
for (const name of [...headers.keys()]) if (name === 'cookie' || name === 'authorization' || name.startsWith('x-cail-')) headers.delete(name);
if (requestId && REQUEST_ID.test(requestId)) headers.set('x-cail-request-id', requestId);
+ return new Request(request.url,{method:request.method,headers,body:request.body,redirect:'manual',signal:request.signal});
+}
+
+export async function authenticatedRequest(request: Request, env: SsoEnv): Promise {
+ const headers = new Headers(sanitizedRequest(request).headers);
if (!publicApi(request)) {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
(request.headers.get('origin') !== env.PUBLIC_BASE_URL || request.headers.get('sec-fetch-site') === 'cross-site')) return denied(403);
diff --git a/cloudflare/test-bug-report-worker.ts b/cloudflare/test-bug-report-worker.ts
new file mode 100644
index 0000000..17fa5b8
--- /dev/null
+++ b/cloudflare/test-bug-report-worker.ts
@@ -0,0 +1,24 @@
+// Local fixture only. This module is never referenced by deployment configuration.
+import app from './worker.ts';
+let mode = 'accepted';
+let calls: unknown[] = [];
+const reports = new Map();
+export default {
+ async fetch(request: Request, env: any, ctx: ExecutionContext) {
+ const headers = new Headers(request.headers); headers.set('cf-connecting-ip', '192.0.2.5');
+ request = new Request(request, { headers });
+ const url = new URL(request.url);
+ if (url.pathname === '/__bug-fixture') {
+ if (request.method === 'POST') { mode = (await request.json()).mode; calls = []; reports.clear(); }
+ return Response.json({ mode, calls });
+ }
+ const service = { submit: async (report: any) => {
+ calls.push(report);
+ if (mode === 'unavailable') return { ok: false, code: 'unavailable', retryable: true, retryAfterSeconds: 1 };
+ if (mode === 'unknown') return { ok: false, code: 'delivery_unknown', retryable: false, reportId: report.reportId };
+ if (!reports.has(report.reportId)) reports.set(report.reportId, { ok: true, status: 'accepted', reportId: report.reportId });
+ return reports.get(report.reportId);
+ } };
+ return app.fetch(request, { ...env, PUBLIC_BASE_URL: url.origin, BUG_REPORTS: service }, ctx);
+ },
+};
diff --git a/cloudflare/test-wasm-register.mjs b/cloudflare/test-wasm-register.mjs
new file mode 100644
index 0000000..66f20e3
--- /dev/null
+++ b/cloudflare/test-wasm-register.mjs
@@ -0,0 +1,11 @@
+// Node unit tests need the same compiled-module import shape Wrangler supplies.
+// This loader is never part of a deployment or the Workerd test harness bundle.
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+registerHooks({load(url,context,nextLoad) {
+ if(url.endsWith('/vendor/mpg123-1.0.3.wasm')) {
+ const bytes=readFileSync(new URL(url));
+ return {format:'module',shortCircuit:true,source:`export default new WebAssembly.Module(Uint8Array.from(${JSON.stringify([...bytes])}));`};
+ }
+ return nextLoad(url,context);
+}});
diff --git a/cloudflare/test-worker.ts b/cloudflare/test-worker.ts
index 2141b2e..8820460 100644
--- a/cloudflare/test-worker.ts
+++ b/cloudflare/test-worker.ts
@@ -1,3 +1,5 @@
+import { pollYouTubeImport } from '../src/youtube.ts';
+import { validateStemAudio } from './audio-validator.ts';
import { courseFixture } from './course-fixture.ts';
import { STEM_COURSE_ID } from '../src/classroom/contract.ts';
// Local test entrypoint only. Never referenced by the deployment config.
@@ -9,6 +11,9 @@ let gatewayCalls = 0;
let gatewayMessages: Array<{ role: string; content: string }> = [];
export default {
async fetch(request: Request, env: TestEnv, ctx: ExecutionContext) {
+ if(new URL(request.url).pathname==='/__fixture/inline-import'&&request.headers.get('x-fixture')==='local-only') {const audio=await pollYouTubeImport('inline-memory-fixture',{...env,REPLICATE_API_TOKEN:'test-replicate-token-000',REPLICATE_YT_MODEL:'test/fixture',REPLICATE_YT_MODEL_VERSION:'a'.repeat(64)});return Response.json({bytes:audio?.data.byteLength});}
+ if(new URL(request.url).pathname==='/__fixture/validate-audio'&&request.headers.get('x-fixture')==='local-only')return Response.json({valid:await validateStemAudio(await request.arrayBuffer())});
+ if(new URL(request.url).pathname==='/__fixture/operations'&&request.headers.get('x-fixture')==='local-only')return Response.json((await env.DB.prepare('SELECT id,state,phase,error_code,fence,lease_until,not_before FROM app_operations').all()).results);
if (new URL(request.url).pathname === '/__fixture/gateway-messages' && request.headers.get('x-fixture') === 'local-only') return Response.json({ messages: gatewayMessages });
if (new URL(request.url).pathname === '/__fixture/purge-conversations' && request.headers.get('x-fixture') === 'local-only') {
return Response.json({ deleted: await purgeExpiredListeningConversations(env.DB) });
@@ -50,11 +55,14 @@ export default {
PUBLIC_BASE_URL: env.TEST_BROWSER === 'true' ? new URL(request.url).origin : env.PUBLIC_BASE_URL,
CAIL_IDENTITY_JWKS: env.TEST_JWKS,
GATEWAY_MODEL: 'glm-5.2',
+ GATEWAY_FALLBACK_MODEL: 'deepseek-v4-flash-0731',
GATEWAY: { fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const outbound = new Request(input, init);
if (new URL(outbound.url).pathname.endsWith('/quota')) return Response.json({ object: 'quota', managed_by: 'cloudflare', state: 'estimated', unit: 'microdollar', currency: 'USD', limit: 1000000, estimated_used: 100000, estimated_remaining: 900000, used_percent: 10, remaining_percent: 90, window_seconds: 86400, window_technique: 'sliding', calculated_at: Math.floor(Date.now() / 1000) });
gatewayCalls++;
- if (outbound.headers.get('x-cail-identity-jwt') !== gatewayJwt || outbound.headers.has('authorization')) throw new Error('fixture credential contract');
+ if (env.GUEST_ENABLED === 'true' && outbound.headers.get('authorization') === `Bearer ${env.GUEST_GATEWAY_API_KEY}`) {
+ if(outbound.headers.has('x-cail-identity-jwt'))throw new Error('fixture mixed credential contract');
+ } else if (outbound.headers.get('x-cail-identity-jwt') !== gatewayJwt || outbound.headers.has('authorization')) throw new Error('fixture credential contract');
const body = await outbound.json<{ model: string }>();
if (body.model !== 'glm-5.2') throw new Error('fixture model contract');
if ('messages' in body && Array.isArray((body as { messages?: unknown }).messages)) gatewayMessages = (body as { messages: Array<{ role: string; content: string }> }).messages;
@@ -62,7 +70,7 @@ export default {
const requestId = '01900000-0000-7000-8000-000000000001';
const error = { error: { code: 'quota_exceeded', type: 'quota_exceeded', param: null, message: 'private fixture', cail: { request_id: requestId, should_retry: false } } };
if (gatewayMode === 'quota') return Response.json(error, { status: 429, headers: { 'x-should-retry': 'false', 'x-request-id': requestId } });
- const chunks = [{ choices: [{ delta: { content: 'Listen for the bass against the drums.' }, finish_reason: null }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] },
+ const chunks = [{ choices: [{ delta: { content: gatewayMode==='empty'?'':'Listen for the bass against the drums.' }, finish_reason: null }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] },
gatewayMode === 'trailing-error' ? error : { choices: [], usage: { total_tokens: 20 } }];
return new Response(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join('') + 'data: [DONE]\n\n', { headers: { 'Content-Type': 'text/event-stream', 'x-request-id': requestId } });
} } as Fetcher,
diff --git a/cloudflare/tsconfig.json b/cloudflare/tsconfig.json
index 4c618c6..8c47efd 100644
--- a/cloudflare/tsconfig.json
+++ b/cloudflare/tsconfig.json
@@ -1 +1 @@
-{ "extends": "../tsconfig.json", "include": ["worker.ts", "alias.ts", "verify.ts", "worker-configuration.d.ts", "alias-configuration.d.ts", "../src"] }
+{ "extends": "../tsconfig.json", "include": ["worker.ts", "wasm.d.ts", "alias.ts", "verify.ts", "worker-configuration.d.ts", "alias-configuration.d.ts", "../src"] }
diff --git a/cloudflare/vendor/README.md b/cloudflare/vendor/README.md
new file mode 100644
index 0000000..7063d7c
--- /dev/null
+++ b/cloudflare/vendor/README.md
@@ -0,0 +1,32 @@
+# Locally bundled MPEG decoder
+
+`mpg123-1.0.3.wasm` is the unmodified 99,479-byte Wasm payload extracted from
+the locked `mpg123-decoder@1.0.3` npm package. SHA-256:
+`f0e9e8848367320ccb757e4092f0d6809668cfe38f43139d47b447151d7dff6b`.
+Reproduce it after the frozen adapter install with
+`node scripts/extract-mp3-wasm.mjs` from `cloudflare/`. The script checks the
+package version and binary digest and makes no network requests.
+
+The JavaScript wrapper is Copyright 2021–2025 Ethan Halsall, published under
+MIT. Its exact source is [wasm-audio-decoders 8f2428c](https://github.com/eshaz/wasm-audio-decoders/tree/8f2428c1cd96b54dab74836c8471ff75fe35cbee).
+The underlying mpg123 source is [08247b3](https://github.com/madebr/mpg123/tree/08247b317163175e62035893af3ff9e71a5dfefd),
+with its complete license notice in [mpg123-COPYING.txt](mpg123-COPYING.txt).
+Upstream's Makefile and C wrapper provide the corresponding source and rebuild
+recipe. The module is separately replaceable; the application imports it as a
+Wasm module rather than modifying or embedding its binary in application code.
+
+`audio-validator.ts` uses the pinned main-thread module entry and module
+injection hook. The normal package entry also constructs browser Worker
+helpers, unavailable in workerd. There is no runtime compilation, network
+decoder fetch, browser-supplied validation, external service, or inference.
+The fixed Wasm heap is 16,973,824 bytes and refuses heap growth. Shared
+validation admits at most 24 MiB compressed input and 900.1 seconds, decodes
+4 KiB chunks, checks finite PCM, and drops each PCM chunk immediately.
+
+mpg123 intentionally conceals some damaged frames. Therefore the application
+also checks Layer III side information before decoding. A demonstrated corrupt
+fixture has legal frame headers but an impossible initial reservoir and
+`big_values=511` (maximum 288); framing and tolerant PCM output alone accept it,
+while the combined validator rejects it. This is bounded decode validation,
+not proof against every possible bit corruption or a judgment of sound quality.
+Legitimate silent stems remain acceptable.
diff --git a/cloudflare/vendor/mpg123-1.0.3.wasm b/cloudflare/vendor/mpg123-1.0.3.wasm
new file mode 100644
index 0000000..79494f2
Binary files /dev/null and b/cloudflare/vendor/mpg123-1.0.3.wasm differ
diff --git a/cloudflare/vendor/mpg123-COPYING.txt b/cloudflare/vendor/mpg123-COPYING.txt
new file mode 100644
index 0000000..14e14f0
--- /dev/null
+++ b/cloudflare/vendor/mpg123-COPYING.txt
@@ -0,0 +1,772 @@
+This is the file that contains the terms of use, copying, etc. for the mpg123 distribution package.
+
+Main message, to include in "About ..." boxes, etc:
+
+ Copyright (c) 1995-2020 by Michael Hipp and others,
+ free software under the terms of the LGPL v2.1
+
+There is an attempt to cover the actual list of authors in the AUTHORS file.
+Project maintainer since 2006 is Thomas Orgis and many people have contributed
+since the Michael Hipp era, but he stays the initial source and it would
+be impractical to count them all individually, so it's "and others".
+Source files contain the phrase "the mpg123 project" to the same effect
+in their license boilerplate; especially those that were added after
+maintainership changed. The person mainly responsible for the first version
+is usually named in the phrase "initially written by ...".
+
+All files in the distribution that don't carry a license note on their own are
+licensed under the terms of the LGPL 2.1; exceptions may apply, especially to
+files not in the official distribution but in the revision control repository.
+
+The formal license text follows.
+
+=======================
+1. The LGPL version 2.1
+=======================
+
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+
+====================
+2. The GPL version 2
+====================
+
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
diff --git a/cloudflare/wasm.d.ts b/cloudflare/wasm.d.ts
new file mode 100644
index 0000000..7eb3ff6
--- /dev/null
+++ b/cloudflare/wasm.d.ts
@@ -0,0 +1 @@
+declare module '*.wasm' {const module:WebAssembly.Module;export default module;}
diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts
index 1e2606f..4624b7f 100644
--- a/cloudflare/worker.test.ts
+++ b/cloudflare/worker.test.ts
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
+import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createTestHarness } from 'wrangler';
import { setupServer } from 'msw/node';
@@ -17,7 +18,9 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
const mp3 = await readFile(new URL('../tests/fixtures/audio/vocals.mp3', import.meta.url));
let providerStarts = 0;
let statusFetches = 0;
+ const inlineAudio=Buffer.alloc(12*1024*1024);inlineAudio.set([0,0,0,24,102,116,121,112,77,52,65,32]);
const network = setupServer(
+ http.get('https://api.replicate.com/v1/predictions/inline-memory-fixture',()=>HttpResponse.json({id:'inline-memory-fixture',status:'succeeded',output:{audio:'data:audio/mp4;base64,'+inlineAudio.toString('base64'),title:'Memory fixture',duration:600}})),
http.post('https://api.replicate.com/v1/predictions', async ({ request }) => {
const body = await request.json();
assert.equal(body.version, 'contract-pin');
@@ -41,12 +44,39 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
// Direct Worker dispatch avoids the development reload proxy; the actual
// production entrypoint, private bindings, D1 and R2 still run in Workerd.
const worker = server.getWorker('stem-preview-contract-test');
+ assert.equal((await (await worker.fetch('/healthz')).json()).courseAccess, null, 'Doorway keeps course launch unavailable before migration');
const schema = schemaStatements(await readFile(new URL('../schema.sql', import.meta.url), 'utf8'));
const setup = await worker.fetch('/__fixture/schema', { method: 'POST', headers: { 'x-fixture': 'local-only', 'Content-Type': 'application/json' }, body: JSON.stringify(schema) });
assert.equal(setup.status, 200);
+ const readyHealth = await (await worker.fetch('/healthz')).json();
+ assert.deepEqual(readyHealth.courseAccess, { contract: 'stem-course-v1' });
+ assert.equal(readyHealth.intakePaused, false);
+ // Real locally bundled Wasm runs in Workerd, with no inference/media service.
+ for(const valid of [true,false]) {
+ const audio=valid?mp3:Buffer.alloc(417*8,255);
+ if(!valid)for(let i=0;i<8;i++)audio.set([255,251,144,0],417*i);
+ const checked=await worker.fetch('/__fixture/validate-audio',{method:'POST',headers:{'x-fixture':'local-only'},body:audio});
+ assert.deepEqual(await checked.json(),{valid});
+ }
+
+ const silence=spawnSync('ffmpeg',['-hide_banner','-loglevel','error','-f','lavfi','-i','anullsrc=r=48000:cl=stereo','-t','2','-c:a','libmp3lame','-b:a','128k','-f','mp3','pipe:1'],{timeout:10000,maxBuffer:1024*1024});
+ assert.equal(silence.status,0,String(silence.stderr));
+ assert.deepEqual(await(await worker.fetch('/__fixture/validate-audio',{method:'POST',headers:{'x-fixture':'local-only'},body:silence.stdout})).json(),{valid:true});
+
+ // A long high-bitrate stem and an ordinary-size inline import overlap in
+ // the same Workerd isolate. This is a local bounded-input check, not a
+ // claimed production CPU or peak-RSS measurement.
+ const longAudio=spawnSync('ffmpeg',['-hide_banner','-loglevel','error','-f','lavfi','-i','anullsrc=r=48000:cl=stereo','-t','600','-c:a','libmp3lame','-b:a','320k','-f','mp3','pipe:1'],{timeout:30000,maxBuffer:25*1024*1024});
+ assert.equal(longAudio.status,0,String(longAudio.stderr));assert.ok(longAudio.stdout.length>22*1024*1024);
+ const largeChecks=await Promise.all([
+ worker.fetch('/__fixture/validate-audio',{method:'POST',headers:{'x-fixture':'local-only'},body:longAudio.stdout}).then(r=>r.json()),
+ worker.fetch('/__fixture/inline-import',{headers:{'x-fixture':'local-only'}}).then(r=>r.json()),
+ ]);
+ assert.deepEqual(largeChecks,[{valid:true},{bytes:12*1024*1024}]);
+
const call = async (path: string, who = 0, init: RequestInit = {}) => {
const response = await worker.fetch(path, {
- ...init, headers: { 'x-fixture-identity': tokens[who], 'x-fixture-gateway-identity': gatewayTokens[who], Origin: 'https://split.test', 'Content-Type': 'application/json', ...init.headers },
+ ...init, headers: { 'x-fixture-identity': tokens[who], 'x-fixture-gateway-identity': gatewayTokens[who], Origin: 'https://split.test', 'Content-Type': 'application/json', ...(init.method==='POST'?{'Idempotency-Key':crypto.randomUUID()}:{}), ...init.headers },
});
const body = await response.arrayBuffer();
assert.notEqual(response.status, 500, new TextDecoder().decode(body));
@@ -82,10 +112,10 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
assert.equal((await call(uploadPath, 1, upload)).status, 404);
assert.equal((await call(uploadPath, 0, upload)).status, 204);
assert.equal((await call(uploadPath, 0, upload)).status, 409);
- const jobRequest = { method: 'POST', body: JSON.stringify({ key: grant.key, filename: 'source.wav', model: 'htdemucs_ft' }) };
+ const jobRequest = { method: 'POST', headers:{'Idempotency-Key':'workerd-split-request-0001'}, body: JSON.stringify({ key: grant.key, filename: 'source.wav', model: 'htdemucs_ft' }) };
assert.equal((await call('/api/jobs', 1, jobRequest)).status, 404);
const createdResponse = await call('/api/jobs', 0, jobRequest);
- assert.equal(createdResponse.status, 200, await createdResponse.clone().text());
+ assert.equal(createdResponse.status, 202, await createdResponse.clone().text());
const created = await createdResponse.json();
assert.equal(created.savedToAccount, true);
const listed = await (await call('/api/jobs')).json();
@@ -96,15 +126,26 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
assert.deepEqual((await (await call('/api/jobs', 2)).json()).jobs, []); // Admin rack is still personal.
assert.equal((await worker.fetch('/api/jobs')).status, 401);
assert.equal((await call('/api/jobs?cursor=bad')).status, 400);
- assert.equal(providerStarts, 1);
+ for(let i=0;i<20&&providerStarts===0;i++){await call(`/api/jobs/${created.id}`);await new Promise(resolve=>setTimeout(resolve,20));}
+ assert.equal(providerStarts, 1,JSON.stringify(await(await worker.fetch('/__fixture/operations',{headers:{'x-fixture':'local-only'}})).json()));
assert.equal((await call(`/api/jobs/${created.id}`, 1)).status, 404);
assert.equal((await call(`/api/files/%73tems/${created.id}/vocals.mp3`, 1)).status, 400);
- const callback = await worker.fetch(`/api/webhooks/separation?job=${created.id}&token=contract-webhook`, { method: 'POST', body: '{"output":{"vocals":"https://attacker.test/private"}}' });
+ const callback = await worker.fetch(`/api/webhooks/separation?job=${created.id}&phase=split&token=contract-webhook`, { method: 'POST', body: '{"output":{"vocals":"https://attacker.test/private"}}' });
assert.equal(callback.status, 200);
- assert.equal(statusFetches, 1);
+ assert.ok(statusFetches>=1);
const job = await (await call(`/api/jobs/${created.id}`)).json();
assert.equal(job.status, 'done');
assert.equal(job.stems.length, 4);
+ const usageEvent={id:'workerd-usage-event-0001',type:'seek',jobId:created.id,positionBucket:4};
+ assert.equal((await call('/api/usage-events',1,{method:'POST',headers:{'X-Stem-Usage-Actor':subjects[1]},body:JSON.stringify({events:[usageEvent]})})).status,404);
+ assert.equal((await call('/api/usage-events',0,{method:'POST',headers:{'X-Stem-Usage-Actor':subjects[0]},body:JSON.stringify({events:[{...usageEvent,text:'must not be logged'}]})})).status,400);
+ assert.equal((await call('/api/usage-events',1,{method:'POST',headers:{'X-Stem-Usage-Actor':subjects[0]},body:JSON.stringify({events:[{id:'old-account-page-0001',type:'page_view'}]})})).status,409,'old queued observations cannot become the next account');
+ for(let n=0;n<2;n++)assert.equal((await call('/api/usage-events',0,{method:'POST',headers:{'X-Stem-Usage-Actor':subjects[0]},body:JSON.stringify({events:[usageEvent]})})).status,200);
+ assert.equal((await call('/api/admin/usage-events')).status,403);
+ const usage=await(await call('/api/admin/usage-events',2)).json();
+ assert.equal(usage.uses.find((row:any)=>row.event_type==='seek').count,1);
+ assert.equal(JSON.stringify(usage).includes(subjects[0]),false);
+
const sharePath = `/api/jobs/${created.id}/share`;
const publicPath = `/api/shared-jobs/${created.id}`;
assert.equal((await worker.fetch(publicPath)).status, 404);
@@ -164,7 +205,7 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
assert.equal((await call(conversationPath, 0, { method: 'PUT', body: JSON.stringify({ entries: [{ kind: 'system', text: 'no' }], revision: 0 }) })).status, 400);
assert.equal((await call(conversationPath, 0, { method: 'PUT', body: JSON.stringify({ entries: [], revision: 8 }) })).status, 409);
assert.equal((await call(`/api/files/stems/${created.id}/vocals.mp3`, 1)).status, 404);
- const stem = await call(`/api/files/stems/${created.id}/vocals.mp3`);
+ const stem = await call(job.stems[0].url);
assert.equal(stem.status, 200);
assert.deepEqual(Buffer.from(await stem.arrayBuffer()), mp3);
assert.match(stem.headers.get('cache-control')!, /no-store/);
@@ -183,29 +224,14 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
const quota = await (await call('/api/model-quota')).json();
assert.equal(quota.quota.remaining_percent, 90);
assert.equal(await stats(), 3);
- const attempts = await Promise.all(Array.from({ length: 20 }, () => call('/api/jobs', 0, { method: 'POST', body: '{}' })));
- assert.equal(attempts.filter((response) => response.status === 400).length, 9);
- assert.equal(attempts.filter((response) => response.status === 429).length, 11);
- const exhausted = attempts.find((response) => response.status === 429)!;
- const exhaustedBody = await exhausted.json();
- assert.equal(exhaustedBody.code, 'split_daily_limit');
- assert.equal(exhaustedBody.limit, 10);
- assert.ok(Number(exhausted.headers.get('retry-after')) > 0);
- assert.match(exhaustedBody.resetsAt, /T00:00:00.000Z$/);
- assert.match(exhausted.headers.get('cache-control')!, /no-store/);
- // A second student and an admin each have ten, independently of the first
- // person: thirty reservations in total, beyond the removed shared cap of 20.
- for (const who of [1, 2]) {
- const responses = await Promise.all(Array.from({ length: 12 }, () => call('/api/jobs', who, { method: 'POST', body: '{}' })));
- assert.equal(responses.filter((response) => response.status === 400).length, who === 1 ? 9 : 10);
- assert.equal(responses.filter((response) => response.status === 429).length, who === 1 ? 3 : 2);
- const account = await (await call(`/api/account?subject=${subjects[0]}`, who)).json();
- assert.equal(account.account.subject, subjects[who]);
- assert.deepEqual(account.splitAllowance, { limit: 10, used: 10, remaining: 0, resetsAt: exhaustedBody.resetsAt });
- }
- const ownAllowance = (await (await call('/api/account')).json()).splitAllowance;
- assert.deepEqual(ownAllowance, { limit: 10, used: 10, remaining: 0, resetsAt: exhaustedBody.resetsAt });
- assert.equal(providerStarts, 1); // Concurrent invalid/replayed requests cannot overspend the daily reservation.
+ const invalid = await Promise.all(Array.from({length:20},()=>call('/api/jobs',0,{method:'POST',body:'{}'})));
+ assert.equal(invalid.every(response=>response.status===400),true);
+ const allowance=(await(await call('/api/account')).json()).splitAllowance;
+ assert.equal(allowance.limit,15);assert.equal(allowance.completed,1);assert.equal(allowance.inProgress,0);assert.equal(allowance.remaining,14);
+ const chatCount=(await(await call('/api/account')).json()).chatAllowance;
+ assert.equal(chatCount.limit,50);assert.equal(chatCount.completed,1);assert.equal(chatCount.inProgress,0);assert.equal(chatCount.remaining,49);
+ const replay=await call('/api/jobs',0,jobRequest);assert.equal(replay.status,200);assert.equal((await replay.json()).id,created.id);
+ assert.equal(providerStarts,1);
const users = await (await call('/api/admin/users', 2)).json();
const alice = users.users.find((user: { subject: string }) => user.subject === subjects[0]);
const grantRole = { method: 'PUT', body: JSON.stringify({ role: 'instructor', expiresAt: new Date(Date.now() + 60000).toISOString(), disabled: false, revision: alice.revision }) };
diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts
index 3895537..4da37dc 100644
--- a/cloudflare/worker.ts
+++ b/cloudflare/worker.ts
@@ -1,13 +1,21 @@
-import app from '../src/index.ts';
+import { recordPageUse, recordServerUse } from '../src/reliability/observability.ts';
+import { youtubeImportConfiguration } from '../src/youtube.ts';
+import { validateStemAudio } from './audio-validator.ts';
+import app, { runReliableJobs } from '../src/index.ts';
import type { Env } from '../src/env.ts';
import { verifyCailIdentity } from './verify.ts';
-import { gatewayForRequest, canonicalModel } from './gateway.ts';
-import { authenticatedRequest, handleAuth, type WorkerIdentity } from './sso.ts';
+import { gatewayForRequest, canonicalModel, gatewayModelsConfigured } from './gateway.ts';
+import { authenticatedRequest, handleAuth, sanitizedRequest, publicApi, type WorkerIdentity } from './sso.ts';
+import { guestConfigured, guestChatConfigured, hasMemberCookie, readGuestSession, revokeGuestSession, startGuestSession, clearGuestCookie, GUEST_COOKIE } from './guest.ts';
+import { guestFailure } from '../src/guest/access.ts';
import { purgeExpiredListeningConversations } from './retention.ts';
+import { purgeOperationContent } from '../src/reliability/retention.ts';
+import { handleBugReport, type BugReportService } from './bug-reports.ts';
+import { intakeEnvironment, intakePauseResponse, type IntakePauseEnv } from './intake-pause.ts';
export type WorkerEnv = Omit &
Pick &
- Partial> &
- { IDENTITY?: WorkerIdentity; PREVIEW_IDENTITY?: WorkerIdentity; GATEWAY?: Fetcher; GATEWAY_MODEL?: string };
+ Partial> & IntakePauseEnv &
+ { IDENTITY?: WorkerIdentity; PREVIEW_IDENTITY?: WorkerIdentity; GATEWAY?: Fetcher; GATEWAY_MODEL?: string; GATEWAY_FALLBACK_MODEL?: string; BUG_REPORTS?: BugReportService };
const HEADERS = {
'X-Content-Type-Options': 'nosniff',
@@ -25,18 +33,27 @@ async function serveApi(request: Request, env: WorkerEnv, ctx: ExecutionContext)
if (!env.REQUEST_LIMIT) return Response.json({ error: 'Service temporarily unavailable.' }, { status: 503 });
const key = request.headers.get('cf-connecting-ip') || 'unknown';
const limited = await env.REQUEST_LIMIT.limit({ key: `stem-preview:${key}` });
- if (!limited.success) return Response.json({ error: 'Please wait a moment and try again.' }, { status: 429, headers: { 'Retry-After': '60' } });
+ if (!limited.success) {
+ const response=Response.json({ error: 'Please wait a moment and try again.' }, { status: 429, headers: { 'Retry-After': '60' } });
+ ctx.waitUntil(recordServerUse(env,request,response,undefined,Date.now()));return response;
+ }
}
- const gateway = gatewayForRequest(env.GATEWAY, request.headers.get('x-cail-gateway-identity-jwt'), request, env.RELEASE || 'candidate');
+ const guest = Boolean(env.guestSession);
+ const gateway = gatewayForRequest(env.GATEWAY, guest ? env.GUEST_GATEWAY_API_KEY ?? null : request.headers.get('x-cail-gateway-identity-jwt'), request, env.RELEASE || 'candidate',guest?'key':'jwt');
return app.fetch(request, { ...env, ASSISTANT_MODEL: env.GATEWAY_MODEL,
- assistantTransport: gateway.stream, assistantQuota: gateway.quota, verifyCailIdentity }, ctx);
+ ASSISTANT_FALLBACK_MODELS: env.GATEWAY_FALLBACK_MODEL ?? '',
+ assistantTransport: gateway.stream, assistantQuota: guest ? undefined : gateway.quota, verifyCailIdentity, validateStemAudio }, ctx);
}
export default {
async scheduled(_controller: ScheduledController, env: WorkerEnv, ctx: ExecutionContext): Promise {
- ctx.waitUntil(purgeExpiredListeningConversations(env.DB));
+ env = intakeEnvironment(env);
+ ctx.waitUntil(runReliableJobs({...env,guestRuntimeReady:guestConfigured(env),guestChatReady:guestChatConfigured(env),validateStemAudio}));
+ ctx.waitUntil(env.DB.prepare('DELETE FROM guest_challenges WHERE expires_at').bind(Date.now()).run());
+ if (_controller.cron === '0 8 * * *') ctx.waitUntil(Promise.all([purgeExpiredListeningConversations(env.DB),purgeOperationContent(env.DB)]));
},
async fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext): Promise {
+ env = intakeEnvironment(env);
const url = new URL(request.url);
// The explicit alias shares this runtime through a service binding. Keep
// browser writes and signed provider URLs on its origin without trusting
@@ -44,25 +61,47 @@ export default {
if (env.CANONICAL_BASE_URL && url.origin === env.CANONICAL_BASE_URL) {
env = { ...env, PUBLIC_BASE_URL: env.CANONICAL_BASE_URL };
}
+ env = {...env,guestRuntimeReady:guestConfigured(env),guestChatReady:guestChatConfigured(env),guestStartAllowed:!hasMemberCookie(request),guestSession:undefined};
let response: Response;
try {
- if (url.pathname === '/healthz') {
+ const paused = intakePauseResponse(request, env);
+ if (paused) {
+ response = paused;
+ } else if (url.pathname === '/healthz') {
const db = await env.DB.prepare('SELECT 1 AS ready').first();
+ const courseSchema = await env.DB.prepare("SELECT COUNT(*) AS present FROM sqlite_master WHERE type='table' AND name IN ('job_courses','app_operations')").first<{ present: number }>();
response = Response.json({ ok: Boolean(db), release: env.RELEASE || 'preview', authMode: env.AUTH_MODE,
- remixer: env.REMIXER_ENABLED === 'true', production: false,
- listeningGuide: { configured: Boolean(env.GATEWAY && canonicalModel(env.GATEWAY_MODEL)),
- transport: 'cail-gateway', fallbackConfigured: false } });
+ remixer: env.REMIXER_ENABLED === 'true', production: false, intakePaused: env.INTAKE_PAUSED === 'true',
+ courseAccess: courseSchema?.present === 2 && env.CAIL_CLASSROOM_ENABLED !== 'false' ? { contract: 'stem-course-v1' } : null,
+ youtubeImport: {configuration:youtubeImportConfiguration(env),strategy:'durable-pinned-provider'},
+ listeningGuide: { configured: Boolean(env.GATEWAY && gatewayModelsConfigured(env.GATEWAY_MODEL,env.GATEWAY_FALLBACK_MODEL)),
+ transport: 'cail-gateway', fallbackConfigured: gatewayModelsConfigured(env.GATEWAY_MODEL,env.GATEWAY_FALLBACK_MODEL) } });
+ } else if (url.pathname === '/api/bug-reports') {
+ response = await handleBugReport(request, { app: 'stem-splitter', origin: env.PUBLIC_BASE_URL,
+ service: env.BUG_REPORTS, limiter: env.REQUEST_LIMIT });
} else if (env.AUTH_MODE !== 'cail') {
response = Response.json({ error: 'Service configuration is incomplete.' }, { status: 503 });
+ } else if (url.pathname === '/auth/guest') {
+ response = await startGuestSession(request,env);
} else if (url.pathname.startsWith('/auth/')) {
- response = await handleAuth(request, env);
+ response = await handleAuth(request, {...env,guestSignout:()=>revokeGuestSession(request,env)});
} else if (url.pathname.startsWith('/api/')) {
// Admission and ownership run in the shared application. Limit expensive
// ingress before provider work; signed provider callbacks are independent.
- const internal = await authenticatedRequest(request, env);
+ let internal: Request | Response;
+ if (!publicApi(request) && !hasMemberCookie(request) && env.guestRuntimeReady) {
+ const session=await readGuestSession(request,env);
+ if (session) {env={...env,guestSession:session};internal=sanitizedRequest(request);}
+ else if ((request.headers.get('cookie') ?? '').split(';').some(part=>part.trim().split('=',1)[0]===GUEST_COOKIE)) {
+ internal=guestFailure('guest_session_expired',401,'This guest session has ended. Start a new guest session or sign in.');
+ internal.headers.append('Set-Cookie',clearGuestCookie());
+ } else internal=await authenticatedRequest(request,env);
+ } else internal=await authenticatedRequest(request,env);
+ if(internal instanceof Response)ctx.waitUntil(recordServerUse(env,request,internal,undefined,Date.now()));
response = internal instanceof Response ? internal : await serveApi(internal, { ...env, CAIL_LOGIN_URL: '/auth/login' }, ctx);
} else {
response = env.ASSETS ? await env.ASSETS.fetch(request) : new Response('Not found', { status: 404 });
+ if(request.method==='GET'&&['/','/index.html','/account.html','/teacher.html','/classroom.html'].includes(url.pathname))ctx.waitUntil(recordPageUse(env,response));
}
} catch {
// No provider body, private URL, SQL error, or identity enters logs.
@@ -70,6 +109,10 @@ export default {
}
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(HEADERS)) headers.set(key, value);
+ if (env.guestRuntimeReady) headers.set('Content-Security-Policy',HEADERS['Content-Security-Policy']
+ .replace("script-src 'self';","script-src 'self' https://challenges.cloudflare.com;")
+ .replace("connect-src 'self';","connect-src 'self' https://challenges.cloudflare.com;")
+ + '; frame-src https://challenges.cloudflare.com');
if (url.pathname.startsWith('/auth/')) headers.set('Referrer-Policy', 'no-referrer');
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth/') || url.pathname === '/healthz') headers.set('Cache-Control', 'private, no-store');
return new Response(response.body, { status: response.status, headers });
diff --git a/cloudflare/wrangler.jsonc b/cloudflare/wrangler.jsonc
index b815fa6..b2dee9c 100644
--- a/cloudflare/wrangler.jsonc
+++ b/cloudflare/wrangler.jsonc
@@ -8,12 +8,13 @@
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
- "triggers": { "crons": ["0 8 * * *"] },
+ "triggers": { "crons": ["* * * * *", "0 8 * * *"] },
"assets": { "directory": "../public", "binding": "ASSETS", "run_worker_first": true },
"observability": { "enabled": true, "logs": { "invocation_logs": false, "head_sampling_rate": 1 } },
"d1_databases": [{ "binding": "DB", "database_name": "cail-stem-splitter-preview", "database_id": "ae01c5db-c19e-48c8-91bd-132876276f22" }],
"r2_buckets": [{ "binding": "AUDIO", "bucket_name": "cail-stem-splitter-preview-audio" }],
"services": [
+ { "binding": "BUG_REPORTS", "service": "cail-tools-admission", "entrypoint": "AdmissionBugReports", "props": { "app": "stem-splitter", "origins": ["https://cail-stem-splitter-preview.ailab-452.workers.dev", "https://stem-splitter.ailab-452.workers.dev"] } },
{ "binding": "GATEWAY", "service": "cail-model-api" },
{ "binding": "ADMISSION_RESOLVER", "service": "cail-tools-admission", "entrypoint": "AdmissionResolver" },
{ "binding": "IDENTITY", "service": "cail-doorway", "entrypoint": "WorkerIdentity", "props": { "id": "stem-splitter", "worker": "stem-splitter", "workspace": false } },
@@ -22,10 +23,12 @@
"ratelimits": [{ "name": "REQUEST_LIMIT", "namespace_id": "350609", "simple": { "limit": 30, "period": 60 } }],
"vars": {
"GATEWAY_MODEL": "glm-5.2",
+ "GATEWAY_FALLBACK_MODEL": "deepseek-v4-flash-0731",
"AUTH_MODE": "cail",
"CAIL_BROWSER_ORIGIN": "https://tools.ailab.gc.cuny.edu",
"PUBLIC_BASE_URL": "https://cail-stem-splitter-preview.ailab-452.workers.dev",
"CANONICAL_BASE_URL": "https://stem-splitter.ailab-452.workers.dev",
+ "GUEST_TURNSTILE_SITE_KEY": "0x4AAAAAAFT4Vghg5XfSQlKb",
"ROUTED_AUDIO": "true",
"CF_ACCOUNT_ID": "452c33847cf5cb1e46f391fca32fd1b5",
"R2_BUCKET_NAME": "cail-stem-splitter-preview-audio",
diff --git a/docs/bug-reports.md b/docs/bug-reports.md
new file mode 100644
index 0000000..82387f7
--- /dev/null
+++ b/docs/bug-reports.md
@@ -0,0 +1,71 @@
+# Bug report footer and reusable adapter
+
+The Cloudflare app exposes an accessible, closed-by-default footer on the splitter,
+account, instructor, and course pages. Name, email, and a 10–4000-character bug
+description are required. The footer sits outside private views so sign-in or
+session verification failures can be reported. Horizontal rules bound the form.
+It supports keyboard disclosure, visible focus, live status, submitting, accepted,
+failure, delayed retry, and uncertain-delivery recovery. It keeps drafts only in
+the current page's memory; reload/navigation clears them.
+
+`POST /api/bug-reports` is deliberately available before authentication. It grants
+no access to app data. It requires the exact app origin, JSON, at most 16,384 bytes,
+bounded fields, a UUIDv4, the existing ingress limiter, and Cloudflare's trusted
+IP header. Missing configuration or trusted IP fails closed. Extra fields,
+cross-site requests, and name/email control characters are rejected. The server
+derives app/day/IP rate metadata; no raw IP or session credential is forwarded.
+The daily hash is a pseudonym and people behind one NAT share a budget.
+
+The private `AdmissionBugReports.submit` service binding uses Admission's existing
+SES sender and fixed `ailab@gc.cuny.edu` recipient/from/subject. Reporter email is
+unverified plain text, never an email header. The email includes only app name,
+random report reference, name, email, and description. It attaches no page URL,
+account identity, logs, chats, audio, maps, or screenshots. No SES credentials are
+copied into either application. Railway's server has no new email endpoint.
+
+One unchanged submission keeps its UUID across explicit retries. Known-safe
+failures preserve editable fields. Lost responses/timeouts lock the original
+fields and offer **Check report status** using that same UUID. There is no
+automatic resend or UUID rotation. **Accepted for delivery** means SES returned a
+message ID and Admission recorded acceptance; mailbox delivery is not proven.
+
+Admission durably limits dispatch to 3 per app/day/IP hash, 50 per app/day, and
+100 total/day. Only explicit SES 429 rejection is eligible for a delayed retry,
+up to three attempts. Permanent opaque receipt tombstones prevent old IDs being
+replayed; capacity is 100,000 receipts, then new reports fail closed. Receipt
+storage contains no raw name, email, description, or IP. Email itself reaches SES
+and the Lab mailbox and is subject to their retention.
+
+## Reuse and ownership
+
+The source contract and fixture belong to
+[Admission's contract package](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/2351fed850f9070524e1ced07fb154c558c92060/packages/admission-contract/src/bug-report.ts),
+with [delivery and release documentation](https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/2351fed850f9070524e1ced07fb154c558c92060/apps/admission/docs/BUG-REPORTS.md).
+This adoption pins receiver commit `2351fed850f9070524e1ced07fb154c558c92060`.
+
+`cloudflare/bug-reports.ts` and the form's `form.js`/`form.css` are identical vendored
+v1 modules in Stem and Systems Mapping. The controller has no framework or auth
+dependency; `mountBugReport(element)` returns its cleanup function. Stem mounts
+it from `public/bug-report/stem.js`; Mapping wraps it in a small React component.
+Only layout/theme integration differs. To adopt in another PoC, copy the adapter
+and controller/styles, add app-specific mounting and same-origin route, and
+explicitly review the new source-owned Admission app/origin grant. Do not turn the
+relay into a general recipient-selectable mail API. A versioned package may
+replace these copies after additional consumers justify publishing one.
+
+## Validation and release hold
+
+`cloudflare/bug-reports.test.ts` exercises request bounds, origin/method failures,
+header injection, missing configuration, ingress limits, opaque rate metadata,
+and lost acknowledgement. `cloudflare/bug-report.spec.mjs` runs the production
+entrypoint and native UI against a local mocked mail service, including keyboard,
+phone, success, safe retry, and uncertain recovery. Test-only wrappers are absent
+from deployment config. Admission separately tests real local RPC/SQLite with
+all SES calls intercepted. No test sends real email.
+
+Source declares the private binding, but no deployment, live namespace creation,
+merge, or real email has occurred. Release requires separate approval for the
+new Admission receipt namespace, per-app binding props and anonymous ingress,
+followed by an approved report from each app and verification in the Lab mailbox.
+Do not report delivery as production-verified until that mailbox acceptance test
+is complete. Browser plugin unavailable; existing Playwright is the QA fallback.
diff --git a/docs/evidence/reliability-20261006/bundle-dependencies.json b/docs/evidence/reliability-20261006/bundle-dependencies.json
new file mode 100644
index 0000000..a66e53c
--- /dev/null
+++ b/docs/evidence/reliability-20261006/bundle-dependencies.json
@@ -0,0 +1,11 @@
+{
+ "checkedAt": "2026-10-06",
+ "sourceHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "command": "wrangler deploy --config wrangler.jsonc --dry-run --metafile",
+ "honoInputCount": 27,
+ "allHonoInputsUsePatchedAlias": true,
+ "legacyRootHonoInputCount": 0,
+ "bundledHonoVersion": "4.13.11",
+ "metafileSha256": "90f2d753424dc61b3064b5350ea2719e58b530eb2c2c2900758921c572cb1b8a",
+ "rootAnalysisDependencyInputsChanged": false
+}
diff --git a/docs/evidence/reliability-20261006/final-checks.json b/docs/evidence/reliability-20261006/final-checks.json
new file mode 100644
index 0000000..c519ece
--- /dev/null
+++ b/docs/evidence/reliability-20261006/final-checks.json
@@ -0,0 +1,129 @@
+{
+ "recordedAt": "2026-10-06T20:05:26.596782+00:00",
+ "applicationSourceHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "finalTestFixtureHead": "cd48cd4233c4da931a59c019625cf7d0cba50426",
+ "baseHead": "f5506597f8fab2196d3f55119242d76210ff553d",
+ "paidProviderCalls": "mocked",
+ "deploymentPerformed": false,
+ "productionDataChanged": false,
+ "independentReview": "pending exact final head; this is not an audit certification",
+ "checks": [
+ {
+ "name": "root",
+ "command": "bun run test",
+ "testedHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "result": {
+ "worker": 325,
+ "server": 42,
+ "python": [
+ 5,
+ 30,
+ 9
+ ]
+ },
+ "localLog": "/tmp/stem-pr3-final-root.log",
+ "sha256": "d491040c2a74834203fda5883e24c593e5f5ffc3787a32da546cddcfdece1d14"
+ },
+ {
+ "name": "cloudflare",
+ "command": "cd cloudflare && bun run typecheck && bun run test",
+ "testedHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "result": {
+ "passed": 84
+ },
+ "localLog": "/tmp/stem-pr3-final-cloudflare.log",
+ "sha256": "277657e5d8d464e3c35f3e8ba0753d72ae0137593db4304532774e8a1a9ac2de"
+ },
+ {
+ "name": "extractor",
+ "command": "python3 -m unittest discover -s replicate-yt-audio -p 'test_*.py'",
+ "testedHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "result": {
+ "passed": 4
+ },
+ "localLog": "/tmp/stem-pr3-final-extractor.log",
+ "sha256": "119eb158af9c8a17b35e94789e871de6f57880473f2db9e480f799d8e7a49d2d"
+ },
+ {
+ "name": "chrome",
+ "command": "playwright test --config cloudflare/playwright.config.mjs",
+ "testedHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "result": {
+ "passed": 38
+ },
+ "localLog": "/tmp/stem-pr3-final-chrome.log",
+ "sha256": "17950f98b2a9f0356986cfb4a55049f347c9caaa244cedbf3964e977942707e1"
+ },
+ {
+ "name": "firefox",
+ "command": "STEM_BROWSER=firefox playwright test playback.spec.mjs guest.spec.mjs account.spec.mjs",
+ "testedHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "result": {
+ "passed": 23
+ },
+ "localLog": "/tmp/stem-pr3-final-firefox.log",
+ "sha256": "06295bcff0b59a2d1e2bd610d4dee3c26ea6ea5834be610d20158d5d5c4b39c1"
+ },
+ {
+ "name": "webkit",
+ "command": "STEM_BROWSER=webkit playwright test playback.spec.mjs guest.spec.mjs account.spec.mjs gateway.spec.mjs",
+ "testedHead": "cd48cd4233c4da931a59c019625cf7d0cba50426",
+ "result": {
+ "passed": 24
+ },
+ "localLog": "/tmp/stem-pr3-final-webkit.log",
+ "sha256": "08d4615bca23cf729d12c47a23e7a051c279737301cf8b7fbec9f0f784c895de"
+ },
+ {
+ "name": "account-chrome",
+ "command": "playwright test account.spec.mjs",
+ "testedHead": "cd48cd4233c4da931a59c019625cf7d0cba50426",
+ "result": {
+ "passed": 4
+ },
+ "localLog": "/tmp/stem-pr3-final-account-chrome.log",
+ "sha256": "542fae9d1fbed9c4a3fd25d73c7edc999c6865c605d8ad0397ca0c543ad4e42f"
+ },
+ {
+ "name": "account-firefox",
+ "command": "STEM_BROWSER=firefox playwright test account.spec.mjs",
+ "testedHead": "cd48cd4233c4da931a59c019625cf7d0cba50426",
+ "result": {
+ "passed": 4
+ },
+ "localLog": "/tmp/stem-pr3-final-account-firefox.log",
+ "sha256": "96f34892f971482c212a5c75150d5def763813945b84d127e262bb113900ca84"
+ },
+ {
+ "name": "account-webkit",
+ "command": "STEM_BROWSER=webkit playwright test account.spec.mjs",
+ "testedHead": "cd48cd4233c4da931a59c019625cf7d0cba50426",
+ "result": {
+ "passed": 4
+ },
+ "localLog": "/tmp/stem-pr3-final-account-webkit.log",
+ "sha256": "3ec28e70006a70f03b1ce2ce63d09a986f267729bf9b4e7b60fc1db474df486f"
+ },
+ {
+ "name": "adapter-audit",
+ "command": "cd cloudflare && bun audit --production",
+ "testedHead": null,
+ "result": {
+ "vulnerabilities": 0,
+ "packages": 10
+ },
+ "localLog": "/tmp/stem-pr3-integrated-adapter-audit.log",
+ "sha256": "c30e6e9e762a3fed57f1733263d5710c67c6e15c78a2230c1d6ec197d192e885"
+ },
+ {
+ "name": "dry-run",
+ "command": "wrangler deploy --config cloudflare/wrangler.jsonc --dry-run --metafile",
+ "testedHead": "ba752069180e625dfb85f49fe773ca523010fc1e",
+ "result": {
+ "deployed": false
+ },
+ "localLog": "/tmp/stem-pr3-final-dryrun.log",
+ "sha256": "708477e713a216703101770b2a9415d19c9a4117ded3461997b3ab5d54434643"
+ }
+ ]
+}
diff --git a/docs/evidence/reliability-20261006/verification.md b/docs/evidence/reliability-20261006/verification.md
new file mode 100644
index 0000000..2255737
--- /dev/null
+++ b/docs/evidence/reliability-20261006/verification.md
@@ -0,0 +1,113 @@
+# PR3 local verification — 2026-10-06
+
+All provider starts/inference in these checks are mocked. No production data,
+paid jobs, migrations, permissions, extractor publication or deployment changed.
+This is an implementation receipt, not an independent audit certification.
+
+## Published checkpoint
+
+`aa74797ce38d9b5dbfcc4849daf17b0cd41a4c96`, on classroom checkpoint
+`daaa8c55a51a6c6ef8a2bc82d8eebba623cc87e7`, was separately checked in a disposable
+worktree: Cloudflare typecheck and 66/66 tests; importer 20/20 tests. The draft
+at that point had a temporary 4 MiB inline limit and five recovery defects
+subsequently reproduced by the independent reviewer. It is not release-ready.
+
+## Integrated implementation checks
+
+Final application source is `ba752069180e625dfb85f49fe773ca523010fc1e`, stacked
+on PR2 `f5506597f8fab2196d3f55119242d76210ff553d`. The later test-only commit
+`cd48cd4233c4da931a59c019625cf7d0cba50426` asserts the intended telemetry
+rejection during deliberate fixture identity switches. Receipt commits do not
+change application behavior. All three TypeScript checks passed.
+
+| Check | Result |
+| --- | --- |
+| Root and Node adapter typechecks | Pass |
+| Root unit/contract suite | 325/325 |
+| Node database/config/isolation suite | 42/42 |
+| Local separator / instrument / YAMNet Python | 5/5, 30/30, 9/9 |
+| Cloudflare typecheck and unit/Workerd suite | 84/84 |
+| YouTube importer / inline-parser cases (included above) | 21/21 |
+| Extractor free subprocess tests | 4/4 |
+| Full Chrome suite | 38/38 |
+| Firefox playback, guest and account | 23/23, including all 18 playback cases |
+| Targeted WebKit playback, guest, account and Gateway | 24/24 |
+| Final account fixture assertion, Chrome / Firefox / WebKit | 4/4 in each |
+| Candidate Wrangler dry-run | Pass; no deploy |
+| Adapter production dependency audit | No vulnerabilities in 10 packages |
+| Root legacy dependency audit | Four moderate Hono advisories; absent from candidate bundle |
+
+The root dependency inputs remain frozen. Audit reported `hono@4.12.34` with
+GHSA-gqvv-2mrq-wpjv, GHSA-g6gw-c38x-mqfc, GHSA-crvj-82cr-hjcx and
+GHSA-hxh3-vqpv-xpqv. The candidate and test Wrangler configurations explicitly alias both `hono`
+and `hono/factory` to adapter `hono-cloudflare@4.13.11`. The dry-run build
+metafile confirms all 27 Hono inputs use that patched alias and zero use the
+legacy root package. The [bundle receipt](bundle-dependencies.json) records
+that check. The four root advisories remain a legacy Node/analyzer dependency
+finding; they are not a demonstrated candidate runtime exposure. Frozen root
+analysis-image inputs were not changed.
+
+The five initial independent-review regressions cover abandoned-ingestion cron
+recovery, stale import fencing, no-network missing-pin failure, complete
+120-second polling cooldown across callbacks, and lost final database
+acknowledgement. The last case verifies all four committed R2 objects still
+exist, including when the first reconciliation read also fails. A pre-commit
+failure remains uncharged. Auto snapshot cleanup is fenced and a completed
+owned snapshot may be reused during durable recovery. The reviewer's follow-up
+cross-operation/cross-phase cooldown case now checks the shared provider floor
+inside the atomic reconciliation claim. No browser polling is needed to recover
+an expired ingestion lease. These are implementation regressions; final
+independent approval is recorded separately by the reviewer.
+
+Additional cases cover 30 simultaneous submissions across two actual Workerd
+Workers sharing D1, 15 member places, 60 human inputs limited to 50, 20 students
+through two SQLite coordinators, UTC/year/DST boundaries, unknown starts,
+cancellation and duplicate callback settlement. Guest checks exercise separate
+ownership, 5/25 accounting, simultaneous 25th/26th inputs, partial output,
+failed-input replacement, proof renewal and immutable quota provenance.
+Operation recovery rejects late final delivery; course effects check the live
+operation as well as the conversation claim. Reset after a saved reply but
+before final delivery cannot restore that reply or emit its tools.
+
+Usage tests validate fixed fields, no private content, stable retry IDs, separate
+member/guest aggregates, bounded volume and 30-day retention. Old-actor batches
+are rejected before recording under a new cookie. WebKit surfaced these
+intentional 409 responses during the account fixture's rapid identity changes;
+the fixture now requires the exact rejection body and differing request/verified
+response actors before allowing that specific console entry. Unrelated errors
+still fail. Browser capture remains best effort, not guaranteed accounting.
+
+Actual Workerd checks statically imported local Wasm decoding, valid silent
+MP3, invalid side information, and an overlap in one isolate between a generated
+600-second >22 MiB MP3 and 12 MiB inline audio import. This is bounded local
+execution evidence, not a measured peak-RSS report, a universal corruption
+proof or production CPU/load acceptance. Two actual Workerd Workers also share
+D1/R2 for atomic admission; separate SQLite-connection cases are not described
+as Worker load tests.
+
+The earlier corrected source-browser checkpoint passed 19/19 after updating
+the fenced stem paths and inheriting the final classroom logo/title fixtures.
+No path was flattened to satisfy an old assertion. Account recovery/logout now
+use actual local HTTPS 303 responses and Secure cookies in all three engines.
+Guest screenshots from the isolated handoff were reviewed; the guest and shared
+mixer retain the existing controls and separate private ownership. Local guest
+fixtures mock Turnstile, Registry/Gateway and Replicate; they establish none of
+those live grants or paid-provider results.
+
+Remaining gates are exact-head independent Astra High review, final PR3 CI,
+approved backup/migration/drain and release, actual Gateway member/sponsor
+entitlement, real exact-host bot verification, an approved extractor image build
+and pin, and permitted-source/normal-runtime audio acceptance. The deployed
+extractor pin is unchanged. The reported live YouTube failure has not been
+reproduced or assigned a verified root cause. Source diagnostics and 12 MiB
+bounded imports are not a claim of a production fix or universal source access.
+
+Local command logs are kept outside the repository as
+`/tmp/stem-pr3-final-root.log`, `/tmp/stem-pr3-final-cloudflare.log`,
+`/tmp/stem-pr3-final-extractor.log`, `/tmp/stem-pr3-final-chrome.log`,
+`/tmp/stem-pr3-final-firefox.log`, `/tmp/stem-pr3-final-webkit.log`,
+`/tmp/stem-pr3-final-account-{chrome,firefox,webkit}.log`,
+`/tmp/stem-pr3-source-e2e.log`, `/tmp/stem-pr3-integrated-adapter-audit.log`,
+`/tmp/stem-pr3-root-audit.log` and `/tmp/stem-pr3-final-dryrun.log`.
+The [machine-readable receipt](final-checks.json) records commands, tested heads,
+results and log hashes. These paths are local receipts, not durable CI links.
diff --git a/docs/guest-access.md b/docs/guest-access.md
new file mode 100644
index 0000000..e284e6e
--- /dev/null
+++ b/docs/guest-access.md
@@ -0,0 +1,54 @@
+# Guest access candidate
+
+The October 6 policy is **5 successful splits and 25 human Listening Guy inputs per guest session per UTC day**. It supersedes the September 29 proposal for three visitor attempts. Pending operations hold a place; failed/cancelled splits and replies with no usable output release it. Usable partial replies count once. Guide generation and cache reads do not consume human-input allowance. Provider spend remains separate from these app counts.
+
+This implementation is **off by default and has not been activated**. No sponsor grant, API key, Turnstile widget, secret, production migration, or deployment was created. No old OpenRouter key is used. Production acceptance still requires the release gates below.
+
+## Authority and privacy
+
+`POST /auth/guest` requires an exact same-origin HTTPS request and a server-verified Turnstile token for the request hostname and `stem_guest` action. Token age is bounded to five minutes. A short-lived hash record rejects token replay in an atomic D1 batch even if a verification response is repeated. The browser receives an HMAC-signed `__Host-stem-guest` cookie with Secure, HttpOnly, SameSite=Lax, a fixed seven-day expiry, and an origin-bound signature. D1 expiry/revocation is rechecked on every protected request. A fresh human check is required on each UTC day before paid starts; renewal preserves the session and its counters.
+
+The guest subject is a server-generated random identifier. It is never a CAIL subject, email, IP address, or admission grant. `guest_sessions`, `guest_job_owners`, `guest_upload_owners`, and `guest_listening_conversations` are separate from member ownership. The operational ledger uses an immutable `quota_class` and foreign keys to the appropriate member or guest principal. That accounting provenance grants no access. Delayed paid starts recheck the guest session, or the existing member/course authority, according to the stored quota class.
+
+Guests can upload/import and split their own audio, play/download their own results, annotate them, and use Listening Guy for those results. A fixed route allowlist and live ownership checks deny member/private jobs, other guests' work, course folders, rosters, prompts, transcripts, instructor/admin functions, public-link creation, and sponsor quota reads. Existing explicitly shared audio-only links retain their existing public projection. App administrators do not gain guest content through member ownership APIs.
+
+A member session cookie always selects the existing CUNY authentication path, including malformed, expired, revoked, or unavailable sessions. Such a request never silently becomes a guest. A successful CUNY login revokes the browser's guest session and clears its cookie; no guest content is adopted into a member account or course. Logout revokes the guest server session before clearing the cookie. A copied cookie cannot reopen a revoked session. Session revalidation conceals private UI while authority is pending or unavailable; a changed identity clears and reloads it.
+
+Guest access lasts seven days in the same browser. Clearing the cookie or ending the session removes access to its saved work; it is not an account-recovery mechanism. Existing uploaded-audio/stem storage retains its 90-day deletion boundary. The daily retention job removes guest transcripts after session expiry/revocation. No member or course content is backfilled or reclassified.
+
+## Abuse boundaries
+
+Anonymous per-human limits cannot be exact. These counts identify the server-issued browser session; cookie resets can create another identity after a new valid human check. The existing Cloudflare ingress binding allows 30 requests/minute per key, with a separate `stem-guest-start` key derived from the trusted ingress IP for guest entry. This is best-effort reset-abuse control, not a unique-human or daily person guarantee. A rejected start performs no database, Turnstile or provider call and creates no cookie. No IP is used as content ownership or persisted as an account identifier. No global rate-limit setting changes are included.
+
+The atomic ledger additionally permits at most two active splits per guest, ten active guest splits across the app, fifteen split attempts per guest/day, fifty chat attempts per guest/day, and ten guide attempts per guest/day. Uploads allow at most three outstanding grants and fifteen grants per guest/day, with one-hour upload expiry. These are abuse and concurrency guards, not successful-use charges or a promised monetary ceiling. Existing provider backoff, uncertain-start reconciliation, idempotency, size/rights checks, and the global bounded queue remain in force.
+
+## Gateway and activation
+
+The installed `cail-client` 6.2.2 supports a registry-managed application key via `{kind:'key',token}`. A verified guest request can use only the explicit optional `GUEST_GATEWAY_API_KEY` on the existing private Gateway binding. Gateway reauthorizes that principal through its Registry on every model request. No guest JWT is minted, no member identity is reused, and no persistent provider credential reaches the browser. The guest cannot inspect the sponsor's quota. Signed-in requests retain their current exact app/Gateway identity pair.
+
+Guest readiness requires all of the following. Missing configuration leaves the feature unavailable, including when an unrelated legacy provider key exists.
+
+| Setting/capability | Purpose |
+| --- | --- |
+| `GUEST_ENABLED=true` | Explicit activation, absent by default |
+| `GUEST_COOKIE_SECRET` | Dedicated app-local cookie signing secret, at least 32 characters |
+| `GUEST_TURNSTILE_SITE_KEY` and `GUEST_TURNSTILE_SECRET` | Widget for the reviewed exact application hostnames; server verification |
+| `GUEST_GATEWAY_API_KEY` | Approved Registry-managed sponsor application key; no fallback credential |
+| Existing `GATEWAY`, exact primary/fallback models, and `REQUEST_LIMIT` | Private authorized inference and ingress control |
+
+Only the widget's site key is public. When configuration is complete, the CSP adds `https://challenges.cloudflare.com` to script/connect/frame sources for the widget. Turnstile follows its [server-side validation contract](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/); the client callback alone grants nothing. Its documented [compact widget size](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/#widget-sizes) fits narrow mobile layouts; real widget acceptance remains a release gate.
+
+Before activation: review and approve the sponsor's grant/budget/model entitlement, dedicated cookie key installation, exact-host Turnstile widget and secret, and CSP/abuse settings. Then apply the reviewed unshipped migration `0022-reliable-operations.sql` with the existing backup/drain/rollback plan and complete real browser/Turnstile/Registry acceptance. Source includes guest tables and accounting provenance in that same migration; no fourth PR or shared Gateway/Admission/SSO deployment is required. A database already initialized with an earlier *unreleased draft* of 0022 needs a reviewed rebuild/upgrade plan; `CREATE TABLE IF NOT EXISTS` does not upgrade that draft's old operation schema.
+
+Local tests use fixture credentials and mocked Turnstile, Replicate, and Gateway. They establish app behavior and privacy boundaries, not real bot resistance, sponsor entitlement, paid model output, production cost, or live deployment readiness.
+
+## Local verification
+
+Using Node 22.23.1 and the existing installed dependencies (no lockfile changes):
+
+- Root, server and Cloudflare TypeScript checks pass; the guest/app/account scripts pass syntax checks.
+- Shared Worker regression suite: 325/325; server/SQLite compatibility suite: 42/42.
+- Cloudflare suite: 78/78, including four guest tests. It covers empty-reply/failed-split release, 5/25 settlement, idempotency, atomic parallel reservation, daily reset/proof renewal, immutable quota provenance, rate-limit rejection with no side effects, cookie expiry/tamper/noncanonical encoding, unavailable storage, revocation, and cross-guest/member/course isolation.
+- `cloudflare/guest.spec.mjs` passes in Chrome, Firefox and WebKit against the real local HTTPS Worker listener. The test uses browser file upload, secure-cookie resumption, account limits, pending/unavailable-authority concealment, second-guest clearing, logout, and 320/390/768/1280px overflow checks. Only external bot/provider responses are fixture-controlled.
+
+Run the Cloudflare suite from `cloudflare/` with `node --import ./test-wasm-register.mjs --import ../node_modules/tsx/dist/loader.mjs --test *.test.ts`. Run the guest browser spec from the repository root with `node ./node_modules/@playwright/test/cli.js test --config cloudflare/playwright.config.mjs guest.spec.mjs`; set `STEM_BROWSER=firefox` or `STEM_BROWSER=webkit` for the other engines. Local Workers/HTTPS need loopback permission. Final PR integration still needs its own exact-head audit and CI.
diff --git a/docs/reliability-operations.md b/docs/reliability-operations.md
new file mode 100644
index 0000000..35fdd8c
--- /dev/null
+++ b/docs/reliability-operations.md
@@ -0,0 +1,249 @@
+# Reliable operations and allowance migration
+
+This PR is source and local-test work. It does not deploy, apply a remote
+migration, change permissions or publish the extractor image. Paid inference,
+separation and import calls in tests are mocked. Independent review and the
+release gates below still apply.
+
+## Accounting and recovery
+
+A student's successful splits plus active reservations cannot exceed 15 per
+UTC submission day, across courses and browsers. The separate guest allowance
+is five successful splits and 25 human inputs per server-issued guest session;
+[guest access and activation](guest-access.md) describe its separate ownership,
+seven-day session and bot/sponsor requirements. Guest access remains off by
+default. Anonymous sessions cannot establish a unique human.
+
+One operation binds subject and immutable quota class,
+verified stored course, idempotency key and input fingerprint to import,
+separation, ingestion and settlement. Validation precedes reservation. `done`
+settles once only after every expected stem is locally decoded and stored.
+Failed or cancelled work releases its reservation once; historical
+`app_request_reservations` are preserved as attempts, never inferred successes.
+An old operation finishing after midnight settles against its original day.
+
+Listening Guy allows 50 member or 25 guest human inputs per UTC day. One reservation wraps tools
+and both Gateway attempts. Guide generation, cache reads and instructor review
+are separate. No usable reply and no durable effect marker releases an input;
+a partial reply counts once. A crash after a persisted delivery/effect-intent
+marker counts once as delivery uncertain. A crash before any marker releases
+it. Markers precede emission/effects and contain no text. A settled key returns
+an actionable 409 and never regenerates a response or repeats tools. Course
+reset and lease recovery fence late deltas and durable effects. The transcript
+lives only in the established conversation tables, never in the operation
+ledger. Empty replies cannot settle success.
+
+App allowance release is **not a provider-billing refund**. Attempts record
+provider, exact model/version, phase, request outcome, external ID and reported
+usage separately. Unreported cost is unknown, never assumed free. Daily abuse
+ceilings for members are 40 split attempts and 80 human-input attempts; guests
+have 15 and 50 respectively, plus two active splits per session and ten active
+guest splits globally. Those denials,
+success/active allowance exhaustion and the 100-operation queue ceiling have
+distinct codes and messages.
+
+The durable coordinator caps concurrent fetch and separation phases at one
+each, with one per student per phase. Shared D1 predicates arbitrate claims;
+least-recently-served students precede repeated work. The one-minute cron,
+callbacks and authorized polling drain the same queue. A 24-hour unstarted
+request deadline does not time out or free an accepted/unknown paid job.
+Leases last five minutes; every takeover increments a fence. A runner must
+successfully persist its fenced attempt before a paid POST. Expiry before that
+write may requeue; expiry after it reconciles. A late accepted response supplies
+its external ID through the durable attempt even if its old lease was lost.
+
+Confirmed capacity rejection may retry at most three starts per phase within
+the request deadline. `Retry-After` seconds and HTTP dates establish the full
+shared cooldown, with bounded jitter only when missing/malformed. Network loss,
+5xx or unreadable acceptance is uncertain: never POST a replacement. Known IDs
+are polled. Callback recovery requires provider-side evidence of the exact
+operation/phase callback and the pinned model version recorded before start.
+An old import callback cannot adopt a separation ID. Callback bodies never
+choose output URLs. Cancelled uncertain work holds allowance/capacity until an
+authoritative terminal result; late success cannot settle it. Active jobs
+cannot be deleted out from under the ledger.
+
+## Audio validation and import diagnosis
+
+The Worker uses the pinned local mpg123 module described in
+[the decoder receipt](../cloudflare/vendor/README.md). Layer III framing and
+side-information checks precede full chunked PCM decoding; decoder errors,
+nonfinite PCM, empty output, implausible duration and damaged syntax fail.
+Input is at most 24 MiB per stem and 900.1 seconds; PCM is discarded per 4 KiB
+compressed chunk. Expected channels and every durable R2 write gate settlement.
+The CAIL importer separately caps remote audio at 12 MiB and inline audio at
+12 MiB, with a roughly 16.3 MiB prediction-JSON envelope. The inline value is removed at the byte level before metadata JSON parsing;
+base64 decodes in 16 KiB chunks directly into one output buffer. Metadata is
+bounded at 256 KiB. Declared oversized
+bodies are cancelled before reading, streamed bodies are bounded, and decoded
+stems are validated/ingested sequentially. The decoder heap is about 16.2 MiB;
+a 24 MiB compressed stem plus bounded response copies and one PCM chunk fits
+a practical budget below the Worker's 128 MiB isolate limit. These are explicit
+input bounds, not a measured guarantee about all runtime overhead. The frozen
+Railway import limits remain unchanged. A too-large unknown start response
+remains in reconciliation; a size limit never permits a replacement paid POST.
+This is evidence of bounded usable decoding, not a universal bit-corruption
+oracle or an aesthetic/audio-quality test. A valid silent stem is allowed.
+
+The known counterexample has complete MPEG headers but illegal side
+information (`main_data_begin` exceeds the available initial reservoir and
+`big_values=511`, greater than 288). Framing alone accepts it; tolerant mpg123
+alone conceals it as samples. The combined validator rejects it. A local
+ffmpeg `-xerror` cross-check also rejects it. Valid and corrupt inputs run
+through the statically bundled module in actual Workerd, not only Node.
+
+Archive cache reuse is restricted to the same subject and exact course.
+Every hit rechecks current public rights evidence. The global fetch slot also
+serializes same-source work, allowing the next request to reuse completed
+bytes without sharing private data across students. Cache metadata lasts at
+most 24 hours. YouTube has no affirmative reuse-rights evidence, so it is not
+cached. Signed download URLs are never cached. No arbitrary-URL fetch or
+source restriction bypass is added.
+
+The 2026-10-06 read-only investigation did **not** establish a working current
+Railway deployment: its latest recorded deployment was removed on October 5
+and its domain returned 404. The accepted August 31 Railway source
+`4c00b32fab96c8f9405095742ce3ca7170ace75b` and Cloudflare baseline `e1918da`
+have identical importer, extractor and root dependency files. There was no
+missing historical extractor patch to port. The Cloudflare version receipt
+showed all four YouTube secret names present; their contents were not read,
+and presence does not prove validity or entitlement. A normal environment
+probe was blocked and a short ordinary log tail found no matching event; no
+bypass was attempted. The exact live cause remains unconfirmed.
+
+Local mocks did establish diagnostic defects in the old fallback chain:
+missing/invalid configuration, provider credit rejection and pin/permission
+rejection could all end as “YouTube blocked.” The durable path preserves the
+stage/code/status, validates required importer configuration before reserving,
+and exposes value-free configuration health. Unknown old-image errors remain
+generic extractor failures; only explicit restriction evidence becomes source
+denial. The updated extractor pins and cleanup are source changes only; the
+existing deployed `REPLICATE_YT_MODEL_VERSION` is deliberately unchanged.
+
+## Gateway contract
+
+The ordered aliases are `glm-5.2`, then `deepseek-v4-flash-0731`. The current
+read-only Gateway catalog reported both active with streaming and tools. This
+does not establish account entitlement or successful live inference.
+
+Fallback is allowed only before an accepted stream, any output or any tool
+side effect, with `should_retry === true` and these exact normalized pairs:
+`upstream_rate_limited`/429, `upstream_unavailable`/503 and
+`model_unavailable`/503. The verified serving source and contract are
+[36cd8cdb](https://github.com/CUNY-AI-Lab/cail-gateway/blob/36cd8cdb7d61e2aebc94a03171b5990cea00bf5e/docs/gateway-contract.md#L535-L570).
+Quota, authentication, payment, policy, unknown 429 and timeouts do not trigger
+fallback. One deadline spans both attempts. The verified subject and course
+prompt remain unchanged. Partial/trailing stream failure never calls another
+model. Tools-only replies get local narration, and duplicate tool IDs fail.
+Missing or different backup configuration fails closed.
+
+Course deltas and effect intents atomically check the active conversation claim.
+Notes and durable replies also require the live 90-second operation lease,
+within the conversation's longer 120-second recovery lease. Final delivery
+requires a winning operation settlement and the unchanged completed course
+revision. Expiry, recovery or reset cannot authorize a stale tool event or
+restore a cleared transcript.
+
+## Private operational and usage evidence
+
+Lifecycle and attempt events are transactionally recorded alongside the durable
+ledger, including operation/attempt IDs, type, phase, state, elapsed duration,
+normalized error, pinned model, fallback and reservation/charge/release effect.
+Repeated settlement or unchanged attempt completion cannot add a second charge
+receipt. Provider usage retains only finite numeric accounting fields.
+
+Discrete browser observations cover page/session attachment, successful play,
+stop, committed seek and download intent. Server observations cover page
+responses, rejected split/chat requests, annotation saves/deletes and routed
+download responses. Download acceptance is not proof the browser saved a file;
+client observations are not billing evidence or unique-person counts.
+There is no audio, chat/annotation text, name, email, title, source URL, signed
+URL or credential in these event tables. Job/operation IDs are internal support
+correlations. Date-scoped subject hashes limit logging volume without
+copying the institutional identity into usage events; these are pseudonymous
+operational records, not an anonymity guarantee.
+
+Client recording is explicitly best effort: a 50-event in-memory queue sends
+batches of at most ten, with one bounded retry and stable IDs. Closing a page,
+offline use, blocked requests, process loss or the daily protection ceiling can
+lose observations. Signed-out public-audio playback and seek cannot submit
+private usage events; only page/download server observations cover that case.
+Account changes clear the queue and abort outstanding work. Each batch also
+carries the expected opaque account identifier in a request header; the server
+compares it with the verified actor before recording anything. This prevents
+an old queued batch from being attributed to a newly selected account even
+before browser revalidation notices the cookie change. The header is not
+retained in the event record and grants no authority.
+Server critical operation triggers are durable; auxiliary request/page logging
+cannot break the original request on a diagnostics failure. Idempotent rejected
+requests coalesce; requests with no operation key are separate observations.
+
+Events are access-controlled in the existing D1 database and purged after
+30 days. A student/guest may submit only the fixed event vocabulary for work
+they can currently access. Per-day client ceilings are 500 member/250 guest
+observations and 25,000 total auxiliary events globally. The existing admin-only
+`GET /api/admin/usage-events?days=7` returns aggregate counts, outcomes and mean
+durations for at most 30 days, without identities or content. Client and server
+counts remain separate to prevent treating them as additive exact usage.
+Member and guest operation aggregates use the ledger's immutable quota class.
+Minimal non-content unresolved operation/billing evidence follows the separate
+reconciliation policy below; no external analytics destination is introduced.
+
+## Migration, drain and rollback
+
+Before any approved release, take and verify an environment-specific database
+backup and record the installed migration version. Rehearse restoration into
+a disposable local database. Apply existing baseline migrations, then
+`0021-classroom.sql`, then additive `0022-reliable-operations.sql`, before new
+code accepts work. `0021` adds existing-table columns and must be tracked/applied
+once; do not blindly rerun it. `0022` is repeatable. Fresh `schema.sql` and the
+baseline-to-0021-to-0022 upgrade are compared in tests, including preservation
+of old private messages, notes, ownership and historical attempts. No legacy
+course associations or successful charges are manufactured.
+
+This remains an unreleased migration. A disposable database initialized from an
+earlier PR3 draft of 0022 must be rebuilt or explicitly upgraded before using
+the final guest principal schema; rerunning `CREATE TABLE IF NOT EXISTS` cannot
+add the later draft's columns. No production database has received any draft.
+
+Choose and record a UTC start boundary. Pause old-version intake before it;
+new reservations start only under the new version after the schema is ready.
+An old ten-attempt reader cannot account for the new success ledger. Do not
+allow mixed old/new intake against the same database during conversion.
+
+For a rollback, set `SPLIT_STARTS_DISABLED=true` in a separately authorized
+release. It pauses intake/new paid starts while preserving read/playback and
+known-operation reconciliation. Cancel queued work through the cancellation
+path. Drain accepted work and reconcile unknown starts with exact provider
+IDs/phase/version evidence. Never mark an unknown start failed merely because
+its lease expired. Keep a compatible reconciler until those operations are
+terminal. Do not drop new tables or restore an old backup over newer work.
+If returning to the historical reader, keep intake paused through the next
+UTC boundary and until all in-flight work is settled; otherwise neither its
+old daily count nor its rollback can represent the same allowance safely.
+
+A vanished provider response with no discoverable ID can require operator
+reconciliation; no provider-wide idempotency lookup is claimed. Retain the
+minimal correlation/cost evidence and hold the operation rather than guessing.
+At 90 days, request filenames/URLs, replay metadata and cached source metadata
+are redacted even for unresolved operations, cancellation is requested, and
+existing conversation/audio retention still applies. Minimal non-content
+accounting/external-ID evidence remains for reconciliation. An unresolved
+provider start never retains the original private request indefinitely.
+
+## Release gates and evidence
+
+Completed local checks and their exact counts are recorded in
+[the verification receipt](evidence/reliability-20261006/verification.md).
+Two actual Workerd Workers share a local D1/R2 fixture for concurrent admission;
+separate two-connection SQLite tests exercise fair progress and fault recovery.
+Neither is production load evidence.
+
+Before enabling this release: independent Astra High review of the final
+stacked head; normal Source, Pinned analysis image and Cloudflare CI gates;
+approved database plan/backup/drain; confirmed Gateway account entitlement;
+approved pinned extractor image build and permitted-source canary; and an
+approved normal-runtime audio acceptance check, including long tracks/resource
+limits. No test here launches a paid job or changes production. The root
+analysis-image dependency inputs are unchanged; only the adapter gains the
+small pinned decoder dependency and separately imported module.
diff --git a/migrations/0022-reliable-operations.sql b/migrations/0022-reliable-operations.sql
new file mode 100644
index 0000000..5395c95
--- /dev/null
+++ b/migrations/0022-reliable-operations.sql
@@ -0,0 +1,223 @@
+-- Deliberately separate from historical app_request_reservations. No inferred
+-- successful charges/backfill. Apply only with the queue-drain rollback plan.
+-- Guest authority is app-local. Never insert anonymous identities into app_users.
+CREATE TABLE IF NOT EXISTS guest_sessions (
+ subject TEXT PRIMARY KEY CHECK(length(subject)=70 AND subject GLOB 'guest-*'),
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL CHECK(expires_at>created_at AND expires_at<=created_at+604800000),
+ verified_day TEXT NOT NULL,
+ revoked_at INTEGER
+);
+CREATE TRIGGER IF NOT EXISTS guest_session_identity_fence BEFORE UPDATE ON guest_sessions
+WHEN NEW.subject<>OLD.subject OR NEW.created_at<>OLD.created_at OR NEW.expires_at<>OLD.expires_at
+ OR (OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NOT OLD.revoked_at)
+BEGIN SELECT RAISE(ABORT,'immutable guest session'); END;
+CREATE TABLE IF NOT EXISTS guest_challenges (
+ token_hash TEXT PRIMARY KEY,
+ expires_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS guest_job_owners (
+ job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE,
+ subject TEXT NOT NULL REFERENCES guest_sessions(subject)
+);
+CREATE INDEX IF NOT EXISTS guest_job_owners_subject ON guest_job_owners(subject);
+CREATE TRIGGER IF NOT EXISTS guest_job_separation BEFORE INSERT ON guest_job_owners
+WHEN EXISTS(SELECT 1 FROM job_owners WHERE job_id=NEW.job_id) OR EXISTS(SELECT 1 FROM job_courses WHERE job_id=NEW.job_id)
+BEGIN SELECT RAISE(ABORT,'guest work cannot be course or member work'); END;
+CREATE TRIGGER IF NOT EXISTS member_job_separation BEFORE INSERT ON job_owners
+WHEN EXISTS(SELECT 1 FROM guest_job_owners WHERE job_id=NEW.job_id)
+BEGIN SELECT RAISE(ABORT,'guest work cannot be member work'); END;
+CREATE TRIGGER IF NOT EXISTS guest_course_separation BEFORE INSERT ON job_courses
+WHEN EXISTS(SELECT 1 FROM guest_job_owners WHERE job_id=NEW.job_id)
+BEGIN SELECT RAISE(ABORT,'guest work cannot be course work'); END;
+
+CREATE TABLE IF NOT EXISTS guest_upload_owners (
+ object_key TEXT PRIMARY KEY,
+ subject TEXT NOT NULL REFERENCES guest_sessions(subject),
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ state TEXT NOT NULL DEFAULT 'issued' CHECK(state IN ('issued','uploading','ready'))
+);
+CREATE TABLE IF NOT EXISTS guest_listening_conversations (
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ subject TEXT NOT NULL REFERENCES guest_sessions(subject),
+ entries TEXT NOT NULL CHECK(json_valid(entries) AND json_type(entries)='array'),
+ revision INTEGER NOT NULL CHECK(revision>=1),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ expires_at TEXT NOT NULL,
+ PRIMARY KEY(job_id,subject)
+);
+-- Accounting provenance is not an access grant. Live session/Admission checks
+-- still run before requests and delayed paid starts. Keep member/guest FKs separate.
+CREATE TABLE IF NOT EXISTS operation_principals (
+ subject TEXT PRIMARY KEY,
+ quota_class TEXT NOT NULL CHECK(quota_class IN ('member','guest')),
+ member_subject TEXT UNIQUE REFERENCES app_users(subject),
+ guest_subject TEXT UNIQUE REFERENCES guest_sessions(subject),
+ UNIQUE(subject,quota_class),
+ CHECK((quota_class='member' AND member_subject IS NOT NULL AND member_subject=subject AND guest_subject IS NULL)
+ OR (quota_class='guest' AND guest_subject IS NOT NULL AND guest_subject=subject AND member_subject IS NULL))
+);
+INSERT OR IGNORE INTO operation_principals(subject,quota_class,member_subject)
+ SELECT subject,'member',subject FROM app_users;
+CREATE TRIGGER IF NOT EXISTS member_operation_principal AFTER INSERT ON app_users BEGIN
+ INSERT INTO operation_principals(subject,quota_class,member_subject) VALUES(NEW.subject,'member',NEW.subject);
+END;
+CREATE TRIGGER IF NOT EXISTS guest_operation_principal AFTER INSERT ON guest_sessions BEGIN
+ INSERT INTO operation_principals(subject,quota_class,guest_subject) VALUES(NEW.subject,'guest',NEW.subject);
+END;
+CREATE TRIGGER IF NOT EXISTS operation_principal_immutable BEFORE UPDATE ON operation_principals
+BEGIN SELECT RAISE(ABORT,'immutable operation principal'); END;
+
+CREATE TABLE IF NOT EXISTS app_operations (
+ id TEXT PRIMARY KEY,
+ subject TEXT NOT NULL,
+ quota_class TEXT NOT NULL DEFAULT 'member' CHECK(quota_class IN ('member','guest')),
+ course_id TEXT CHECK(quota_class<>'guest' OR course_id IS NULL),
+ kind TEXT NOT NULL CHECK(kind IN ('split','chat','guide')),
+ idempotency_key TEXT NOT NULL,
+ fingerprint TEXT NOT NULL,
+ day TEXT NOT NULL,
+ state TEXT NOT NULL CHECK(state IN ('queued','running','starting','processing','reconciling','succeeded','partial','failed','cancelled')),
+ phase TEXT NOT NULL CHECK(phase IN ('fetch','split','chat','guide')),
+ job_id TEXT,
+ request_json TEXT NOT NULL DEFAULT '{}',
+ result_json TEXT,
+ provider_id TEXT,
+ lease_owner TEXT,
+ lease_until INTEGER NOT NULL DEFAULT 0,
+ fence INTEGER NOT NULL DEFAULT 0,
+ not_before INTEGER NOT NULL DEFAULT 0,
+ deadline INTEGER NOT NULL,
+ cancel_requested INTEGER NOT NULL DEFAULT 0 CHECK(cancel_requested IN (0,1)),
+ error_code TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(subject,kind,idempotency_key),
+ FOREIGN KEY(subject,quota_class) REFERENCES operation_principals(subject,quota_class)
+);
+CREATE INDEX IF NOT EXISTS app_operations_allowance ON app_operations(subject,kind,day,state);
+CREATE INDEX IF NOT EXISTS app_operations_queue ON app_operations(kind,phase,state,not_before,created_at);
+CREATE UNIQUE INDEX IF NOT EXISTS app_operations_split_job ON app_operations(job_id) WHERE kind='split';
+CREATE TABLE IF NOT EXISTS operation_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ operation_id TEXT NOT NULL REFERENCES app_operations(id),
+ state TEXT NOT NULL,
+ phase TEXT NOT NULL,
+ fence INTEGER NOT NULL,
+ code TEXT,
+ at INTEGER NOT NULL,
+ event_type TEXT NOT NULL DEFAULT 'lifecycle',
+ attempt_id TEXT,
+ kind TEXT NOT NULL,
+ duration_ms INTEGER NOT NULL DEFAULT 0,
+ model TEXT,
+ fallback INTEGER NOT NULL DEFAULT 0,
+ quota_effect TEXT NOT NULL DEFAULT 'none'
+);
+CREATE TRIGGER IF NOT EXISTS operation_created AFTER INSERT ON app_operations BEGIN
+ INSERT INTO operation_events(operation_id,state,phase,fence,code,at,kind,duration_ms,quota_effect)
+ VALUES(NEW.id,NEW.state,NEW.phase,NEW.fence,NEW.error_code,NEW.updated_at,NEW.kind,
+ MAX(0,NEW.updated_at-NEW.created_at),'reserved');
+END;
+CREATE TRIGGER IF NOT EXISTS operation_changed AFTER UPDATE ON app_operations
+WHEN OLD.state<>NEW.state OR OLD.phase<>NEW.phase OR OLD.fence<>NEW.fence BEGIN
+ INSERT INTO operation_events(operation_id,state,phase,fence,code,at,kind,duration_ms,quota_effect)
+ VALUES(NEW.id,NEW.state,NEW.phase,NEW.fence,NEW.error_code,NEW.updated_at,NEW.kind,
+ MAX(0,NEW.updated_at-NEW.created_at),CASE WHEN NEW.state IN ('succeeded','partial') THEN 'charged'
+ WHEN NEW.state IN ('failed','cancelled') THEN 'released' ELSE 'none' END);
+END;
+CREATE TRIGGER IF NOT EXISTS operation_terminal_fence BEFORE UPDATE ON app_operations
+WHEN OLD.state IN ('succeeded','partial','failed','cancelled') AND NEW.state<>OLD.state
+BEGIN SELECT RAISE(ABORT,'terminal operation'); END;
+CREATE TRIGGER IF NOT EXISTS operation_identity_fence BEFORE UPDATE ON app_operations
+WHEN NEW.subject<>OLD.subject OR NEW.quota_class<>OLD.quota_class OR NEW.kind<>OLD.kind OR NEW.day<>OLD.day OR
+ NEW.fingerprint<>OLD.fingerprint OR NEW.idempotency_key<>OLD.idempotency_key OR NEW.course_id IS NOT OLD.course_id
+BEGIN SELECT RAISE(ABORT,'immutable operation identity'); END;
+CREATE TABLE IF NOT EXISTS operation_attempts (
+ id TEXT PRIMARY KEY,
+ operation_id TEXT NOT NULL REFERENCES app_operations(id),
+ phase TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ model TEXT,
+ outcome TEXT NOT NULL,
+ external_id TEXT,
+ http_status INTEGER,
+ code TEXT,
+ -- Provider spend is separate from allowance. NULL means unreported, not free.
+ usage_json TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS operation_attempts_operation ON operation_attempts(operation_id,phase,created_at);
+CREATE TABLE IF NOT EXISTS operation_fairness (
+ subject TEXT NOT NULL,
+ phase TEXT NOT NULL,
+ last_started INTEGER NOT NULL,
+ PRIMARY KEY(subject,phase)
+);
+CREATE TABLE IF NOT EXISTS provider_cooldowns (
+ provider TEXT PRIMARY KEY,
+ until_ms INTEGER NOT NULL
+);
+-- Only same subject AND exact authorized course may coalesce/reuse imports.
+-- Sources with no affirmative rights evidence (including YouTube) never cache.
+CREATE TABLE IF NOT EXISTS import_cache (
+ scope TEXT NOT NULL,
+ source TEXT NOT NULL,
+ operation_id TEXT NOT NULL REFERENCES app_operations(id),
+ object_key TEXT,
+ metadata_json TEXT,
+ expires_at INTEGER NOT NULL,
+ PRIMARY KEY(scope,source)
+);
+CREATE TRIGGER IF NOT EXISTS operation_job_done_guard BEFORE UPDATE OF status ON jobs
+WHEN NEW.status='done' AND EXISTS(SELECT 1 FROM app_operations o WHERE o.job_id=NEW.id AND o.kind='split'
+ AND (o.state IN ('failed','cancelled') OR o.cancel_requested=1))
+BEGIN SELECT RAISE(ABORT,'operation no longer accepts success'); END;
+CREATE TRIGGER IF NOT EXISTS operation_job_delete_guard BEFORE DELETE ON jobs
+WHEN EXISTS(SELECT 1 FROM app_operations WHERE job_id=OLD.id AND kind='split'
+ AND state NOT IN ('succeeded','partial','failed','cancelled'))
+BEGIN SELECT RAISE(ABORT,'cancel and reconcile operation before deleting job'); END;
+CREATE TRIGGER IF NOT EXISTS operation_job_settlement AFTER UPDATE OF status ON jobs
+WHEN NEW.status IN ('done','failed') BEGIN
+ UPDATE app_operations SET state=CASE WHEN NEW.status='done' THEN 'succeeded'
+ WHEN cancel_requested=1 THEN 'cancelled' ELSE 'failed' END,
+ lease_owner=NULL,lease_until=0,updated_at=CAST(strftime('%s','now') AS INTEGER)*1000
+ WHERE job_id=NEW.id AND kind='split' AND state NOT IN ('succeeded','partial','failed','cancelled');
+END;
+
+-- Attempts and lifecycle events are in the same transaction as their evidence.
+-- No body, transcript, filename, source URL, name, email or credential is copied.
+CREATE TRIGGER IF NOT EXISTS operation_attempt_created AFTER INSERT ON operation_attempts BEGIN
+ INSERT INTO operation_events(operation_id,state,phase,fence,code,at,event_type,attempt_id,kind,duration_ms,model,fallback)
+ SELECT NEW.operation_id,NEW.outcome,NEW.phase,o.fence,NEW.code,NEW.created_at,'attempt_start',NEW.id,o.kind,0,NEW.model,
+ CASE WHEN NEW.provider='cail-gateway' AND EXISTS(SELECT 1 FROM operation_attempts a WHERE a.operation_id=NEW.operation_id AND a.id<>NEW.id) THEN 1 ELSE 0 END
+ FROM app_operations o WHERE o.id=NEW.operation_id;
+END;
+CREATE TRIGGER IF NOT EXISTS operation_attempt_changed AFTER UPDATE ON operation_attempts
+WHEN NEW.outcome<>OLD.outcome BEGIN
+ INSERT INTO operation_events(operation_id,state,phase,fence,code,at,event_type,attempt_id,kind,duration_ms,model,fallback)
+ SELECT NEW.operation_id,NEW.outcome,NEW.phase,o.fence,NEW.code,NEW.updated_at,'attempt_end',NEW.id,o.kind,
+ MAX(0,NEW.updated_at-NEW.created_at),NEW.model,
+ CASE WHEN NEW.provider='cail-gateway' AND NEW.model='deepseek-v4-flash-0731' THEN 1 ELSE 0 END
+ FROM app_operations o WHERE o.id=NEW.operation_id;
+END;
+CREATE INDEX IF NOT EXISTS operation_events_retention ON operation_events(at);
+CREATE TABLE IF NOT EXISTS usage_events (
+ event_id TEXT PRIMARY KEY,
+ actor_key TEXT NOT NULL,
+ actor_class TEXT NOT NULL CHECK(actor_class IN ('member','guest','anonymous')),
+ event_type TEXT NOT NULL,
+ source TEXT NOT NULL CHECK(source IN ('client','server')),
+ job_id TEXT,
+ outcome TEXT NOT NULL,
+ code TEXT,
+ http_status INTEGER,
+ duration_ms INTEGER,
+ position_bucket INTEGER,
+ at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS usage_events_retention ON usage_events(at);
+CREATE INDEX IF NOT EXISTS usage_events_actor ON usage_events(actor_key,at);
diff --git a/public/account.html b/public/account.html
index 4f49543..b1321b9 100644
--- a/public/account.html
+++ b/public/account.html
@@ -16,8 +16,9 @@
My account
Daily split allowance unavailable.
+
Listening Guy allowance unavailable.
Model usage unavailable.
-
Submitted runs count even if they fail. Listening Guy uses your Lab allowance separately.
+
Only finished splits use the daily split allowance. Pending splits reserve a place; failed or cancelled splits return it. Listening Guy allows 50 human inputs per UTC day, separate from estimated Lab model usage.