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 { + 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

- - + +
+ + + + diff --git a/public/classroom.js b/public/classroom.js index 4baac6c..a7c0e50 100644 --- a/public/classroom.js +++ b/public/classroom.js @@ -111,6 +111,7 @@ async function openFolder(id, scope = context()) { async function selectCourse() { generation++; ready = false; course = courses.find(entry => entry.classId === el('classroom-course').value); clearCourseView(); if (!course) return null; + window.StemUsage?.setCourse(course.classId); const scope = context(); el('classroom-refresh').disabled = true; status('Loading…'); try { await Promise.all([folders(scope), course.owner ? roster(false, scope) : Promise.resolve()]); diff --git a/public/guest.js b/public/guest.js new file mode 100644 index 0000000..250b910 --- /dev/null +++ b/public/guest.js @@ -0,0 +1,67 @@ +// Public configuration carries only the widget site key. The server verifies +// the challenge and owns the HttpOnly guest cookie, limits, and private work. +(() => { + let scriptReady; + function loadWidget() { + if (window.turnstile) return Promise.resolve(); + if (!scriptReady) scriptReady = new Promise((resolve,reject) => { + const script=document.createElement('script'); + script.src='https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.async=true; + script.addEventListener('load',()=>window.turnstile ? resolve() : reject(new Error('The guest check did not load. Try again.')),{once:true}); + script.addEventListener('error',()=>{scriptReady=null;reject(new Error('The guest check could not connect. Try again.'));},{once:true}); + document.head.append(script); + }); + return scriptReady; + } + window.StemGuest = { + mount(container,configuration,state={}) { + if (!container || configuration?.enabled !== true || configuration.canStart === false || !configuration.siteKey) return; + const panel=document.createElement('section'); + panel.className='guest-access account-overview'; + panel.setAttribute('aria-label','Guest access'); + const description=document.createElement('p'); + description.className='account-help'; + const allowance=configuration.chatAvailable === true + ? '5 successful splits and 25 Listening Guy questions per UTC day.' + : '5 successful splits per UTC day. Use CUNY Login for Listening Guy.'; + description.textContent=(state.active ? 'Guest session: ' : 'Try as a guest: ')+allowance+' Failed splits return their place. Your guest work stays separate from CUNY accounts and courses.'; + const privacy=document.createElement('p'); + privacy.className='account-help'; + privacy.textContent='Guest access lasts 7 days in this browser. Clearing its cookie or ending the session removes access to its saved work. Guest limits are per browser session; abuse protection also applies.'; + const button=document.createElement('button'); + button.type='button';button.className='account-button'; + button.textContent=state.active ? 'Refresh guest check' : 'Continue as guest'; + const status=document.createElement('p');status.className='account-help';status.setAttribute('role','status'); + if(state.verificationRequired)status.textContent='Complete a fresh guest check before your first split or question today.'; + const widget=document.createElement('div'); + let widgetId; + button.addEventListener('click',async()=>{ + button.disabled=true;status.textContent='Loading the guest check…'; + try { + await loadWidget(); + if(widgetId!==undefined){window.turnstile.reset(widgetId);status.textContent='Complete the guest check.';return;} + widgetId=window.turnstile.render(widget,{sitekey:configuration.siteKey,action:'stem_guest',size:'compact', + callback:async token=>{ + status.textContent='Starting your guest session…'; + try { + const response=await fetch('/auth/guest',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})}); + const result=await response.json(); + if(!response.ok)throw new Error(result.error?.message || 'The guest session could not start. Try again.'); + location.reload(); + }catch(error){status.textContent=error.message;button.disabled=false;window.turnstile.reset(widgetId);} + }, + 'error-callback':()=>{status.textContent='The guest check could not complete. Try again.';button.disabled=false;}, + 'expired-callback':()=>{status.textContent='The guest check expired. Try again.';button.disabled=false;}, + }); + status.textContent='Complete the guest check.'; + }catch(error){status.textContent=error.message;button.disabled=false;} + }); + const links=document.createElement('p');links.className='account-help'; + const login=document.createElement('a');login.href='/auth/login';login.textContent='CUNY Login'; + const access=document.createElement('a');access.href='https://ailab.gc.cuny.edu/request-access/';access.textContent='Request Lab access'; + links.append(login,' · ',access); + panel.append(description,privacy,button,status,widget,links);container.append(panel); + }, + }; +})(); diff --git a/public/index.html b/public/index.html index 4babc30..967ab1a 100644 --- a/public/index.html +++ b/public/index.html @@ -46,7 +46,7 @@

Stem Splitter

- +

Split into

@@ -168,8 +168,10 @@

REMIX DECK02 · INSTRUCTOR - +
+ +
@@ -187,7 +189,9 @@

ENTER CLASS CODE

+ - + + diff --git a/public/session-guard.js b/public/session-guard.js index d7f1684..e4295d4 100644 --- a/public/session-guard.js +++ b/public/session-guard.js @@ -33,7 +33,7 @@ } function clear() { if (clearing) return; - clearing = true; suspend(); cleanup(); + clearing = true; window.StemUsage?.clear(); suspend(); cleanup(); const main = document.querySelector('main'); if (main) main.replaceChildren(); message.textContent = 'Your account changed. Reloading your workspace…'; retry.hidden = true; @@ -71,7 +71,7 @@ return checking; } window.StemSessionGuard = { - start(value, onClear, onPause) { subject = value; cleanup = onClear || cleanup; pause = onPause || pause; }, + start(value, onClear, onPause) { subject = value; cleanup = onClear || cleanup; pause = onPause || pause; window.StemUsage?.start(value); }, observe, check, clear, }; window.addEventListener('focus', () => void check()); diff --git a/public/teacher.html b/public/teacher.html index 96bb631..97b6778 100644 --- a/public/teacher.html +++ b/public/teacher.html @@ -129,8 +129,11 @@

More

Uploaded files are deleted after 90 days. Only upload music you’re entitled to use for class.

← Back to Splitter

- +
+ + + diff --git a/public/teacher.js b/public/teacher.js index 730e23b..65176a3 100644 --- a/public/teacher.js +++ b/public/teacher.js @@ -451,7 +451,7 @@ historyMoreBtn.addEventListener('click', async () => { for(const course of courses)select.add(new Option(`${course.className} · ${course.term} · ${course.section}`,course.classId)); const requested=new URLSearchParams(location.search).get('course');selectedCourse=courses.some(c=>c.classId===requested)?requested:courses[0].classId;select.value=selectedCourse; document.getElementById('teacher-courses').hidden=false; - const setRoster=()=>{document.getElementById('teacher-roster').href=`/classroom.html?course=${encodeURIComponent(selectedCourse)}`;};setRoster(); + const setRoster=()=>{window.StemUsage?.setCourse(selectedCourse);document.getElementById('teacher-roster').href=`/classroom.html?course=${encodeURIComponent(selectedCourse)}`;};setRoster(); select.addEventListener('change',async()=>{ if(amendment.value.trim()!==loadedAmendment&&!confirm('Discard unsaved course instructions?')){select.value=selectedCourse;return;} selectedCourse=select.value;setRoster();try{await loadPrompt();}catch{/* The current load owns its error state. */} diff --git a/public/usage.js b/public/usage.js new file mode 100644 index 0000000..59175e5 --- /dev/null +++ b/public/usage.js @@ -0,0 +1,38 @@ +/* Discrete, content-free product diagnostics. Playback never waits for this. + * In-memory queue only: at most 50 events, one network retry, no offline archive. + * Stable IDs make that retry idempotent; page close/offline/rate limits can lose + * events. These are observations, not billing or guaranteed delivery receipts. */ +(() => { + let active=false,actor=null,course='personal',queue=[],timer=null,sending=false,generation=0,controller=null; + const allowed=new Set(['session_start','page_view','playback_start','playback_stop','seek','download_intent']); + function record(type,detail={}) { + if(!active||!allowed.has(type)||queue.length>=50)return; + const event={id:crypto.randomUUID(),type}; + if(typeof detail.jobId==='string'&&/^[a-zA-Z0-9_-]{1,80}$/.test(detail.jobId))event.jobId=detail.jobId; + if(Number.isFinite(detail.durationMs))event.durationMs=Math.min(86400000,Math.max(0,Math.round(detail.durationMs))); + if(Number.isFinite(detail.position))event.positionBucket=Math.min(90,Math.max(0,Math.floor(detail.position/10))); + queue.push(event);if(!timer)timer=setTimeout(flush,1200); + } + async function flush() { + clearTimeout(timer);timer=null; + if(sending||!active||!queue.length)return; + sending=true;const current=generation,currentActor=actor;controller=new AbortController();const batch=queue.splice(0,10); + try { + for(let attempt=0;attempt<2;attempt++) { + if(!active||generation!==current)break; + try { + const response=await fetch('/api/usage-events',{method:'POST',credentials:'same-origin',keepalive:true,signal:AbortSignal.any([AbortSignal.timeout(5000),controller.signal]), + headers:{'Content-Type':'application/json','X-Stem-Course':course,'X-Stem-Usage-Actor':currentActor},body:JSON.stringify({events:batch})}); + if(response.ok||response.status<500)break; + }catch{} + if(attempt===0)await new Promise(resolve=>setTimeout(resolve,2000)); + } + }finally{sending=false;if(queue.length&&active)timer=setTimeout(flush,1200);} + } + window.StemUsage={record,setCourse(value){course=typeof value==='string'?value:'personal';},start(value){ + if(typeof value!=='string'||!value)return; + if(active&&actor===value)return; + generation++;controller?.abort();queue=[];actor=value;active=true;record('session_start');record('page_view'); + },clear(){generation++;controller?.abort();active=false;actor=null;queue=[];clearTimeout(timer);timer=null;}}; + window.addEventListener('pagehide',()=>void flush()); +})(); diff --git a/replicate-yt-audio/README.md b/replicate-yt-audio/README.md index 7bc2f56..351f9f9 100644 --- a/replicate-yt-audio/README.md +++ b/replicate-yt-audio/README.md @@ -1,39 +1,43 @@ -# yt-audio — Replicate fetch model +# yt-audio — pinned permitted-source importer -Tiny CPU model (yt-dlp + ffmpeg) that turns a YouTube URL into an M4A audio -file plus title/duration. Deployed as **cali-tlc/yt-audio** (private — the -owner must match the account of the `REPLICATE_API_TOKEN` in use, or every -prediction start 422s with "version does not exist or not permitted") and -used by the app as the primary fetcher; YouTube bot-checks the in-app -`youtubei.js` attempt from all datacenter egress IPs. +This directory maintains the source of the private Replicate audio-import +model. Changing it does **not** change the deployed model version selected by +`REPLICATE_YT_MODEL_VERSION`. No image was published or paid canary run for this +PR. An approved image build, exact new version pin and permitted-source canary +remain release gates. -- Inputs: `url` (YouTube link), `max_duration` (seconds, default 900) -- Output: `{ audio: file, title: string, duration: float }` -- Errors are raised as `ValueError` with student-readable messages; the - Worker surfaces prediction errors verbatim. +The image pins `yt-dlp[default,pin]==2026.8.19`, matching +`yt-dlp-ejs==0.8.0`, and the extractor's pinned supported Deno `2.9.5` runtime. +These pins were checked against their public package metadata on 2026-10-06. +The system ffmpeg package must also be recorded in the eventual image digest. +The previous model image is not evidence that the updated JS/EJS runtime runs. -## Update / redeploy +Only supported YouTube video URLs enter yt-dlp. Authentication, age, region, +DRM, removed/private video and challenge restrictions stop the request. There +is no cookie injection, logged-in browser impersonation, proxy rotation, +mobile-client rotation or challenge bypass. The app offers an original or +licensed upload, the user's own Studio/Takeout download, and authorized Archive +sources when importing is unavailable. A valid URL is not a promise of access. -```sh -brew install cog # once -cog login # once, interactive — the account owning REPLICATE_API_TOKEN -cd replicate-yt-audio -SMOKE_YOUTUBE_URL='' -cog predict -i url="$SMOKE_YOUTUBE_URL" # local smoke test (needs Docker) -cog push r8.im/cali-tlc/yt-audio -``` +Each prediction has a unique output directory, a 230-second total deadline, +a 90-second subprocess ceiling and zero nested yt-dlp retries. Timeout or +cancellation kills the subprocess group, including ffmpeg children. Audio is +limited to 100 MiB and 15 minutes, probed for an audio stream, then fully decoded +with ffmpeg before returning success. Metadata success alone is insufficient. +Failed/cancelled requests remove their temporary directory immediately. +Successful output must remain until Cog uploads the returned `Path`; it is +swept after one hour on setup or the next request. A terminated container's +filesystem remains governed by the provider's container lifecycle. Stdout and +stderr are captured on disk, bounded when read, and never sent to students. -Rebuild and push whenever imports start failing even with a valid pin: -`cog.yaml` deliberately does not pin `yt-dlp`, so a fresh build picks up the -latest extractor fixes — stale yt-dlp is the usual reason YouTube starts -bot-blocking a previously working deployment. +Run the free fault fixtures with: -After a push, retrieve the new version id, run the authorized import canary, and -then update `REPLICATE_YT_MODEL_VERSION` on Railway. A push does not take effect -automatically. The app sends that exact version to Replicate and rejects the -floating value `latest`. `REPLICATE_YT_MODEL` still records the owner/name; unset -either variable to disable the fallback without making app startup fail. +```sh +python3 -m unittest discover -s replicate-yt-audio -p 'test_*.py' +``` -If YouTube starts bot-checking Replicate's IPs too, the next escape hatch is -passing a logged-in session cookie to yt-dlp (`--cookies`); use a throwaway -Google account for that, not a personal one. +The tests use a Cog shim and synthetic subprocesses, not YouTube or Replicate. +They cover URL restrictions, dependencies, timeout/process termination, +metadata-only failure, invalid media, disk failure, concurrent paths and +restart cleanup. They do not validate a published image, account entitlement, +or live source availability. See [the operations runbook](../docs/reliability-operations.md). diff --git a/replicate-yt-audio/cog.yaml b/replicate-yt-audio/cog.yaml index efe642e..5f2f19d 100644 --- a/replicate-yt-audio/cog.yaml +++ b/replicate-yt-audio/cog.yaml @@ -1,10 +1,11 @@ -# Tiny CPU model: fetch a YouTube video's audio track with yt-dlp. -# Push target: r8.im//yt-audio (see README in this dir). +# Source maintenance only. A new image/version requires a separately approved canary. build: gpu: false python_version: "3.11" system_packages: - ffmpeg python_packages: - - yt-dlp + - "yt-dlp[default,pin]==2026.8.19" + - "yt-dlp-ejs==0.8.0" + - "deno==2.9.5" predict: "predict.py:Predictor" diff --git a/replicate-yt-audio/predict.py b/replicate-yt-audio/predict.py index 617c27d..feea1ff 100644 --- a/replicate-yt-audio/predict.py +++ b/replicate-yt-audio/predict.py @@ -1,18 +1,56 @@ -"""YouTube audio fetcher for the stem-splitter app. - -Downloads the best audio-only track as M4A (Demucs ingests M4A directly). -Raises ValueError with student-readable messages; the Worker surfaces -prediction errors verbatim. -""" - +"""Bounded import of permitted YouTube audio. No client/cookie/proxy rotation.""" +import atexit import json import os +import re +import shutil +import signal import subprocess import tempfile -from urllib.parse import urlparse +import time +from pathlib import Path as FilePath +from urllib.parse import parse_qs, urlparse from cog import BaseModel, BasePredictor, Input, Path +MAX_BYTES = 100 * 1024 * 1024 +MAX_SECONDS = 900 +TOTAL_SECONDS = 230 +ROOT = FilePath(tempfile.gettempdir()) / "stem-youtube-artifacts" +DENIAL = re.compile(r"private|sign.?in|age.?restrict|not available in your country|region|geo.?restrict|removed|deleted|drm|captcha|confirm.*bot|challenge|403|401", re.I) +_active = set() + + +def canonical_url(url): + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or parsed.username or parsed.password or parsed.port: + raise ValueError("invalid_youtube_url") + host = (parsed.hostname or "").lower() + if host.startswith("www.") or host.startswith("m."): + host = host.split(".", 1)[1] + video = None + if host == "youtu.be" and len(parsed.path.strip("/").split("/")) == 1: + video = parsed.path.strip("/") + elif host in ("youtube.com", "music.youtube.com", "youtube-nocookie.com"): + if parsed.path == "/watch": + video = parse_qs(parsed.query).get("v", [None])[0] + elif re.fullmatch(r"/(shorts|embed|live)/[\w-]{11}/?", parsed.path): + video = parsed.path.split("/")[2] + if not video or not re.fullmatch(r"[A-Za-z0-9_-]{11}", video): + raise ValueError("invalid_youtube_url") + return "https://www.youtube.com/watch?v=" + video + + +def cleanup_stale(now=None): + """Finished output is retained briefly for Cog's uploader, then collected. + Failed/cancelled work is removed immediately; startup also clears old roots. + """ + now = time.time() if now is None else now + ROOT.mkdir(mode=0o700, parents=True, exist_ok=True) + for child in ROOT.iterdir(): + if child.is_dir() and str(child) not in _active and now - child.stat().st_mtime > 3600: + shutil.rmtree(child, ignore_errors=True) + class Output(BaseModel): audio: Path @@ -21,83 +59,89 @@ class Output(BaseModel): class Predictor(BasePredictor): - def predict( - self, - url: str = Input(description="YouTube video URL"), - max_duration: int = Input( - description="Reject videos longer than this many seconds", default=900 - ), - ) -> Output: - # The URL is untrusted input; never let it be parsed as a yt-dlp flag. - parsed = urlparse(url) - if url.startswith("-") or parsed.scheme not in ("http", "https") or not parsed.netloc: - raise ValueError("Not a valid http(s) URL.") + def setup(self): + cleanup_stale() + for command in ("yt-dlp", "deno", "ffmpeg", "ffprobe"): + if shutil.which(command) is None: + raise RuntimeError("extractor_dependency_missing") + atexit.register(self._cleanup_active) - info = self._probe(url) + def _cleanup_active(self): + for directory in list(_active): + shutil.rmtree(directory, ignore_errors=True) - if info.get("is_live"): - raise ValueError("Live streams cannot be imported.") - duration = float(info.get("duration") or 0) - if duration > max_duration: - minutes = max_duration // 60 - raise ValueError(f"Video is longer than {minutes} minutes — pick a shorter one.") + def predict(self, url: str = Input(description="Permitted YouTube video URL"), + max_duration: int = Input(description="Duration ceiling", default=900)) -> Output: + url = canonical_url(url) + ceiling = min(MAX_SECONDS, max(1, int(max_duration))) + cleanup_stale() + directory = tempfile.mkdtemp(prefix="import-", dir=ROOT) + _active.add(directory) + keep = False + deadline = time.monotonic() + TOTAL_SECONDS + common = ["yt-dlp", "--ignore-config", "--no-playlist", "--no-cookies", "--no-cache-dir", + "--js-runtimes", "deno", "--no-remote-components", "--socket-timeout", "20", + "--retries", "0", "--fragment-retries", "0", "--extractor-retries", "0", + "--concurrent-fragments", "1", "--max-filesize", str(MAX_BYTES)] + try: + info = json.loads(self._run(common + ["-J", "--skip-download", "--", url], deadline)) + if info.get("is_live") or info.get("live_status") in ("is_live", "is_upcoming"): + raise ValueError("Live streams cannot be imported.") + duration = float(info.get("duration") or 0) + if not 0 < duration <= ceiling: + raise ValueError("Video is longer than 15 minutes or has no usable duration.") + if info.get("availability") in ("private", "premium_only", "subscriber_only", "needs_auth") or info.get("age_limit", 0) > 0: + raise ValueError("source_denied") + self._run(common + ["-f", "bestaudio[ext=m4a]/bestaudio", "-x", "--audio-format", "m4a", + "--postprocessor-args", "ffmpeg:-fs 104857600", "-o", os.path.join(directory, "audio.%(ext)s"), "--", url], deadline) + out = os.path.join(directory, "audio.m4a") + if not os.path.isfile(out) or not 1024 <= os.path.getsize(out) <= MAX_BYTES: + raise ValueError("invalid_audio_response") + probe = json.loads(self._run(["ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,codec_name", "-of", "json", out], deadline)) + decoded_duration = float(probe.get("format", {}).get("duration") or 0) + if not 0 < decoded_duration <= ceiling or not any(s.get("codec_type") == "audio" for s in probe.get("streams", [])): + raise ValueError("invalid_audio_response") + # ffprobe metadata alone is not successful decoding. + self._run(["ffmpeg", "-v", "error", "-xerror", "-i", out, "-map", "0:a:0", "-f", "null", "-"], deadline) + for child in FilePath(directory).iterdir(): + if str(child) != out: + child.unlink(missing_ok=True) + keep = True + return Output(audio=Path(out), title=str(info.get("title") or "youtube-audio")[:200], duration=decoded_duration) + finally: + _active.discard(directory) + if not keep: + shutil.rmtree(directory, ignore_errors=True) - tmp = tempfile.mkdtemp() - # %(ext)s keeps a video-container download from colliding with the - # extracted M4A; -x --audio-format m4a makes the final name stable. - template = os.path.join(tmp, "audio.%(ext)s") - out = os.path.join(tmp, "audio.m4a") - # YouTube requires PO tokens for web-client format URLs from datacenter - # IPs (media downloads 403 even when metadata succeeds). These app - # clients are exempt from that requirement to varying degrees, so try - # them in order until one delivers audio. Some expose no audio-only - # formats, hence the /best fallback — extraction transcodes to M4A. - last_error = None - for client in ("android", "android_vr", "tv", "ios", "default"): + def _run(self, cmd, deadline=None): + timeout = min(90, (deadline or time.monotonic() + 90) - time.monotonic()) + if timeout <= 0: + raise ValueError("extractor_timeout") + # A process group lets timeout/cancellation kill ffmpeg descendants too. + with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: + proc = subprocess.Popen(cmd, stdout=output, stderr=errors, start_new_session=True) try: - self._run( - [ - "yt-dlp", - "--no-playlist", - "--extractor-args", - f"youtube:player_client={client}", - "-f", - "bestaudio[ext=m4a]/bestaudio/best", - "-x", - "--audio-format", - "m4a", - "-o", - template, - "--", - url, - ] - ) - except ValueError as err: - last_error = err - print(f"[yt-audio] client {client} failed: {err}", flush=True) - continue - if os.path.exists(out): - print(f"[yt-audio] client {client} delivered audio", flush=True) - break - if not os.path.exists(out): - if last_error is not None: - raise last_error - raise ValueError("YouTube returned no audio track for this video.") - - return Output( - audio=Path(out), - title=info.get("title") or "youtube-audio", - duration=duration, - ) - - def _probe(self, url: str) -> dict: - stdout = self._run(["yt-dlp", "-J", "--no-playlist", "--", url]) - return json.loads(stdout) - - def _run(self, cmd: list) -> str: - proc = subprocess.run(cmd, capture_output=True, text=True) - if proc.returncode != 0: - # Last stderr line usually carries yt-dlp's human-readable reason. - reason = (proc.stderr or "").strip().splitlines()[-1:] or ["unknown error"] - raise ValueError(f"YouTube fetch failed: {reason[0][:300]}") - return proc.stdout + proc.wait(timeout=timeout) + except BaseException: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + raise ValueError("extractor_timeout_or_cancelled") from None + # Keep provider diagnostics bounded and private. Disk-backed capture + # prevents a giant metadata/error response exhausting process RAM. + output.seek(0, os.SEEK_END) + if output.tell() > 8 * 1024 * 1024: + raise ValueError("extractor_response_too_large") + output.seek(0) + stdout = output.read(8 * 1024 * 1024).decode("utf-8", errors="strict") + errors.seek(0) + stderr = errors.read(64 * 1024).decode("utf-8", errors="replace") + if proc.returncode: + if DENIAL.search(stderr): + raise ValueError("source_denied") + if re.search(r"429|too many requests|rate.?limit", stderr, re.I): + raise ValueError("source_rate_limited") + raise ValueError("extractor_failed") + return stdout diff --git a/replicate-yt-audio/test_predict.py b/replicate-yt-audio/test_predict.py new file mode 100644 index 0000000..d55d0fa --- /dev/null +++ b/replicate-yt-audio/test_predict.py @@ -0,0 +1,89 @@ +"""Free extractor fault fixtures; no YouTube/Replicate requests.""" +import importlib.util +import json +import os +import sys +import tempfile +import threading +import time +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +# Cog is an image dependency; these tests exercise our code, not a Cog upload. +class Model: + def __init__(self, **kwargs): self.__dict__.update(kwargs) +cog = types.ModuleType('cog') +cog.BaseModel = Model +cog.BasePredictor = object +cog.Input = lambda **kwargs: kwargs.get('default') +cog.Path = Path +sys.modules['cog'] = cog +spec = importlib.util.spec_from_file_location('predict', Path(__file__).with_name('predict.py')) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +class ExtractorTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root_patch = patch.object(m, 'ROOT', Path(self.tmp.name)) + self.root_patch.start() + self.addCleanup(self.root_patch.stop) + self.addCleanup(self.tmp.cleanup) + + def fixture(self, failure=None, barrier=None): + calls=[] + def run(cmd, deadline=None): + calls.append(cmd) + if '-J' in cmd: + if failure == 'denial': raise ValueError('source_denied') + return json.dumps({'title':'Fixture','duration':12,'is_live':failure=='live'}) + if cmd[0]=='yt-dlp': + target=cmd[cmd.index('-o')+1].replace('%(ext)s','m4a') + if failure=='disk': raise OSError('disk full') + Path(target).write_bytes(b'fixture' * 300) + if barrier: barrier.wait(timeout=3) + return '' + if cmd[0]=='ffprobe': return json.dumps({'format':{'duration':0 if failure=='zero' else 12},'streams':[{'codec_type':'audio'}]}) + if failure=='decode': raise ValueError('invalid_audio_response') + return '' + return run,calls + + def test_urls_and_option_injection(self): + for url in ['https://youtu.be/abcdefghijk','https://youtube.com/watch?v=abcdefghijk','https://music.youtube.com/watch?v=abcdefghijk','https://youtube.com/shorts/abcdefghijk','https://youtube.com/embed/abcdefghijk','https://youtube.com/live/abcdefghijk']: + self.assertEqual(m.canonical_url(url),'https://www.youtube.com/watch?v=abcdefghijk') + for url in ['--exec=evil','file:///etc/passwd','https://youtube.com.evil/watch?v=abcdefghijk','https://u:p@youtube.com/watch?v=abcdefghijk','https://youtube.com:88/watch?v=abcdefghijk','http://127.0.0.1/','https://youtube.com/playlist?list=abc']: + with self.assertRaises(ValueError): m.canonical_url(url) + + def test_denial_live_disk_and_bad_media_leave_no_partial_outputs(self): + for failure in ['denial','live','disk','zero','decode']: + predictor=m.Predictor();run,calls=self.fixture(failure) + with patch.object(predictor,'_run',side_effect=run): + with self.assertRaises((ValueError,OSError)):predictor.predict('https://youtu.be/abcdefghijk',900) + self.assertEqual(list(m.ROOT.iterdir()),[]) + if failure=='denial':self.assertEqual(len(calls),1) + + def test_concurrent_outputs_have_unique_paths_and_stale_restart_cleanup(self): + barrier=threading.Barrier(2);outputs=[];errors=[] + def invoke(): + predictor=m.Predictor();run,calls=self.fixture(barrier=barrier) + try: + with patch.object(predictor,'_run',side_effect=run):outputs.append(predictor.predict('https://youtu.be/abcdefghijk',900)) + for cmd in calls: + if cmd[0]=='yt-dlp': + self.assertEqual(cmd[-2],'--');self.assertIn('--retries',cmd);self.assertNotIn('--extractor-args',cmd) + except BaseException as error:errors.append(error) + threads=[threading.Thread(target=invoke) for _ in range(2)] + for thread in threads:thread.start() + for thread in threads:thread.join(5) + self.assertEqual(errors,[]);self.assertEqual(len(outputs),2);self.assertNotEqual(outputs[0].audio,outputs[1].audio) + m.cleanup_stale(time.time()+3700);self.assertEqual(list(m.ROOT.iterdir()),[]) + + def test_real_timeout_kills_child_process_and_missing_dependency_fails_closed(self): + with self.assertRaisesRegex(ValueError,'timeout'): + m.Predictor()._run([sys.executable,'-c','import time;time.sleep(20)'],time.monotonic()+.03) + with patch.object(m.shutil,'which',return_value=None): + with self.assertRaisesRegex(RuntimeError,'dependency'):m.Predictor().setup() + +if __name__=='__main__':unittest.main() diff --git a/replicate-yt-audio/verify-image.mjs b/replicate-yt-audio/verify-image.mjs new file mode 100644 index 0000000..f51cc58 --- /dev/null +++ b/replicate-yt-audio/verify-image.mjs @@ -0,0 +1,105 @@ +// Build acceptance only: inspect a local image and run setup with network disabled. +// A successful receipt establishes neither Replicate publication nor live import. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { findYouTubePinViolations } from '../scripts/lib/pin-check.mjs'; + +const [image, sourceCommit, outputPath] = process.argv.slice(2); +assert.match(sourceCommit ?? '', /^[a-f0-9]{40}$/); +assert.equal(image, `stem-youtube-candidate:${sourceCommit}`); +assert.ok(outputPath, 'an evidence output path is required'); + +function docker(args, input) { + return execFileSync('docker', args, { + encoding: 'utf8', input, timeout: 90_000, maxBuffer: 2 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); +} + +// Read only the metadata needed for this receipt, rather than image env values. +const metadata = docker(['image', 'inspect', '--format', [ + '{{.Id}}', '{{.Os}}', '{{.Architecture}}', + '{{index .Config.Labels "run.cog.version"}}', + '{{index .Config.Labels "run.cog.openapi_schema"}}', +].join('\n'), image]).split('\n'); +const [imageId, os, architecture, cogVersion, ...schemaLines] = metadata; +assert.match(imageId, /^sha256:[a-f0-9]{64}$/); +assert.equal(os, 'linux'); +assert.equal(architecture, 'amd64'); +assert.equal(cogVersion.replace(/^v/, ''), '0.23.0'); +const schemaText = schemaLines.join('\n'); +const schema = JSON.parse(schemaText); +// Cog's API schema version is independent of the CLI version in the image label. +assert.deepEqual(findYouTubePinViolations(schema), []); +assert.ok(!schema.components.schemas.Input.required.includes('max_duration')); +assert.equal(schema.components.schemas.Input.properties.max_duration.default, 900); +assert.equal(schema.components.schemas.Output.properties.audio.format, 'uri'); + +const smoke = JSON.parse(docker([ + 'run', '--rm', '--network', 'none', '--read-only', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=67108864', + '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--memory', '512m', '--cpus', '1', '--pids-limit', '64', + '--interactive', '--entrypoint', 'python', image, '-', +], ` +import importlib.metadata +import json +import platform +import re +import subprocess +from predict import Predictor + +expected = {"yt-dlp": "2026.8.19", "yt-dlp-ejs": "0.8.0", "deno": "2.9.5"} +versions = {name: importlib.metadata.version(name) for name in expected} +assert versions == expected, "extractor package versions differ from the reviewed pins" +assert platform.python_version_tuple()[:2] == ("3", "11") + +def version(command): + result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=15) + line = result.stdout.splitlines()[0] + assert len(line) <= 300 and re.fullmatch(r"[ -~]+", line) + return line + +executables = { + "yt-dlp": version(["yt-dlp", "--version"]), + "deno": version(["deno", "--version"]), + "ffmpeg": version(["ffmpeg", "-version"]), + "ffprobe": version(["ffprobe", "-version"]), +} +assert executables["yt-dlp"] == "2026.08.19" +assert executables["deno"].startswith("deno 2.9.5 ") +assert executables["ffmpeg"].startswith("ffmpeg version ") +assert executables["ffprobe"].startswith("ffprobe version ") + +def forbid_prediction(*args, **kwargs): + raise AssertionError("the build smoke must not execute predictions") + +Predictor.predict = forbid_prediction +Predictor().setup() +print(json.dumps({"python": platform.python_version(), "packages": versions, + "executables": executables, "setupPassed": True, "predictionCalls": 0})) +`)); +assert.equal(smoke.setupPassed, true); +assert.equal(smoke.predictionCalls, 0); +assert.deepEqual(Object.keys(smoke).sort(), ['executables', 'packages', 'predictionCalls', 'python', 'setupPassed']); + +const evidence = { + schemaVersion: 1, + sourceCommit, + imageId, + platform: `${os}/${architecture}`, + cogVersion, + schemaSha256: createHash('sha256').update(schemaText).digest('hex'), + contract: { + requiredInputs: ['url'], optionalInputs: ['max_duration'], maximumDurationDefault: 900, + requiredOutputs: ['audio', 'title', 'duration'], audioFormat: 'uri', + }, + smoke, + runtimeNetwork: 'none', + imagePublished: false, + liveImportVerified: false, +}; +writeFileSync(outputPath, `${JSON.stringify(evidence, null, 2)}\n`); +console.log(`Verified extractor image ${imageId}; setup and schema passed; prediction calls: 0.`); diff --git a/schema.sql b/schema.sql index 159c2d7..bb604cf 100644 --- a/schema.sql +++ b/schema.sql @@ -679,3 +679,226 @@ BEGIN INSERT INTO course_folder_events(folder_id,course_id,actor,permission,revision) VALUES(NEW.id,NEW.course_id,NEW.created_by,NEW.permission,NEW.revision); END; +-- 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/src/analysis/source.ts b/src/analysis/source.ts index 534f5c9..be72965 100644 --- a/src/analysis/source.ts +++ b/src/analysis/source.ts @@ -214,10 +214,14 @@ export async function prepareAuthoritativeAutoSource( sourceKey: string; /** Test seam for the local 30-day retention boundary. */ nowMs?: number; + /** Durable callers fence cleanup and may reuse their own completed snapshot. */ + isCurrent?: () => Promise; + allowExisting?: boolean; } ): Promise { validateInput(input); const snapshotKey = `auto-inputs/v1/${input.jobId}`; + const rollback=async()=>{try {if(!input.isCurrent||await input.isCurrent())await rollbackSnapshot(env,snapshotKey);}catch {/* Uncertain ownership never licenses deletion. */}}; let source: R2ObjectBody | null; try { @@ -242,7 +246,12 @@ export async function prepareAuthoritativeAutoSource( } try { - if (await env.AUDIO.head(snapshotKey)) { + const existing=await env.AUDIO.head(snapshotKey); + if(existing&&input.allowExisting&&existing.size===source.size&&existing.customMetadata?.purpose==='authoritative-auto-input-v1'&&existing.customMetadata?.sourceBytes===String(source.size)) { + await source.body.cancel().catch(()=>undefined); + return {snapshotKey,bytes:existing.size}; + } + if (existing) { throw new AuthoritativeAutoSourceError( 'snapshot_conflict', 'The immutable Auto source snapshot already exists' @@ -256,6 +265,7 @@ export async function prepareAuthoritativeAutoSource( ); } + if(input.isCurrent&&!await input.isCurrent()) {await source.body.cancel().catch(()=>undefined);throw new AuthoritativeAutoSourceError('snapshot_conflict','This source request is being recovered.');} const bounded = boundedCopyStream(source.body, source.size); const upload = fixedLengthUpload(bounded.stream, source.size); let stored: R2Object | null = null; @@ -275,7 +285,7 @@ export async function prepareAuthoritativeAutoSource( } catch { await bounded.cancel(); if (upload.pump) await Promise.allSettled([upload.pump]); - await rollbackSnapshot(env, snapshotKey); + await rollback(); throw bounded.failure() ?? new AuthoritativeAutoSourceError( 'snapshot_failed', 'The immutable Auto source snapshot could not be stored' @@ -284,7 +294,7 @@ export async function prepareAuthoritativeAutoSource( if (!stored || stored.size !== source.size || !bounded.completed()) { await bounded.cancel(); - await rollbackSnapshot(env, snapshotKey); + await rollback(); throw bounded.failure() ?? new AuthoritativeAutoSourceError( 'snapshot_failed', 'The immutable Auto source snapshot did not match the uploaded source size' @@ -295,14 +305,14 @@ export async function prepareAuthoritativeAutoSource( try { committed = await env.AUDIO.head(snapshotKey); } catch { - await rollbackSnapshot(env, snapshotKey); + await rollback(); throw new AuthoritativeAutoSourceError( 'snapshot_failed', 'The immutable Auto source snapshot could not be verified' ); } if (!committed || committed.size !== source.size) { - await rollbackSnapshot(env, snapshotKey); + await rollback(); throw new AuthoritativeAutoSourceError( 'snapshot_failed', 'The immutable Auto source snapshot could not be verified' diff --git a/src/assistant/index.ts b/src/assistant/index.ts index 069f937..9a38b5a 100644 --- a/src/assistant/index.ts +++ b/src/assistant/index.ts @@ -228,6 +228,7 @@ export async function streamGuide( // generation made under the current prompt becomes the class-wide cache. // Returning this generation also keeps the final SSE event consistent with // the deltas this caller received when two generations race. + await env.assistantEffectIntent?.(); await cacheGuideIfPromptCurrent(env, guide, promptState.revision, promptHash); return { guide, cached: false }; } @@ -281,7 +282,7 @@ export async function streamChat( onDelta ); const toolCalls = sanitizeToolCalls(reply.toolCalls, stemNames, durationSec); - if (!reply.content && toolCalls.length === 0) throw new AssistantError(502, COACH_DOWN); + if (!reply.content.trim() && toolCalls.length === 0) throw new AssistantError(502, COACH_DOWN); // Tool-calling models often act without narrating, but the narration IS the // guiding. One cheap tool-free follow-up turns the console moves into prose; diff --git a/src/assistant/tools.ts b/src/assistant/tools.ts index 3b4e156..cd0267c 100644 --- a/src/assistant/tools.ts +++ b/src/assistant/tools.ts @@ -71,7 +71,10 @@ export function sanitizeToolCalls( durationSec?: number ): AssistantToolCall[] { const out: AssistantToolCall[] = []; + const seen = new Set(); for (const call of raw.slice(0, MAX_TOOL_CALLS)) { + if (call.id && (typeof call.id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(call.id) || seen.has(call.id))) continue; + if (call.id) seen.add(call.id); const name = call.function?.name; let args: unknown; try { @@ -85,16 +88,16 @@ export function sanitizeToolCalls( if (name === 'solo' || name === 'set_mute') { if (typeof a.stem !== 'string' || !stemNames.includes(a.stem)) continue; if (name === 'set_mute' && typeof a.muted !== 'boolean') continue; - out.push({ name, args: name === 'solo' ? { stem: a.stem } : { stem: a.stem, muted: a.muted } }); + out.push({ ...(call.id ? { id: call.id } : {}), name, args: name === 'solo' ? { stem: a.stem } : { stem: a.stem, muted: a.muted } }); } else if (name === 'seek' || name === 'add_note') { const seconds = clampSeconds(a.seconds, durationSec); if (seconds === null) continue; if (name === 'add_note') { const text = String(a.text ?? '').trim().slice(0, MAX_NOTE_CHARS); if (!text) continue; - out.push({ name, args: { seconds, text } }); + out.push({ ...(call.id ? { id: call.id } : {}), name, args: { seconds, text } }); } else { - out.push({ name, args: { seconds } }); + out.push({ ...(call.id ? { id: call.id } : {}), name, args: { seconds } }); } } } diff --git a/src/assistant/types.ts b/src/assistant/types.ts index 7d801aa..4a6f34d 100644 --- a/src/assistant/types.ts +++ b/src/assistant/types.ts @@ -17,6 +17,8 @@ export interface ChatTurn { /** A validated mixer command the browser executes (fire-and-forget). */ export interface AssistantToolCall { + /** Stable within one provider attempt; never use it without the operation ID. */ + id?: string; name: 'solo' | 'set_mute' | 'seek' | 'add_note'; args: Record; } diff --git a/src/classroom/access.ts b/src/classroom/access.ts index 032e0a3..2c64470 100644 --- a/src/classroom/access.ts +++ b/src/classroom/access.ts @@ -1,5 +1,6 @@ import type { Env } from '../env.ts'; import type { AppPrincipal } from '../identity.ts'; +import { guestOwnsJob } from '../guest/access.ts'; import { COURSE_APP, STEM_COURSE_ID, validAccess, validClassId, validFailure, type CourseAssignment } from './contract.ts'; export class CourseError extends Error { @@ -50,6 +51,8 @@ export function cleanDisplayName(value: unknown): string { } export interface JobPermission { owner: boolean; read: boolean; comment: boolean; courseId: string | null; instructor: boolean; administration?: boolean } export async function jobPermission(env: Env, principal: AppPrincipal, id: string): Promise { + if (principal.quotaClass === 'guest') return await guestOwnsJob(env,principal.subject,id) + ? {owner:true,read:true,comment:true,courseId:null,instructor:false} : null; const row = await env.DB.prepare(`SELECT o.subject,j.created_at,c.course_id FROM jobs j JOIN job_owners o ON o.job_id=j.id LEFT JOIN job_courses c ON c.job_id=j.id WHERE j.id=? AND j.created_at>datetime('now','-90 days')`).bind(id) .first<{ subject: string; course_id: string | null }>(); diff --git a/src/classroom/conversations.ts b/src/classroom/conversations.ts index fefd043..9745980 100644 --- a/src/classroom/conversations.ts +++ b/src/classroom/conversations.ts @@ -62,22 +62,28 @@ export async function beginCourseTurn(env: Env, jobId: string, principal: AppPri const turns = results.reverse().map(row => ({ role: row.kind === 'you' ? 'user' as const : 'assistant' as const, content: row.text.slice(0,2000) })); return { turnId, claimId, replay: null, revision: Number(revision)+1, turns }; } -export async function finishCourseTurn(env: Env, jobId: string, subject: string, turnId: string, claimId: string, result: ChatResult, failed = false, displayName = 'Student'): Promise { +export async function finishCourseTurn(env: Env, jobId: string, subject: string, turnId: string, claimId: string, result: ChatResult, failed = false, displayName = 'Student', operationId?: string): Promise { + // PR3's reservation may expire before the longer conversation recovery lease. + // Each effect checks it in the same transaction, so an old runner cannot + // restore output or notes after allowance recovery has fenced the operation. + const operationGuard=operationId?` AND EXISTS(SELECT 1 FROM app_operations o + WHERE o.id=? AND o.subject=? AND o.job_id=? AND o.kind='chat' AND o.state='running' AND o.lease_until>?)`:''; + const operationArgs=operationId?[operationId,subject,jobId,Date.now()]:[]; const statements = []; for (const call of result.toolCalls) if (!failed && call.name === 'add_note') { statements.push(env.DB.prepare(`INSERT INTO annotations(id,job_id,at_seconds,text,author_subject,author_name,provenance) - SELECT ?,job_id,?,?,subject,?,'server-assistant' FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')`) - .bind(crypto.randomUUID(),Number(call.args.seconds),String(call.args.text).slice(0,200),displayName,jobId,subject,claimId)); + SELECT ?,job_id,?,?,subject,?,'server-assistant' FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')${operationGuard}`) + .bind(crypto.randomUUID(),Number(call.args.seconds),String(call.args.text).slice(0,200),displayName,jobId,subject,claimId,...operationArgs)); } if (result.reply) statements.push(env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) - SELECT ?,job_id,subject,?,'coach','server-assistant',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,result.reply,jobId,subject,claimId)); + SELECT ?,job_id,subject,?,'coach','server-assistant',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')${operationGuard}`).bind(crypto.randomUUID(),turnId,result.reply,jobId,subject,claimId,...operationArgs)); if (result.toolCalls.length) statements.push(env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) - SELECT ?,job_id,subject,?,'action','server-tool',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,JSON.stringify(result.toolCalls),jobId,subject,claimId)); + SELECT ?,job_id,subject,?,'action','server-tool',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')${operationGuard}`).bind(crypto.randomUUID(),turnId,JSON.stringify(result.toolCalls),jobId,subject,claimId,...operationArgs)); statements.push(env.DB.prepare(`UPDATE course_turns SET state=?,reply=?,tools=?,finish_reason=?,revision=revision+1 - WHERE job_id=? AND subject=? AND turn_id=? AND state='pending' AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now'))`) - .bind(failed?'failed':'complete',result.reply,JSON.stringify(result.toolCalls),result.finishReason,jobId,subject,turnId,jobId,subject,claimId)); - statements.push(env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=NULL,pending_expires_at=NULL WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now') RETURNING revision`).bind(jobId,subject,claimId)); + WHERE job_id=? AND subject=? AND turn_id=? AND state='pending' AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now'))${operationGuard}`) + .bind(failed?'failed':'complete',result.reply,JSON.stringify(result.toolCalls),result.finishReason,jobId,subject,turnId,jobId,subject,claimId,...operationArgs)); + statements.push(env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=NULL,pending_expires_at=NULL WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')${operationGuard} RETURNING revision`).bind(jobId,subject,claimId,...operationArgs)); const saved = await env.DB.batch<{ revision: number }>(statements); if(!saved.at(-1)?.meta.changes)return null; const completed=await env.DB.prepare('SELECT revision FROM course_turns WHERE job_id=? AND subject=? AND turn_id=?').bind(jobId,subject,turnId).first<{revision:number}>(); diff --git a/src/daily-allowance.ts b/src/daily-allowance.ts index 41e4419..acb4037 100644 --- a/src/daily-allowance.ts +++ b/src/daily-allowance.ts @@ -1,5 +1,14 @@ -/** Request-count guard, not a monetary balance or a substitute for Gateway authorization. */ -export const SIGNED_IN_SPLITS_PER_DAY = 10; +/** App allowances are independent of provider spend and Gateway authorization. */ +export const SIGNED_IN_SPLITS_PER_DAY = 15; +export const HUMAN_INPUTS_PER_DAY = 50; +export const GUEST_SPLITS_PER_DAY = 5; +export const GUEST_INPUTS_PER_DAY = 25; +export type QuotaClass = 'member' | 'guest'; +export function allowanceLimit(kind: 'split' | 'chat' | 'guide', quotaClass: QuotaClass) { + return kind === 'guide' ? quotaClass === 'guest' ? 10 : 20 + : kind === 'split' ? quotaClass === 'guest' ? GUEST_SPLITS_PER_DAY : SIGNED_IN_SPLITS_PER_DAY + : quotaClass === 'guest' ? GUEST_INPUTS_PER_DAY : HUMAN_INPUTS_PER_DAY; +} export function dailyWindow(now = new Date()) { const day = now.toISOString().slice(0, 10); @@ -8,30 +17,19 @@ export function dailyWindow(now = new Date()) { return { day, resetsAt: resetsAt.toISOString(), retryAfter: Math.max(1, Math.ceil((resetsAt.getTime() - now.getTime()) / 1000)) }; } -export async function splitAllowance(db: D1Database, subject: string, now = new Date()) { - const { day, resetsAt } = dailyWindow(now); - const row = await db.prepare("SELECT COUNT(*) AS used FROM app_request_reservations WHERE scope = 'split' AND day = ? AND subject = ?") - .bind(day, subject).first<{ used: number }>(); - // A missing/corrupt read must never present a full allowance as verified. - if (!row || !Number.isSafeInteger(row.used) || row.used < 0) throw new Error('Allowance unavailable'); - return { limit: SIGNED_IN_SPLITS_PER_DAY, used: row.used, remaining: Math.max(0, SIGNED_IN_SPLITS_PER_DAY - row.used), resetsAt }; +export async function splitAllowance(db: D1Database, subject: string, now = new Date(), quotaClass: QuotaClass = 'member') { + return operationAllowance(db, subject, 'split', now, quotaClass); } -/** One atomic insert: parallel requests and different isolates share the same count. - * Reserve before imports/provider work; uncertain or failed attempts are not refunded. - * Only call after verified CAIL identity, Admission and local suspension checks. - */ -export async function reserveDailyRequest(db: D1Database, subject: string, scope: 'split' | 'guide', now = new Date()) { - const window = dailyWindow(now); - const personalLimit = scope === 'split' ? SIGNED_IN_SPLITS_PER_DAY : 100; - // No shared/class split ceiling. Preserve the legacy non-Gateway guide guard. - const sharedGuard = scope === 'guide' - ? 'AND (SELECT COUNT(*) FROM app_request_reservations WHERE scope = ? AND day = ?) < ?' : ''; - const bindings: (string | number)[] = [crypto.randomUUID(), subject, scope, window.day, scope, window.day, subject, personalLimit]; - if (scope === 'guide') bindings.push(scope, window.day, 500); - const reservation = await db.prepare(`INSERT INTO app_request_reservations (id, subject, scope, day) - SELECT ?, ?, ?, ? WHERE - (SELECT COUNT(*) FROM app_request_reservations WHERE scope = ? AND day = ? AND subject = ?) < ? ${sharedGuard}`) - .bind(...bindings).run(); - return { allowed: reservation.meta.changes === 1, ...window, limit: personalLimit }; +export async function operationAllowance(db: D1Database, subject: string, kind: 'split' | 'chat', now = new Date(), quotaClass: QuotaClass = 'member') { + const { day, resetsAt } = dailyWindow(now); + const row = await db.prepare(`SELECT + COALESCE(SUM(CASE WHEN state IN ('succeeded','partial') THEN 1 ELSE 0 END),0) AS completed, + COALESCE(SUM(CASE WHEN state NOT IN ('succeeded','partial','failed','cancelled') THEN 1 ELSE 0 END),0) AS inProgress + FROM app_operations WHERE kind=? AND day=? AND subject=?`) + .bind(kind, day, subject).first<{ completed: number; inProgress: number }>(); + // A missing/corrupt read must never present a full allowance as verified. + if (!row || !Number.isSafeInteger(row.completed) || row.completed < 0 || !Number.isSafeInteger(row.inProgress) || row.inProgress < 0) throw new Error('Allowance unavailable'); + const limit = allowanceLimit(kind,quotaClass); + return { limit, completed: row.completed, inProgress: row.inProgress, remaining: Math.max(0, limit-row.completed-row.inProgress), resetsAt }; } diff --git a/src/env.ts b/src/env.ts index d6d8ab6..2b85586 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,4 +1,4 @@ -export type Env = { +export type Env = import('./guest/access.ts').GuestSettings & { // Bindings AUDIO: R2Bucket; DB: D1Database; @@ -10,7 +10,14 @@ export type Env = { /** Private adapter dependencies; never accepted from a request body. */ assistantTransport?: typeof import('./assistant/openrouter.ts').openRouterChatStream; assistantQuota?: () => Promise; + /** Trusted platform-local PCM decoder; never supplied by a browser. */ + validateStemAudio?: (data: ArrayBuffer) => Promise; ASSISTANT_ABORT_SIGNAL?: AbortSignal; + /** Trusted logical input, bound to authenticated subject/course by the route. */ + ASSISTANT_OPERATION_ID?: string; + assistantEffectIntent?: () => Promise; + /** Drain/reconcile existing operations while preventing new paid starts. */ + SPLIT_STARTS_DISABLED?: string; CAIL_BROWSER_ORIGIN?: string; CAIL_COURSE_IDS?: string; /** Literal false pauses course collaboration while retaining owner-only reads. */ diff --git a/src/guest/access.ts b/src/guest/access.ts new file mode 100644 index 0000000..9fac8f6 --- /dev/null +++ b/src/guest/access.ts @@ -0,0 +1,79 @@ +import type { Env } from '../env.ts'; +import type { AppPrincipal } from '../identity.ts'; +import { dailyWindow } from '../daily-allowance.ts'; + +export interface GuestSession { + subject: string; + expiresAt: number; + verifiedDay: string; +} +export interface GuestSettings { + GUEST_ENABLED?: string; + GUEST_COOKIE_SECRET?: string; + GUEST_TURNSTILE_SITE_KEY?: string; + GUEST_TURNSTILE_SECRET?: string; + GUEST_GATEWAY_API_KEY?: string; + /** Computed by the private Worker adapter, never taken from browser input. */ + guestRuntimeReady?: boolean; + /** Optional Listening Guy readiness, computed by the private Worker adapter. */ + guestChatReady?: boolean; + guestStartAllowed?: boolean; + /** Verified cookie and live server session, set by the private adapter only. */ + guestSession?: GuestSession; +} +export const GUEST_SESSION_MS = 7 * 86400000; +export const GUEST_SUBJECT = /^guest-[0-9a-f]{64}$/; +export const guestPrincipal = (principal: AppPrincipal | undefined): boolean => principal?.quotaClass === 'guest'; +export const ownershipTable = (principal: AppPrincipal | undefined, kind: 'job' | 'upload') => + guestPrincipal(principal) ? kind === 'job' ? 'guest_job_owners' : 'guest_upload_owners' + : kind === 'job' ? 'job_owners' : 'upload_owners'; +export const conversationTable = (principal: AppPrincipal) => guestPrincipal(principal) ? 'guest_listening_conversations' : 'listening_conversations'; + +export async function activeGuest(db: D1Database, subject: string, now = Date.now()): Promise { + if (!GUEST_SUBJECT.test(subject)) return null; + const row = await db.prepare(`SELECT subject,expires_at AS expiresAt,verified_day AS verifiedDay FROM guest_sessions + WHERE subject=? AND expires_at>? AND revoked_at IS NULL`).bind(subject, now).first(); + return row && Number.isSafeInteger(row.expiresAt) && row.expiresAt > now && /^\d{4}-\d{2}-\d{2}$/.test(row.verifiedDay) ? row : null; +} +export async function guestOwnsJob(env: Env, subject: string, id: string): Promise { + return Boolean(await env.DB.prepare(`SELECT 1 FROM guest_job_owners o JOIN jobs j ON j.id=o.job_id + JOIN guest_sessions s ON s.subject=o.subject LEFT JOIN job_courses c ON c.job_id=j.id + WHERE o.subject=? AND j.id=? AND s.revoked_at IS NULL AND s.expires_at>? + AND c.job_id IS NULL AND j.created_at>datetime('now','-90 days')`).bind(subject,id,Date.now()).first()); +} +export function guestFailure(code: string, status: 401 | 403 | 429 | 503, message: string): Response { + return Response.json({ error: { code, message } }, { status, headers: { 'Cache-Control':'private, no-store' } }); +} + +/** Guests have a separate authority and an explicit route allowlist. A guest + * session cannot resolve Admission, select a course, or use member ownership. */ +export async function authorizeGuestRequest(request: Request, env: Env, authenticated: (principal: AppPrincipal) => void): Promise { + const supplied = env.guestSession; + const session = env.guestRuntimeReady && supplied && await activeGuest(env.DB,supplied.subject); + if (!session || session.expiresAt !== supplied?.expiresAt) return guestFailure('guest_session_expired',401,'This guest session has ended. Start a new guest session or sign in.'); + const path = new URL(request.url).pathname, method = request.method; + const read = method === 'GET' || method === 'HEAD'; + if (!read && (request.headers.get('origin') !== new URL(request.url).origin || request.headers.get('sec-fetch-site') === 'cross-site')) return guestFailure('guest_origin_invalid',403,'Open Stem Splitter directly to continue.'); + if (request.headers.has('x-stem-course') && request.headers.get('x-stem-course') !== 'personal') return guestFailure('guest_course_forbidden',403,'Sign in with an enrolled account to use course work.'); + const job = /^\/api\/jobs\/([a-zA-Z0-9-]+)(?:\/(labels|annotations|guide|chat|cancel|listening-conversation)(?:\/([a-zA-Z0-9-]+))?)?$/.exec(path); + const file = /^\/api\/files\/stems\/([a-zA-Z0-9-]+)\/(?:[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_.-]+$/.exec(path); + const general = read && ['/api/account','/api/auth-check','/api/jobs','/api/teacher/me','/api/archive/scopes','/api/archive/search'].includes(path) + || read && /^\/api\/archive\/items\/[a-zA-Z0-9_.-]+$/.test(path) + || method === 'POST' && ['/api/uploads','/api/jobs','/api/usage-events'].includes(path) + || method === 'PUT' && path.startsWith('/api/local-uploads/'); + const jobAllowed = job && (!job[2] ? read : job[2] === 'labels' ? method === 'PUT' + : ['guide','chat','cancel'].includes(job[2]) ? method === 'POST' && !job[3] + : job[2] === 'listening-conversation' ? ['GET','PUT','DELETE'].includes(method) && !job[3] + : job[2] === 'annotations' ? job[3] ? ['PUT','DELETE'].includes(method) : method === 'POST' : false); + if (!general && !jobAllowed && !(file && read)) return guestFailure('guest_route_forbidden',403,'This feature requires an authorized signed-in account.'); + if ((job || file) && !await guestOwnsJob(env,session.subject,(job ?? file)![1])) return Response.json({error:'Job not found'},{status:404}); + if (method === 'POST' && job && ['guide','chat'].includes(job[2]) && env.guestChatReady !== true) { + return guestFailure('guest_assistant_unavailable',503,'Listening Guy is available with CUNY Login. You can keep splitting and playing audio as a guest.'); + } + // A saved guest session can read its work after midnight, but paid starts + // require a fresh daily server-verified human check. + const paid = method === 'POST' && (path === '/api/jobs' || job && ['guide','chat'].includes(job[2])); + if (paid && session.verifiedDay !== dailyWindow().day) return guestFailure('guest_verification_required',403,'Complete the guest check again to continue today. Your saved work is still available.'); + authenticated({ subject:session.subject,role:'guest',quotaClass:'guest',displayName:'Guest',course:null,courseId:null }); + return null; +} diff --git a/src/identity.ts b/src/identity.ts index a56f433..fd3a9f9 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -2,10 +2,11 @@ import { courseIds, resolveCourse, jobCourse, jobPermission, cleanDisplayName, C import { STEM_COURSE_ID } from './classroom/contract.ts'; import type { AdmissionCourseResolver, CourseAssignment } from './classroom/contract.ts'; import type { Env } from './env.ts'; +import { authorizeGuestRequest } from './guest/access.ts'; export const STEM_AUDIENCE = 'cail:stem-splitter'; -export type AppRole = 'student' | 'instructor' | 'admin'; -export interface AppPrincipal { subject: string; role: AppRole; displayName: string; course: CourseAssignment | null; courseId: string | null; } +export type AppRole = 'student' | 'instructor' | 'admin' | 'guest'; +export interface AppPrincipal { subject: string; role: AppRole; quotaClass?: 'member' | 'guest'; displayName: string; course: CourseAssignment | null; courseId: string | null; } export interface AdmissionResolver extends AdmissionCourseResolver { resolveMembership(input: { subject: string }): Promise; } @@ -47,6 +48,10 @@ export async function authorizeCailRequest(request: Request, env: Env, authentic if (path === '/api/runtime' || path === '/api/separation-options' || path === '/api/webhooks/separation') return null; if (['GET', 'HEAD'].includes(request.method) && /^\/api\/shared-jobs\/[a-zA-Z0-9-]+(?:\/stems\/\d+)?$/.test(path)) return null; if (path.startsWith('/api/local-sources/') && request.method === 'GET') return null; // HMAC capability checked by the source route. + if (env.guestSession) { + if (!validWriteOrigin(request,env)) return authFailure('invalid_credential',403); + return authorizeGuestRequest(request,env,authenticated); + } if (!env.verifyCailIdentity || !env.ADMISSION_RESOLVER) return authFailure('identity_verification_misconfigured', 503); const token = request.headers.get('x-cail-identity-jwt'); if (!token || token.length > 16384) return authFailure('authentication_required', 401); @@ -108,7 +113,7 @@ export async function authorizeCailRequest(request: Request, env: Env, authentic const entry = await resolveCourse(env,identity.subject,STEM_COURSE_ID); if (!entry.participant) throw new CourseError(403,'target_course_enrollment_required'); } - const principal: AppPrincipal = { subject: identity.subject, role, + const principal: AppPrincipal = { subject: identity.subject, role, quotaClass:'member', displayName: cleanDisplayName(identity.name), course, courseId: !personal && request.headers.has('x-stem-course') && course ? course.classId : null }; if (course?.displayNameSource === 'verified_profile') principal.displayName = cleanDisplayName(course.displayName); diff --git a/src/index.ts b/src/index.ts index 9a78994..370af93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,19 @@ +import { retryAt } from './reliability/retry.ts'; +import { parseClientUses, recordClientUses, recordServerUse, usageSummary } from './reliability/observability.ts'; import classroomRoutes from './classroom/routes.ts'; import { courseAssignmentStatement, jobCourse, jobPermission, CourseError, courseErrorResponse } from './classroom/access.ts'; import { beginCourseTurn, finishCourseTurn, conversationPage, resetCourseConversation } from './classroom/conversations.ts'; +import { coursePrompt } from './classroom/prompts.ts'; import { Hono } from 'hono'; import type { Context } from 'hono'; import { createMiddleware } from 'hono/factory'; import type { Env } from './env'; -import { reserveDailyRequest, splitAllowance } from './daily-allowance.ts'; +import { splitAllowance, operationAllowance, dailyWindow } from './daily-allowance.ts'; +import { submitSplit, drainSplitQueue, reconcileSplit, recoverCallback } from './reliability/splits.ts'; +import { type Operation, readOperation, OperationError, fingerprint } from './reliability/ledger.ts'; +import { cancelOperation } from './reliability/queue.ts'; +import { reserveAssistant, assistantReceipt } from './reliability/assistant.ts'; +import { validMp3Frames } from './reliability/media.ts'; import { getRetainedAudio, serveStemAudio, @@ -122,6 +130,7 @@ import { import { audioSepReplicateIdentity } from './isolation/options.ts'; import { authorizeCailRequest, equalSecret, validWriteOrigin, type AppPrincipal } from './identity.ts'; import { readBoundedResponse } from './http/bounded-response.ts'; +import { guestPrincipal, ownershipTable, conversationTable } from './guest/access.ts'; const ALLOWED_EXTENSIONS = ['.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aiff', '.aif']; const MAX_SOURCE_BYTES = 100 * 1024 * 1024; // 100 MB @@ -176,6 +185,12 @@ async function sha256Text(value: string): Promise { type AppContext = { Bindings: Env; Variables: { principal?: AppPrincipal } }; const app = new Hono(); +app.use('/api/*', async (c,next)=>{ + const started=Date.now(); + await next(); + await recordServerUse(c.env,c.req.raw,c.res,c.get('principal'),started); +}); + app.use('/api/*', async (c, next) => { if (c.env.AUTH_MODE === 'cail') { const denied = await authorizeCailRequest(c.req.raw, c.env, (principal) => c.set('principal', principal)); @@ -183,20 +198,6 @@ app.use('/api/*', async (c, next) => { c.header('Cache-Control', 'private, no-store'); const principal = c.get('principal'); if(principal)c.header('X-Stem-Account',principal.subject); - const scope = c.req.method !== 'POST' ? null : c.req.path === '/api/jobs' ? 'split' - : !c.env.assistantTransport && /^\/api\/jobs\/[^/]+\/(?:guide|chat)$/.test(c.req.path) ? 'guide' : null; - if (scope && principal) { - const reservation = await reserveDailyRequest(c.env.DB, principal.subject, scope); - if (!reservation.allowed) { - c.header('Retry-After', String(reservation.retryAfter)); - return c.json({ - error: scope === 'split' ? `You have used today's ${reservation.limit} runs. Your daily limit resets at midnight UTC.` - : 'The daily guide allowance has been reached. Please try again tomorrow.', - code: scope === 'split' ? 'split_daily_limit' : 'guide_daily_limit', - limit: reservation.limit, resetsAt: reservation.resetsAt, - }, 429); - } - } } else if (c.req.path.startsWith('/api/teacher/') && !['GET', 'HEAD'].includes(c.req.method)) { if (c.req.header('origin') && !validWriteOrigin(c.req.raw, c.env)) return c.json({ error: 'Request origin not allowed' }, 403); } @@ -205,10 +206,34 @@ app.use('/api/*', async (c, next) => { app.route('/api/classroom', classroomRoutes); +app.post('/api/usage-events',async c=>{ + const principal=c.get('principal'); + if(!principal||c.env.AUTH_MODE!=='cail')return c.json({error:'Sign in to continue.'},401); + // This opaque expectation grants no authority. It prevents a queued batch + // from being attributed to the next cookie's account after a browser switch. + if(c.req.header('X-Stem-Usage-Actor')!==principal.subject)return c.json({error:'The account changed. Discard these observations.'},409); + const parsed=await boundedJson(c,8192);if('response'in parsed)return parsed.response; + const events=parseClientUses(parsed.value);if(!events)return c.json({error:'Invalid usage event batch.'},400); + for(const jobId of new Set(events.map(e=>e.jobId).filter((id):id is string=>!!id))) { + if(!await jobPermission(c.env,principal,jobId))return c.json({error:'Job not found'},404); + } + await recordClientUses(c.env.DB,principal,events); + return c.json({accepted:true,capture:'best_effort'}); +}); +app.get('/api/admin/usage-events',async c=>{ + if(c.get('principal')?.role!=='admin')return c.json({error:'Not allowed'},403); + const days=Number(c.req.query('days')??7); + if(!Number.isInteger(days)||days<1||days>30)return c.json({error:'days must be 1–30.'},400); + return c.json(await usageSummary(c.env.DB,days)); +}); + + app.get('/api/runtime', (c) => c.json({ authMode: c.env.AUTH_MODE === 'cail' ? 'cail' : 'class-code', loginUrl: c.env.CAIL_LOGIN_URL || null, remixer: c.env.REMIXER_ENABLED === 'true', + guest: c.env.guestRuntimeReady ? {enabled:true,canStart:c.env.guestStartAllowed===true,siteKey:c.env.GUEST_TURNSTILE_SITE_KEY, + splitLimit:5,chatAvailable:c.env.guestChatReady===true,chatLimit:c.env.guestChatReady===true?25:null} : null, })); // Explicit sharing exposes only finished audio and its title, never account @@ -261,8 +286,12 @@ app.get('/api/account', async (c) => { if (!principal) return c.json({ account: null }, 401); // Usage is informational; an unavailable count must not hide a valid account. let allowance = null; - try { allowance = await splitAllowance(c.env.DB, principal.subject); } catch { /* Report unknown, not zero. */ } - return c.json({ account: principal, splitAllowance: allowance }); + try { allowance = await splitAllowance(c.env.DB, principal.subject,new Date(),principal.quotaClass ?? 'member'); } catch { /* Report unknown, not zero. */ } + let chatAllowance = null; + const chatAvailable=!guestPrincipal(principal)||c.env.guestChatReady===true; + if(chatAvailable)try { chatAllowance = await operationAllowance(c.env.DB, principal.subject, 'chat',new Date(),principal.quotaClass ?? 'member'); } catch {} + return c.json({ account: principal, splitAllowance: allowance, chatAllowance, + ...(guestPrincipal(principal)?{guest:{expiresAt:c.env.guestSession?.expiresAt,chatAvailable,verificationRequired:c.env.guestSession?.verifiedDay!==dailyWindow().day}}:{}) }); }); app.get('/api/model-quota', async (c) => { @@ -373,8 +402,14 @@ app.post('/api/uploads', requireClassCode, async (c) => { const key = `uploads/${crypto.randomUUID()}/${filename}`; const uploadUrl = await presignUpload(c.env, key); const principal = c.get('principal'); - if (principal) await c.env.DB.prepare('INSERT INTO upload_owners (object_key, subject, expires_at) VALUES (?, ?, ?)') - .bind(key, principal.subject, new Date(Date.now() + 3600000).toISOString()).run(); + if (guestPrincipal(principal)) { + const inserted=await c.env.DB.prepare(`INSERT INTO guest_upload_owners(object_key,subject,expires_at) + SELECT ?,?,? WHERE (SELECT COUNT(*) FROM guest_upload_owners WHERE subject=? AND created_at>=date('now'))<15 + AND (SELECT COUNT(*) FROM guest_upload_owners WHERE subject=? AND state IN ('issued','uploading') AND expires_at>?)<3`) + .bind(key,principal!.subject,new Date(Date.now()+3600000).toISOString(),principal!.subject,principal!.subject,new Date().toISOString()).run(); + if (!inserted.meta.changes) throw new OperationError('guest_upload_limit',429,'The guest upload protection limit has been reached. Wait for pending uploads to expire or use CUNY Login.',3600); + } else if (principal) await c.env.DB.prepare('INSERT INTO upload_owners (object_key, subject, expires_at) VALUES (?, ?, ?)') + .bind(key, principal.subject, new Date(Date.now() + 3600000).toISOString()).run(); return c.json({ key, uploadUrl }); }); @@ -385,7 +420,7 @@ app.put('/api/local-uploads/*', requireClassCode, async (c) => { const key = localObjectKey(c.req.url, '/api/local-uploads/'); if (!key?.startsWith('uploads/')) return c.text('Not found', 404); const principal = c.get('principal'); - if (principal && !(await c.env.DB.prepare('SELECT object_key FROM upload_owners WHERE object_key = ? AND subject = ? AND expires_at > ?') + if (principal && !(await c.env.DB.prepare(`SELECT object_key FROM ${ownershipTable(principal,'upload')} WHERE object_key = ? AND subject = ? AND expires_at > ?`) .bind(key, principal.subject, new Date().toISOString()).first())) return c.text('Not found', 404); const contentLength = c.req.header('content-length'); @@ -405,7 +440,7 @@ app.put('/api/local-uploads/*', requireClassCode, async (c) => { if (!c.req.raw.body) return c.json({ error: 'Upload body is required' }, 400); if (principal) { - const claim = await c.env.DB.prepare("UPDATE upload_owners SET state = 'uploading' WHERE object_key = ? AND subject = ? AND state = 'issued' AND expires_at > ?") + const claim = await c.env.DB.prepare(`UPDATE ${ownershipTable(principal,'upload')} SET state = 'uploading' WHERE object_key = ? AND subject = ? AND state = 'issued' AND expires_at > ?`) .bind(key, principal.subject, new Date().toISOString()).run(); if (!claim.meta.changes) return c.json({ error: 'This upload has already been used. Choose the file again.' }, 409); } @@ -418,7 +453,7 @@ app.put('/api/local-uploads/*', requireClassCode, async (c) => { await c.env.AUDIO.delete(key); return c.json({ error: 'Upload size did not match Content-Length' }, 400); } - if (principal) await c.env.DB.prepare("UPDATE upload_owners SET state = 'ready' WHERE object_key = ? AND subject = ? AND state = 'uploading'") + if (principal) await c.env.DB.prepare(`UPDATE ${ownershipTable(principal,'upload')} SET state = 'ready' WHERE object_key = ? AND subject = ? AND state = 'uploading'`) .bind(key, principal.subject).run(); return c.body(null, 204); }); @@ -463,7 +498,7 @@ function ensureTeachersSeeded(c: Context): Promise { async function currentTeacher(c: Context) { if (c.env.AUTH_MODE === 'cail') { const principal = c.get('principal'); - return principal && principal.role !== 'student' ? { username: principal.subject, displayName: principal.displayName } : null; + return principal && ['instructor','admin'].includes(principal.role) ? { username: principal.subject, displayName: principal.displayName } : null; } await ensureTeachersSeeded(c); return resolveSession(c.env, readSessionCookie(c.req.header('Cookie'))); @@ -910,6 +945,12 @@ function archiveErrorResponse(c: Context, err: unknown, fallback: st app.post('/api/jobs', requireClassCode, async (c) => { const parsed = await boundedJson(c, MAX_JOB_JSON_BYTES); if ('response' in parsed) return parsed.response; + if (c.env.AUTH_MODE === 'cail' && c.get('principal')) { + const result=await submitSplit(c.env,c.get('principal')!,parsed.value,c.req.header('Idempotency-Key')); + try { c.executionCtx.waitUntil(runReliableJobs(c.env)); } catch { await runReliableJobs(c.env); } + const row=await c.env.DB.prepare('SELECT * FROM jobs WHERE id=?').bind(result.operation.job_id).first(); + return c.json({...jobResponse(row!), savedToAccount:true, operationId:result.operation.id, replay:!result.created}, result.created?202:200); + } const body = parsed.value as | { coursePolicy?: string; @@ -1219,7 +1260,7 @@ app.get('/api/jobs', async (c) => { } const { results } = await c.env.DB.prepare(` SELECT j.id, j.filename, j.model, j.created_at - FROM job_owners o JOIN jobs j ON j.id = o.job_id + FROM ${ownershipTable(principal,'job')} o JOIN jobs j ON j.id = o.job_id WHERE o.subject = ? AND j.created_at > datetime('now', '-90 days') AND (? IS NULL OR j.created_at < ? OR (j.created_at = ? AND j.id < ?)) ORDER BY j.created_at DESC, j.id DESC LIMIT 41 @@ -1233,8 +1274,18 @@ app.get('/api/jobs', async (c) => { nextCursor: results.length > 40 && last ? btoa(JSON.stringify({ createdAt: last.created_at, id: last.id })) : null }); }); +app.post('/api/jobs/:id/cancel', requireClassCode, async (c) => { + const principal=c.get('principal'); + if (!principal || !await cancelOperation(c.env.DB,c.req.param('id'),principal.subject)) return c.json({error:'Split not found.'},404); + return c.json({ok:true}); +}); + app.get('/api/jobs/:id', async (c) => { const id = c.req.param('id'); + if(c.env.AUTH_MODE==='cail') { + const operation=await readOperation(c.env.DB,id); + if(operation) { await reconcileSplit(c.env,operation,ingestResult); try {c.executionCtx.waitUntil(runReliableJobs(c.env));} catch {} } + } let row = await c.env.DB.prepare('SELECT * FROM jobs WHERE id = ?').bind(id).first(); if (!row) return c.json({ error: 'Job not found' }, 404); @@ -1252,7 +1303,7 @@ app.get('/api/jobs/:id', async (c) => { // Reconciliation fallback: if we're still 'processing', poll the provider // directly in case the completion webhook was missed (also makes local // dev work, where webhooks can't reach us). - if (row.status === 'processing' && row.external_id) { + if (row.status === 'processing' && row.external_id && !(c.env.AUTH_MODE==='cail' && await readOperation(c.env.DB,id))) { try { const result = await getBackend(c.env).fetchStatus(row.external_id); if (result.status !== 'processing') { @@ -1275,7 +1326,8 @@ app.get('/api/jobs/:id', async (c) => { const publicSharing = c.env.AUTH_MODE === 'cail' && Boolean(await c.env.DB.prepare('SELECT 1 FROM public_split_links WHERE job_id=?').bind(id).first()); const principal = c.get('principal'); const permission = principal ? await jobPermission(c.env, principal, id) : null; - return c.json({ ...jobResponse(row, results ?? [], guide), annotations: (results ?? []).map(a => ({ id:a.id,atSeconds:a.at_seconds,text:a.text, + const operation=c.env.AUTH_MODE==='cail'?await readOperation(c.env.DB,id):null; + return c.json({ ...jobResponse(row, results ?? [], guide), operation:operation?{id:operation.id,stage:operation.state,cancelRequested:!!operation.cancel_requested,supportId:operation.id}:null, annotations: (results ?? []).map(a => ({ id:a.id,atSeconds:a.at_seconds,text:a.text, authorName:a.author_subject ? (a.author_name || 'Student') : 'Author unavailable',canDelete:!principal || a.author_subject===principal.subject,provenance:a.provenance??null })), courseId:permission?.courseId??null, permissions:permission, publicSharing, attribution: attribution ? JSON.parse(attribution.attribution) : null }); }); @@ -1746,12 +1798,23 @@ app.post('/api/jobs/:id/guide', requireClassCode, async (c) => { .bind(id) .all(); + const principal=c.get('principal'); + const cachedGuide=await getGuide(c.env,id); + if(cachedGuide)return sseResponse(c,async emit=>{await emit({type:'done',text:cachedGuide.text,model:cachedGuide.model,createdAt:cachedGuide.createdAt,cached:true,finishReason:'stop'});}); + const scope=await jobCourse(c.env,id); + const prompt=await coursePrompt(c.env,scope?.course_id ?? null); + const guideKey=await fingerprint([id,scope?.course_id ?? null,prompt.revision,await hashSystemPromptFingerprint(prompt.amendment),Math.floor(Date.now()/300000)]); + const reservation=principal?await reserveAssistant(c.env,principal.subject,scope?.course_id ?? null,'guide',guideKey,id,{guideKey},principal.quotaClass ?? 'member'):null; + const receipt=reservation?assistantReceipt(c.env,reservation.operation):null; return sseResponse(c, async (emit, signal) => { - const { guide, cached } = await streamGuide( - { ...c.env, ASSISTANT_ABORT_SIGNAL: signal }, row, results ?? [], parseDuration(body?.durationSec), - (text) => emit({ type: 'delta', text }) - ); - await emit({ type: 'done', text: guide.text, model: guide.model, createdAt: guide.createdAt, cached, finishReason: 'stop' }); + try { + const { guide, cached } = await streamGuide( + { ...c.env, ASSISTANT_ABORT_SIGNAL: signal,ASSISTANT_OPERATION_ID:reservation?.operation.id,assistantEffectIntent:()=>receipt?.effect() ?? Promise.resolve() }, row, results ?? [], parseDuration(body?.durationSec), + async text => {await receipt?.delta(text);await emit({type:'delta',text});} + ); + await receipt?.complete(guide.text); + await emit({ type: 'done', text: guide.text, model: guide.model, createdAt: guide.createdAt, cached, finishReason: 'stop' }); + } catch(error) {await receipt?.failed();throw error;} }); }); @@ -1770,16 +1833,16 @@ app.get('/api/jobs/:id/listening-conversation', async (c) => { } const row = await c.env.DB.prepare(` SELECT conv.entries, conv.revision, conv.expires_at AS expiresAt - FROM listening_conversations conv + FROM ${conversationTable(principal)} conv JOIN jobs j ON j.id = conv.job_id - JOIN job_owners o ON o.job_id = j.id + JOIN ${ownershipTable(principal,'job')} o ON o.job_id = j.id WHERE conv.job_id = ? AND conv.subject = ? AND o.subject = ? AND j.created_at > datetime('now', '-90 days') AND conv.expires_at > datetime('now') `).bind(id, principal.subject, principal.subject).first<{ entries: string; revision: number; expiresAt: string }>(); if (row) return c.json({ entries: JSON.parse(row.entries), revision: row.revision, expiresAt: row.expiresAt }); const job = await c.env.DB.prepare(`SELECT datetime(created_at, '+90 days') AS expiresAt FROM jobs WHERE id = ? AND created_at > datetime('now', '-90 days') AND EXISTS ( - SELECT 1 FROM job_owners WHERE job_id = jobs.id AND subject = ? + SELECT 1 FROM ${ownershipTable(principal,'job')} WHERE job_id = jobs.id AND subject = ? )`).bind(id, principal.subject).first<{ expiresAt: string }>(); if (!job) return c.json({ error: 'Split not found.' }, 404); return c.json({ entries: [], revision: 0, expiresAt: job.expiresAt }); @@ -1806,19 +1869,19 @@ app.put('/api/jobs/:id/listening-conversation', async (c) => { } const id = c.req.param('id'); const result = await c.env.DB.prepare(` - INSERT INTO listening_conversations (job_id, subject, entries, revision, expires_at) + INSERT INTO ${conversationTable(principal)} (job_id, subject, entries, revision, expires_at) SELECT j.id, ?, ?, 1, datetime(j.created_at, '+90 days') FROM jobs j - JOIN job_owners o ON o.job_id = j.id AND o.subject = ? + JOIN ${ownershipTable(principal,'job')} o ON o.job_id = j.id AND o.subject = ? WHERE j.id = ? AND j.created_at > datetime('now', '-90 days') - AND (? = 0 OR EXISTS (SELECT 1 FROM listening_conversations prior WHERE prior.job_id = j.id AND prior.subject = ?)) + AND (? = 0 OR EXISTS (SELECT 1 FROM ${conversationTable(principal)} prior WHERE prior.job_id = j.id AND prior.subject = ?)) ON CONFLICT(job_id, subject) DO UPDATE SET entries = excluded.entries, - revision = listening_conversations.revision + 1, updated_at = datetime('now') - WHERE listening_conversations.revision = ? AND listening_conversations.expires_at > datetime('now') + revision = ${conversationTable(principal)}.revision + 1, updated_at = datetime('now') + WHERE ${conversationTable(principal)}.revision = ? AND ${conversationTable(principal)}.expires_at > datetime('now') RETURNING revision, expires_at AS expiresAt `).bind(principal.subject, JSON.stringify(entries), principal.subject, id, Number(body.revision), principal.subject, Number(body.revision)) .first<{ revision: number; expiresAt: string }>(); if (result) return c.json({ ok: true, revision: result.revision, expiresAt: result.expiresAt }); - const owned = await c.env.DB.prepare(`SELECT 1 FROM jobs j JOIN job_owners o ON o.job_id = j.id + const owned = await c.env.DB.prepare(`SELECT 1 FROM jobs j JOIN ${ownershipTable(principal,'job')} o ON o.job_id = j.id WHERE j.id = ? AND o.subject = ? AND j.created_at > datetime('now', '-90 days')`) .bind(id, principal.subject).first(); if (!owned) return c.json({ error: 'Split not found.' }, 404); @@ -1835,13 +1898,13 @@ app.delete('/api/jobs/:id/listening-conversation', async (c) => { catch(error) { if(error instanceof CourseError)return courseErrorResponse(error);throw error; } } const result = await c.env.DB.prepare(` - INSERT INTO listening_conversations (job_id, subject, entries, revision, expires_at) + INSERT INTO ${conversationTable(principal)} (job_id, subject, entries, revision, expires_at) SELECT j.id, ?, '[]', 1, datetime(j.created_at, '+90 days') FROM jobs j - JOIN job_owners o ON o.job_id = j.id AND o.subject = ? + JOIN ${ownershipTable(principal,'job')} o ON o.job_id = j.id AND o.subject = ? WHERE j.id = ? AND j.created_at > datetime('now', '-90 days') ON CONFLICT(job_id, subject) DO UPDATE SET entries = '[]', - revision = listening_conversations.revision + 1, updated_at = datetime('now') - WHERE listening_conversations.expires_at > datetime('now') + revision = ${conversationTable(principal)}.revision + 1, updated_at = datetime('now') + WHERE ${conversationTable(principal)}.expires_at > datetime('now') RETURNING revision `).bind(principal.subject, principal.subject, id).first<{ revision: number }>(); if (!result) return c.json({ error: 'Split not found.' }, 404); @@ -1882,29 +1945,50 @@ app.post('/api/jobs/:id/chat', requireClassCode, async (c) => { .all(); const course = await jobCourse(c.env,id); + const principal=c.get('principal'); + if(course && (!Number.isSafeInteger(body?.revision) || Number(body?.revision)<0)) return c.json({error:'Reload this conversation before sending.'},400); + const inputKey=typeof body?.messageId==='string'?body.messageId:c.req.header('Idempotency-Key') ?? ''; + const reservation=principal?await reserveAssistant(c.env,principal.subject,course?.course_id ?? null,'chat',inputKey,id, + [id,course?.course_id ?? null,course?[turns.at(-1),body?.revision]:turns,parseDuration(body?.durationSec)],principal.quotaClass ?? 'member'):null; + const receipt=reservation?assistantReceipt(c.env,reservation.operation):null; let durable: Awaited> | null = null; if (course) { - try { durable = await beginCourseTurn(c.env,id,c.get('principal')!,turns.at(-1)!.content,body?.messageId,body?.revision); } - catch(error) { if(error instanceof CourseError)return courseErrorResponse(error);throw error; } + try { durable = await beginCourseTurn(c.env,id,c.get('principal')!,turns.at(-1)!.content,body?.messageId,body?.revision);receipt?.bindCourseClaim(durable.claimId); } + catch(error) {await receipt?.failed();if(error instanceof CourseError)return courseErrorResponse(error);throw error;} } return sseResponse(c, async (emit, signal) => { - if (durable?.replay) { - await emit({type:'done',text:durable.replay.reply??'',finishReason:durable.replay.finish_reason??'stop',revision:durable.replay.revision,replayed:true});return; - } let streamed = ''; - let result; - try { result = await streamChat( - { ...c.env, ASSISTANT_ABORT_SIGNAL: signal }, row, results ?? [], durable?.turns ?? turns, parseDuration(body?.durationSec), - (text) => { streamed += text; return emit({ type: 'delta', text }); } - ); } catch(error) { - if(durable) await finishCourseTurn(c.env,id,c.get('principal')!.subject,durable.turnId,durable.claimId,{reply:streamed,toolCalls:[],finishReason:'error'},true); - throw error; + try { + if(durable?.replay) {await receipt?.failed();throw new AssistantError(503,'This input was already accepted. Reload the conversation before continuing.');} + const result = await streamChat( + { ...c.env, ASSISTANT_ABORT_SIGNAL: signal,ASSISTANT_OPERATION_ID:reservation?.operation.id }, row, results ?? [], durable?.turns ?? turns, parseDuration(body?.durationSec), + async text => { + if(durable && !await c.env.DB.prepare(`SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')`) + .bind(id,principal!.subject,durable.claimId).first()) throw new AssistantError(503,'This conversation changed. Refresh before continuing.'); + streamed+=text;await receipt?.delta(text);await emit({type:'delta',text}); + } + ); + await receipt?.effect(); + const revision = durable ? await finishCourseTurn(c.env,id,principal!.subject,durable.turnId,durable.claimId,result,false,principal!.displayName,reservation?.operation.id) : undefined; + if(durable && revision===null) throw new AssistantError(503,'This conversation changed. Refresh before continuing.'); + // A personal note also has a durable operation/call identity. Completion + // replay cannot add it twice. Course notes are fenced in finishCourseTurn. + if(principal && reservation && !durable) { + for(const [index,call] of result.toolCalls.entries()) if(call.name==='add_note') { + await c.env.DB.prepare(`INSERT OR IGNORE INTO annotations(id,job_id,at_seconds,text,author_subject,author_name,provenance) + SELECT ?,?,?,?,?,?,'server-assistant' WHERE EXISTS(SELECT 1 FROM app_operations WHERE id=? AND state='running' AND lease_until>?)`) + .bind(`${reservation.operation.id}-${index}`,id,Number(call.args.seconds),String(call.args.text),principal.subject,principal.displayName,reservation.operation.id,Date.now()).run(); + } + } + const calls=principal?result.toolCalls.filter(call=>call.name!=='add_note'):result.toolCalls; + await receipt?.complete(result.reply); + if(calls.length)await emit({type:'tool_calls',calls,operationId:reservation?.operation.id}); + await emit({type:'done',text:result.reply,finishReason:result.finishReason,operationId:reservation?.operation.id, + ...(revision===undefined?{}:{revision}),notesChanged:!!principal&&result.toolCalls.some(call=>call.name==='add_note')}); + } catch(error) { + if(durable)await finishCourseTurn(c.env,id,principal!.subject,durable.turnId,durable.claimId,{reply:streamed,toolCalls:[],finishReason:'error'},true,principal!.displayName,reservation?.operation.id); + await receipt?.failed();throw error; } - const revision = durable ? await finishCourseTurn(c.env,id,c.get('principal')!.subject,durable.turnId,durable.claimId,result,false,c.get('principal')!.displayName) : undefined; - if(durable && revision===null) throw new AssistantError(503,'This conversation changed. Refresh before continuing.'); - const calls = durable ? result.toolCalls.filter(call => call.name !== 'add_note') : result.toolCalls; - if (calls.length) await emit({ type: 'tool_calls', calls }); - await emit({ type: 'done', text: result.reply, finishReason: result.finishReason, ...(revision===undefined?{}:{revision,notesChanged:result.toolCalls.some(call=>call.name==='add_note')}) }); }); }); @@ -1915,6 +1999,25 @@ app.post('/api/webhooks/separation', async (c) => { const jobId = c.req.query('job'); if (!(await equalSecret(token, c.env.WEBHOOK_SECRET))) return c.text('Forbidden', 403); if (!jobId) return c.text('Missing job', 400); + if(c.env.AUTH_MODE==='cail') { + let operation=await readOperation(c.env.DB,jobId); + if(operation) { + // A delayed import callback must never recover a separator prediction. + if(c.req.query('phase')!==operation.phase) {await c.req.raw.body?.cancel().catch(()=>undefined);return c.json({ok:true});} + if(!operation.provider_id) { + const parsed=await boundedJson(c,MAX_WEBHOOK_JSON_BYTES); + const value=parsed.value as {id?:unknown}|undefined; + if(value && typeof value.id==='string') await recoverCallback(c.env,operation,value.id); + operation=await readOperation(c.env.DB,jobId); + } else await c.req.raw.body?.cancel().catch(()=>undefined); + if(operation?.provider_id) { + await c.env.DB.prepare(`UPDATE app_operations SET not_before=MAX(not_before,COALESCE((SELECT until_ms FROM provider_cooldowns WHERE provider='replicate'),0)) WHERE id=?`).bind(operation.id).run(); + await reconcileSplit(c.env,operation,ingestResult); + } + try {c.executionCtx.waitUntil(runReliableJobs(c.env));} catch {} + return c.json({ok:true}); + } + } const row = await c.env.DB.prepare('SELECT * FROM jobs WHERE id = ?').bind(jobId).first(); if (!row) return c.text('Unknown job', 404); @@ -1972,16 +2075,21 @@ app.notFound((c) => c.json({ error: 'Not found' }, 404)); app.onError((_error, c) => { if(_error instanceof CourseError)return courseErrorResponse(_error); + if (_error instanceof OperationError) { + if(_error.retryAfter)c.header('Retry-After',String(_error.retryAfter)); + return c.json({error:_error.message,code:_error.code,resetsAt:dailyWindow().resetsAt},_error.status); + } console.error(JSON.stringify({ event: 'request_failed', method: c.req.method })); return c.json({ error: 'The service is temporarily unavailable. Please try again.' }, 503); }); export default app; +export async function runReliableJobs(env: Env) { return drainSplitQueue(env,ingestResult); } // --- helpers ------------------------------------------------------------ /** Download finished stems from the provider and store them in R2. */ -async function ingestResult(env: Env, jobId: string, result: SeparationResult): Promise { +async function ingestResult(env: Env, jobId: string, result: SeparationResult, operation?: Operation): Promise { if (result.status !== 'failed' && result.status !== 'succeeded') return; const job = await env.DB.prepare('SELECT model FROM jobs WHERE id = ?') @@ -1996,9 +2104,10 @@ async function ingestResult(env: Env, jobId: string, result: SeparationResult): // so a later poll can recover if this Worker dies before releasing the claim. const lease = `${INGEST_LEASE_PREFIX}${Date.now()}:${crypto.randomUUID()}`; const claim = await env.DB.prepare( - "UPDATE jobs SET status = 'ingesting', error = ? WHERE id = ? AND status = 'processing'" + `UPDATE jobs SET status = 'ingesting', error = ? WHERE id = ? AND status = 'processing' + AND (? IS NULL OR EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND lease_owner IS ? AND lease_until>?))` ) - .bind(lease, jobId) + .bind(lease, jobId,operation?.id??null,operation?.id??null,operation?.fence??null,operation?.lease_owner??null,Date.now()) .run(); if (!claim.meta.changes) return; @@ -2036,51 +2145,57 @@ async function ingestResult(env: Env, jobId: string, result: SeparationResult): throw new InvalidStemAudioError('The separator returned an unsupported audio address'); } } - const audio = await downloadStem(stem.name, stem.url); - const key = `stems/${jobId}/${stem.name}.mp3`; + const audio = await downloadStem(stem.name, stem.url,env.AUTH_MODE==='cail'); + if(env.AUTH_MODE==='cail' && (!env.validateStemAudio || !await env.validateStemAudio(audio))) throw new InvalidStemAudioError('The separator returned audio that could not be decoded. No successful-split allowance was used.'); + const key = `stems/${jobId}/${lease.split(':').at(-1)}/${stem.name}.mp3`; await env.AUDIO.put(key, audio, { httpMetadata: { contentType: 'audio/mpeg' }, }); stored.push({ name: stem.name, key }); } - await env.DB.prepare( - "UPDATE jobs SET status = ?, stems = ?, error = NULL WHERE id = ? AND status = 'ingesting' AND error = ?" + const committed = await env.DB.prepare( + `UPDATE jobs SET status = ?, stems = ?, error = NULL WHERE id = ? AND status = 'ingesting' AND error = ? + AND (? IS NULL OR EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND lease_owner IS ?))` ) - .bind('done', JSON.stringify(stored), jobId, lease) + .bind('done', JSON.stringify(stored), jobId, lease,operation?.id??null,operation?.id??null,operation?.fence??null,operation?.lease_owner??null) .run(); + if (!committed.meta.changes) await Promise.allSettled(stored.map(({key})=>env.AUDIO.delete(key))); } catch (error) { - const cleanup = await Promise.allSettled(stored.map(({ key }) => env.AUDIO.delete(key))); - if (cleanup.some((result) => result.status === 'rejected')) { - console.error('failed to remove partial stem files', { jobId }); - } - if (error instanceof InvalidStemAudioError) { - await env.DB.prepare( - "UPDATE jobs SET status = 'failed', error = ? WHERE id = ? AND status = 'ingesting' AND error = ?" - ) - .bind(error.message, jobId, lease) - .run(); - return; - } - // Let a provider retry or the next browser poll make another attempt. - await env.DB.prepare( - "UPDATE jobs SET status = 'processing', error = NULL WHERE id = ? AND status = 'ingesting' AND error = ?" - ) - .bind(jobId, lease) - .run(); + // A failed acknowledgement does not prove a failed commit. First fence the + // old ingestion lease, then inspect durable references before any deletion. + // If either database read/write is uncertain, leave objects for recovery. + try { + const snapshot=await env.DB.prepare('SELECT status,stems,error FROM jobs WHERE id=?').bind(jobId).first<{status:string;stems:string|null;error:string|null}>(); + const referenced=(row:typeof snapshot)=>new Set(row?.stems?JSON.parse(row.stems).map((stem:{key:string})=>stem.key):[]); + if(stored.some(({key})=>referenced(snapshot).has(key)))return; + const failure=error instanceof InvalidStemAudioError?error.message:'The finished audio could not be saved. No successful-split allowance was used.'; + const terminal=error instanceof InvalidStemAudioError||env.AUTH_MODE==='cail'; + await env.DB.prepare(`UPDATE jobs SET status=?,error=? WHERE id=? AND status='ingesting' AND error=? + AND (? IS NULL OR EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND lease_owner IS ?))`) + .bind(terminal?'failed':'processing',terminal?failure:null,jobId,lease,operation?.id??null,operation?.id??null,operation?.fence??null,operation?.lease_owner??null).run(); + const durable=await env.DB.prepare('SELECT status,stems,error FROM jobs WHERE id=?').bind(jobId).first<{status:string;stems:string|null;error:string|null}>(); + if(durable?.status==='ingesting'&&durable.error===lease)return; + const keep=referenced(durable); + const cleanup=await Promise.allSettled(stored.filter(({key})=>!keep.has(key)).map(({key})=>env.AUDIO.delete(key))); + if(cleanup.some(result=>result.status==='rejected'))console.error(JSON.stringify({event:'partial_stem_cleanup_failed',jobId})); + if(terminal)return; + }catch {return;} throw error; } } class InvalidStemAudioError extends Error {} -async function downloadStem(name: string, url: string): Promise { +async function downloadStem(name: string, url: string, strict = false): Promise { let lastError: Error | null = null; + const deadline=Date.now()+60000; for (let attempt = 1; attempt <= 3; attempt += 1) { + if(strict&&Date.now()>=deadline)throw lastError??new Error('Audio download deadline expired'); let response: Response; try { - response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(20000) }); + response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(strict?Math.max(1,Math.min(20000,deadline-Date.now())):20000) }); } catch (error) { lastError = error instanceof Error @@ -2095,15 +2210,15 @@ async function downloadStem(name: string, url: string): Promise { if (response.ok) { try { const audio = await readBoundedResponse(response, { - maximumBytes: 32 * 1024 * 1024, - timeoutMs: 30000, + maximumBytes: (strict?24:32) * 1024 * 1024, + timeoutMs: strict?Math.max(1,Math.min(30000,deadline-Date.now())):30000, errors: { tooLarge: () => new InvalidStemAudioError('The separator returned an oversized track'), timedOut: () => new Error('Audio download timed out'), unreadable: () => new Error('Audio download could not be read'), }, }); - if (!looksLikeMp3(audio)) { + if (!(strict ? validMp3Frames(audio) : looksLikeMp3(audio))) { lastError = new InvalidStemAudioError( `The "${name}" track was empty or was not a playable MP3` ); @@ -2134,7 +2249,10 @@ async function downloadStem(name: string, url: string): Promise { if (response.status !== 429 && response.status < 500) throw lastError; if (attempt < 3) { - await new Promise((resolve) => setTimeout(resolve, attempt * 100)); + const wait=strict&&response.status===429?Math.max(0,retryAt(response.headers.get('retry-after'))-Date.now()):attempt*100; + // A safe GET may stop retrying; it may never retry before the full floor. + if(strict&&Date.now()+wait>=deadline)throw lastError; + await new Promise((resolve) => setTimeout(resolve,wait)); } } diff --git a/src/reliability/assistant.ts b/src/reliability/assistant.ts new file mode 100644 index 0000000..34bbc48 --- /dev/null +++ b/src/reliability/assistant.ts @@ -0,0 +1,60 @@ +import type { Env } from '../env.ts'; +import { fingerprint, OperationError, reserveOperation, settleOperation, type Operation } from './ledger.ts'; +import type { QuotaClass } from '../daily-allowance.ts'; + +/** One reservation surrounds the human message, tools, and both model attempts. + * This ledger contains no transcript text. Conversation storage owns readback. */ +export async function reserveAssistant(env: Env, subject: string, courseId: string | null, kind: 'chat'|'guide', key: string, jobId: string, input: unknown, quotaClass: QuotaClass = 'member') { + const reserved=await reserveOperation(env.DB,{subject,quotaClass,courseId,kind,key,jobId,fingerprint:await fingerprint(input),phase:kind,request:{}}); + const op=reserved.operation; + if(!reserved.created) { + throw new OperationError('input_already_submitted',409,op.state==='failed'?'This input did not complete. Send a new message to try again.':op.error_code==='delivery_uncertain'?'A reply may have started before the connection ended. This counts as one input. Reload the conversation before continuing.':'This input was already accepted. Reload the conversation before continuing.'); + } + await env.DB.prepare("UPDATE app_operations SET state='running',lease_until=?,updated_at=? WHERE id=? AND state='queued'") + .bind(Date.now()+90000,Date.now(),op.id).run(); + return {operation:op,replay:false}; +} +export function assistantReceipt(env: Env,operation: Operation) { + let delivered=false,courseClaim:string|null=null; + const mark=async(delivery:'partial'|'effect_intent')=>{ + const result=await env.DB.prepare(`UPDATE app_operations SET result_json=?,updated_at=? WHERE id=? AND state='running' AND lease_until>? + AND (course_id IS NULL OR kind<>'chat' OR EXISTS(SELECT 1 FROM course_conversations c + WHERE c.job_id=app_operations.job_id AND c.subject=app_operations.subject AND c.pending_turn=? + AND c.pending_expires_at>datetime('now') AND c.expires_at>datetime('now')))`) + .bind(JSON.stringify({delivery}),Date.now(),operation.id,Date.now(),courseClaim).run(); + if(!result.meta.changes)throw new OperationError('input_no_longer_active',409,'This input ended. Reload the conversation before continuing.'); + delivered=true; + }; + return { + bindCourseClaim(claimId:string) {courseClaim=claimId;}, + /** Persist BEFORE handing output to the response writer. A process crash + * after this point must not turn a delivered partial into a free replay. */ + async delta(value: string) { + if(value.trim())await mark('partial'); + }, + /** Persist before durable conversation/cache/tool effects, including an + * unusual provider response that supplied final content without deltas. */ + async effect() {await mark('effect_intent');}, + async complete(_reply: string) { + if(!_reply.trim()) { + await settleOperation(env.DB,operation.id,'failed','no_usable_response'); + throw new OperationError('no_usable_response',503,'The Listening Guide could not finish this reply. Please try a new message.'); + } + // Final delivery is conditional on the same live reservation. A stale + // runner must not emit tools/done after recovery has already settled it. + // For course work, reset or a new turn also invalidates the saved receipt. + const result=await env.DB.prepare(`UPDATE app_operations SET state='succeeded',error_code=NULL,result_json=?, + lease_owner=NULL,lease_until=0,updated_at=? WHERE id=? AND state='running' AND lease_until>? AND cancel_requested=0 + AND (course_id IS NULL OR kind<>'chat' OR EXISTS(SELECT 1 FROM course_turns t + JOIN course_conversations c ON c.job_id=t.job_id AND c.subject=t.subject + WHERE t.job_id=app_operations.job_id AND t.subject=app_operations.subject + AND t.turn_id=app_operations.idempotency_key AND t.state='complete' AND c.pending_turn IS NULL + AND c.revision=t.revision AND c.expires_at>datetime('now')))`) + .bind(JSON.stringify({delivery:'completed'}),Date.now(),operation.id,Date.now()).run(); + if(!result.meta.changes)throw new OperationError('input_no_longer_active',409,'This input ended. Reload the conversation before continuing.'); + }, + async failed() { + await settleOperation(env.DB,operation.id,delivered?'partial':'failed',delivered?'partial_response':'no_usable_response',{delivery:delivered?'partial':'none'}); + }, + }; +} diff --git a/src/reliability/ledger.ts b/src/reliability/ledger.ts new file mode 100644 index 0000000..1b5f092 --- /dev/null +++ b/src/reliability/ledger.ts @@ -0,0 +1,110 @@ +import { dailyWindow, allowanceLimit, type QuotaClass } from '../daily-allowance.ts'; + +export type OperationKind = 'split' | 'chat' | 'guide'; +export type Phase = 'fetch' | 'split' | 'chat' | 'guide'; +export type State = 'queued' | 'running' | 'starting' | 'processing' | 'reconciling' | 'succeeded' | 'partial' | 'failed' | 'cancelled'; +export interface Operation { + id: string; subject: string; quota_class: QuotaClass; course_id: string | null; kind: OperationKind; + idempotency_key: string; fingerprint: string; day: string; state: State; phase: Phase; + job_id: string | null; request_json: string; result_json: string | null; provider_id: string | null; + lease_owner: string | null; lease_until: number; fence: number; not_before: number; + deadline: number; cancel_requested: number; error_code: string | null; created_at: number; updated_at: number; +} +export const TERMINAL = "'succeeded','partial','failed','cancelled'"; +export const MAX_QUEUED_SPLITS = 100; +export const MAX_SPLIT_ATTEMPTS_PER_DAY = 40; +export const MAX_CHAT_ATTEMPTS_PER_DAY = 80; +export const validOperationKey = (key: unknown): key is string => typeof key === 'string' && /^[a-zA-Z0-9_-]{16,80}$/.test(key); +export class OperationError extends Error { + code: string; status: 400|404|409|429|503; retryAfter: number; + constructor(code: string, status: 400 | 404 | 409 | 429 | 503, message: string, retryAfter = 0) { super(message);this.code=code;this.status=status;this.retryAfter=retryAfter; } +} +export async function fingerprint(value: unknown): Promise { + const data = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value))); + return Array.from(new Uint8Array(data), b => b.toString(16).padStart(2, '0')).join(''); +} +export async function readOperation(db: D1Database, id: string): Promise { + return db.prepare('SELECT * FROM app_operations WHERE id=?').bind(id).first(); +} +export async function findOperation(db: D1Database, subject: string, kind: OperationKind, key: string) { + return db.prepare('SELECT * FROM app_operations WHERE subject=? AND kind=? AND idempotency_key=?').bind(subject, kind, key).first(); +} +/** The INSERT predicate and all associated job writes execute in one D1 batch. + * The unique key handles racing replays; quota includes active and settled work. + */ +export async function reserveOperation(db: D1Database, input: { + subject: string; quotaClass?: QuotaClass; courseId: string | null; kind: OperationKind; key: string; fingerprint: string; + phase: Phase; jobId?: string; request?: unknown; now?: number; + statements?: (id: string) => D1PreparedStatement[]; +}): Promise<{ operation: Operation; created: boolean }> { + if (!validOperationKey(input.key)) throw new OperationError('idempotency_required', 400, 'Reload and submit again with a request identifier.'); + const now = input.now ?? Date.now(); + const { day, retryAfter } = dailyWindow(new Date(now)); + const quotaClass = input.quotaClass ?? 'member'; + if (quotaClass === 'guest' && input.courseId !== null) throw new OperationError('guest_course_forbidden',400,'Guest work cannot be assigned to a course.'); + const limit = allowanceLimit(input.kind,quotaClass); + const abuseLimit = quotaClass === 'guest' ? input.kind === 'split' ? 15 : input.kind === 'chat' ? 50 : 10 + : input.kind === 'split' ? MAX_SPLIT_ATTEMPTS_PER_DAY : input.kind === 'chat' ? MAX_CHAT_ATTEMPTS_PER_DAY : 20; + const id = input.kind==='split' && input.jobId ? input.jobId : crypto.randomUUID(); + const insert = db.prepare(`INSERT INTO app_operations + (id,subject,quota_class,course_id,kind,idempotency_key,fingerprint,day,state,phase,job_id,request_json,deadline,created_at,updated_at) + SELECT ?,?,?,?,?,?,?,?,'queued',?,?,?,?,?,? WHERE + (SELECT COUNT(*) FROM app_operations WHERE subject=? AND kind=? AND day=? AND state NOT IN ('failed','cancelled')) < ? + AND (SELECT COUNT(*) FROM app_operations WHERE subject=? AND kind=? AND day=?) < ? + AND (? <> 'split' OR (SELECT COUNT(*) FROM app_operations WHERE kind='split' AND state NOT IN (${TERMINAL})) < ?) + AND (? <> 'guide' OR (SELECT COUNT(*) FROM app_operations WHERE kind='guide' AND day=?) < 200) + AND (? <> 'guest' OR EXISTS(SELECT 1 FROM guest_sessions WHERE subject=? AND expires_at>? AND revoked_at IS NULL AND verified_day=?)) + AND (? <> 'guest' OR ? <> 'split' OR (SELECT COUNT(*) FROM app_operations WHERE subject=? AND kind='split' AND state NOT IN (${TERMINAL})) < 2) + AND (? <> 'guest' OR ? <> 'split' OR (SELECT COUNT(*) FROM app_operations WHERE quota_class='guest' AND kind='split' AND state NOT IN (${TERMINAL})) < 10) + ON CONFLICT(subject,kind,idempotency_key) DO NOTHING`) + .bind(id,input.subject,quotaClass,input.courseId,input.kind,input.key,input.fingerprint,day,input.phase,input.jobId ?? null, + JSON.stringify(input.request ?? {}),now+24*60*60*1000,now,now,input.subject,input.kind,day,limit, + input.subject,input.kind,day,abuseLimit,input.kind,MAX_QUEUED_SPLITS,input.kind,day, + quotaClass,input.subject,now,day,quotaClass,input.kind,input.subject,quotaClass,input.kind); + await db.batch([insert,...(input.statements?.(id) ?? [])]); + const operation = await findOperation(db,input.subject,input.kind,input.key); + if (!operation) { + const counts=await db.prepare(`SELECT COUNT(*) AS attempts, + SUM(CASE WHEN state NOT IN ('failed','cancelled') THEN 1 ELSE 0 END) AS held + FROM app_operations WHERE subject=? AND kind=? AND day=?`).bind(input.subject,input.kind,day).first<{attempts:number;held:number}>(); + if ((counts?.held ?? 0)>=limit) throw new OperationError(`${input.kind}_daily_limit`,429, + input.kind==='split'?`Completed and in-progress splits fill today's ${limit} places. Failed splits do not count as completed.` + :'Today\'s Listening Guy input allowance is full.',retryAfter); + if ((counts?.attempts ?? 0)>=abuseLimit || input.kind==='guide') throw new OperationError(`${input.kind}_attempt_limit`,429, + 'Repeated requests have reached the daily protection limit. Failed splits did not use successful-split allowance. Please try again after the reset.',retryAfter); + throw new OperationError('split_queue_full',429,'The audio queue is full. Wait a minute and submit the same request again.',60); + } + if (operation.fingerprint !== input.fingerprint || operation.course_id !== input.courseId || operation.quota_class !== quotaClass) { + throw new OperationError('idempotency_conflict',409,'This request identifier belongs to different input. Submit a new request.'); + } + return { operation, created: operation.id === id && operation.created_at === now }; +} +export async function settleOperation(db: D1Database, id: string, state: 'succeeded' | 'partial' | 'failed' | 'cancelled', code: string | null = null, result: unknown = null, fence?: number) { + const update = await db.prepare(`UPDATE app_operations SET state=?,error_code=?,result_json=?,lease_owner=NULL,lease_until=0,updated_at=? + WHERE id=? AND state NOT IN (${TERMINAL}) AND (? IS NULL OR fence=?) + AND (? NOT IN ('succeeded','partial') OR cancel_requested=0)`) + .bind(state,code,result === null ? null : JSON.stringify(result),Date.now(),id,fence ?? null,fence ?? null,state).run(); + return update.meta.changes > 0; +} +export async function beginAttempt(db: D1Database, operation: Operation, provider: string, model?: string) { + const id=crypto.randomUUID(), now=Date.now(); + const inserted=await db.prepare(`INSERT INTO operation_attempts(id,operation_id,phase,provider,model,outcome,created_at,updated_at) + SELECT ?,id,phase,?,?,'starting',?,? FROM app_operations WHERE id=? AND fence=? AND state NOT IN (${TERMINAL})`) + .bind(id,provider,model ?? null,now,now,operation.id,operation.fence).run(); + if(!inserted.meta.changes)throw new OperationError('operation_fenced',409,'This request is being recovered.'); + return id; +} +export async function finishAttempt(db: D1Database, id: string, outcome: string, detail: { externalId?: string; status?: number; code?: string; usage?: unknown } = {}) { + await db.prepare('UPDATE operation_attempts SET outcome=?,external_id=COALESCE(?,external_id),http_status=?,code=?,usage_json=?,updated_at=? WHERE id=?') + .bind(outcome,detail.externalId ?? null,detail.status ?? null,safeEventCode(detail.code),numericUsage(detail.usage),Date.now(),id).run(); +} + +// Gateway usage is untrusted structured data. Preserve only numeric accounting +// fields, never arbitrary provider strings or nested response content. +function numericUsage(value:unknown) { + if(!value||typeof value!=='object'||Array.isArray(value))return null; + const fields=['prompt_tokens','completion_tokens','total_tokens','input_tokens','output_tokens','cost','cost_usd']; + const result=Object.fromEntries(fields.flatMap(key=>{const n=(value as Record)[key];return typeof n==='number'&&Number.isFinite(n)&&n>=0?[[key,n]]:[];})); + return Object.keys(result).length?JSON.stringify(result):null; +} +export const safeEventCode=(code:unknown):string|null=>typeof code==='string'&&/^[a-z][a-z0-9_]{0,63}$/.test(code)?code:null; diff --git a/src/reliability/media.ts b/src/reliability/media.ts new file mode 100644 index 0000000..f99b889 --- /dev/null +++ b/src/reliability/media.ts @@ -0,0 +1,108 @@ +/** Structural preflight only. Settlement additionally requires validateMp3Pcm + * with the trusted platform decoder; frame headers alone are insufficient. */ +export function validMp3Frames(data: ArrayBuffer): boolean { + const b=new Uint8Array(data);let offset=0,frames=0,signature=-1; + if(b.length>=10&&b[0]===73&&b[1]===68&&b[2]===51) { + if([b[6],b[7],b[8],b[9]].some(v=>v>127))return false; + offset=10+((b[6]<<21)|(b[7]<<14)|(b[8]<<7)|b[9])+(b[5]&16?10:0); + if(offset>256*1024||offset>=b.length)return false; + } + for(;offset+4<=b.length;) { + if(b.length-offset===128&&b[offset]===84&&b[offset+1]===65&&b[offset+2]===71)return frames>=2; + if(b[offset]!==255||(b[offset+1]&0xe0)!==0xe0)return false; + const version=(b[offset+1]>>3)&3,layer=(b[offset+1]>>1)&3,rateIndex=(b[offset+2]>>2)&3,bitrateIndex=b[offset+2]>>4; + if(version===1||layer!==1||rateIndex===3||bitrateIndex===0||bitrateIndex===15)return false; + const rate=[44100,48000,32000][rateIndex]/(version===3?1:version===2?2:4); + const bitrate=(version===3?[0,32,40,48,56,64,80,96,112,128,160,192,224,256,320]:[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160])[bitrateIndex]; + const current=(version<<4)|rateIndex; + if(signature!==-1&&signature!==current)return false;signature=current; + const length=Math.floor((version===3?144000:72000)*bitrate/rate)+((b[offset+2]>>1)&1); + if(length<24||offset+length>b.length)return false; + offset+=length;frames++; + } + return offset===b.length&&frames>=2; +} + +export interface PcmDecoder { + ready: Promise; + decode(bytes: Uint8Array): { errors: unknown[]; channelData: Float32Array[]; samplesDecoded: number; sampleRate: number }; + free(): void; +} + +/** Reject semantic side-info errors which tolerant decoders may conceal as + * silence. These are MPEG Layer III syntax constraints, not an audio-quality + * heuristic: a legitimate silent stem remains valid. */ +export function mp3FrameInfo(data: ArrayBuffer): { frames: number; seconds: number; sampleRate: number } | null { + if(data.byteLength>24*1024*1024||!validMp3Frames(data))return null; + const bytes=new Uint8Array(data);let offset=0,frames=0,seconds=0,availableMain=0,sampleRate=0; + if(bytes[0]===73&&bytes[1]===68&&bytes[2]===51)offset=10+((bytes[6]<<21)|(bytes[7]<<14)|(bytes[8]<<7)|bytes[9])+(bytes[5]&16?10:0); + while(offset+4<=bytes.length) { + if(bytes.length-offset===128&&bytes[offset]===84&&bytes[offset+1]===65&&bytes[offset+2]===71)break; + const version=(bytes[offset+1]>>3)&3,channels=(bytes[offset+3]>>6)===3?1:2; + sampleRate=[44100,48000,32000][(bytes[offset+2]>>2)&3]/(version===3?1:version===2?2:4); + const bitrate=(version===3?[0,32,40,48,56,64,80,96,112,128,160,192,224,256,320]:[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160])[bytes[offset+2]>>4]; + const length=Math.floor((version===3?144000:72000)*bitrate/sampleRate)+((bytes[offset+2]>>1)&1); + const sideBytes=version===3?(channels===1?17:32):(channels===1?9:17),headerBytes=4+(bytes[offset+1]&1?0:2); + if(length{let value=0;while(count--)value=(value<<1)|((bytes[bit>>3]>>(7-(bit++&7)))&1);return value;}; + const mainBegin=take(version===3?9:8); + if(mainBegin>availableMain)return null; + take(version===3?(channels===1?5:3):channels); + if(version===3)take(channels*4); + let codedBits=0; + for(let block=0;block<(version===3?2:1)*channels;block++) { + codedBits+=take(12); + if(take(9)>288)return null; + take(8);take(version===3?4:9); + if(take(1)) { + if(take(2)===0)return null; + take(1); + for(let table=0;table<2;table++)if([4,14].includes(take(5)))return null; + take(9); + } else { + for(let table=0;table<3;table++)if([4,14].includes(take(5)))return null; + if(take(4)+take(3)>20)return null; + } + if(version===3)take(1); + take(2); + } + const mainBytes=length-headerBytes-sideBytes; + if(codedBits>(mainBegin+mainBytes)*8)return null; + availableMain=Math.min(availableMain+mainBytes,version===3?511:255); + seconds+=(version===3?1152:576)/sampleRate;frames++;offset+=length; + } + return seconds<=900.1?{frames,seconds,sampleRate}:null; +} + +/** Decode the entire bounded input in small chunks and discard PCM immediately. + * The adapter supplies a pinned, locally bundled decoder, never a browser claim. + * Tolerant decoders cannot prove freedom from all corruption; syntax preflight, + * reported errors, finite PCM, expected duration and nonempty output all gate + * settlement. No inference about artistic content or silence is made. */ +export async function validateMp3Pcm(data: ArrayBuffer,create:()=>PcmDecoder): Promise { + const frames=mp3FrameInfo(data);if(!frames)return false; + let decoder:PcmDecoder|undefined; + try { + decoder=create();await decoder.ready; + const bytes=new Uint8Array(data);let samples=0; + // At most 6144 synchronous chunks, <=131072 samples/channel per chunk. + // A 15-minute limit bounds work independently of a host's frozen clock. + for(let offset=0;offset131072)return false; + if(part.samplesDecoded) { + if(part.sampleRate!==frames.sampleRate||part.channelData.length<1||part.channelData.length>2)return false; + for(const channel of part.channelData) { + if(channel.length!==part.samplesDecoded)return false; + for(const sample of channel)if(!Number.isFinite(sample))return false; + } + samples+=part.samplesDecoded; + } + } + const decoded=samples/frames.sampleRate; + // MP3 delay/padding and one metadata frame may be omitted by the decoder. + return decoded>0 && decoded<=900.1 && decoded<=frames.seconds+0.1 && frames.seconds-decoded<=0.5; + } catch {return false;} + finally {if(decoder)try {decoder.free();}catch { /* A failed decoder never settles success. */ }} +} diff --git a/src/reliability/observability.ts b/src/reliability/observability.ts new file mode 100644 index 0000000..2d5a583 --- /dev/null +++ b/src/reliability/observability.ts @@ -0,0 +1,89 @@ +import type { Env } from '../env.ts'; +import type { AppPrincipal } from '../identity.ts'; +import { fingerprint, safeEventCode } from './ledger.ts'; + +export const CLIENT_USAGE_TYPES=['session_start','page_view','playback_start','playback_stop','seek','download_intent'] as const; +type ClientType=typeof CLIENT_USAGE_TYPES[number]; +export interface ClientUse { id:string; type:ClientType; jobId?:string; durationMs?:number; positionBucket?:number; } +const identifier=(v:unknown):v is string=>typeof v==='string'&&/^[a-zA-Z0-9_-]{16,80}$/.test(v); +export function parseClientUses(value:unknown):ClientUse[]|null { + if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).some(k=>k!=='events'))return null; + const events=(value as {events?:unknown}).events; + if(!Array.isArray(events)||events.length<1||events.length>10)return null; + for(const event of events) { + if(!event||typeof event!=='object'||Array.isArray(event)||Object.keys(event).some(k=>!['id','type','jobId','durationMs','positionBucket'].includes(k)))return null; + if(!identifier(event.id)||!CLIENT_USAGE_TYPES.includes(event.type))return null; + if(event.jobId!==undefined&&(typeof event.jobId!=='string'||!/^[a-zA-Z0-9_-]{1,80}$/.test(event.jobId)))return null; + if(['playback_start','playback_stop','seek','download_intent'].includes(event.type)&&!event.jobId)return null; + if(event.durationMs!==undefined&&(!Number.isSafeInteger(event.durationMs)||event.durationMs<0||event.durationMs>86400000))return null; + if(event.positionBucket!==undefined&&(!Number.isSafeInteger(event.positionBucket)||event.positionBucket<0||event.positionBucket>90))return null; + } + return events; +} +function actorClass(principal?:AppPrincipal) { + return !principal?'anonymous':(principal as AppPrincipal&{quotaClass?:string}).quotaClass==='guest'?'guest':'member'; +} +async function actorKey(principal:AppPrincipal|undefined,now:number) { + // Rotating daily pseudonym bounds abusive logging without recording identity. + return fingerprint(['usage-actor-v1',new Date(now).toISOString().slice(0,10),principal?.subject??'anonymous']); +} +interface Use {id:string;type:string;jobId?:string;source:'client'|'server';outcome:string;code?:string;status?:number;durationMs?:number;positionBucket?:number;} +async function persist(db:D1Database,principal:AppPrincipal|undefined,events:Use[],now=Date.now()) { + const actor=await actorKey(principal,now),kind=actorClass(principal),day=Math.floor(now/86400000)*86400000; + const statements=await Promise.all(events.map(async e=>db.prepare(`INSERT OR IGNORE INTO usage_events + (event_id,actor_key,actor_class,event_type,source,job_id,outcome,code,http_status,duration_ms,position_bucket,at) + SELECT ?,?,?,?,?,?,?,?,?,?,?,? WHERE + (?='server' OR (SELECT COUNT(*) FROM usage_events WHERE actor_key=? AND source='client' AND at>=?)=?)<25000`) + .bind(await fingerprint(['usage-v1',principal?.subject??'anonymous',e.id,e.type]),actor,kind,e.type,e.source,e.jobId??null,e.outcome, + safeEventCode(e.code),e.status??null,e.durationMs??null,e.positionBucket??null,now,e.source,actor,day,kind==='guest'?250:500,day))); + await db.batch(statements); +} +export async function recordClientUses(db:D1Database,principal:AppPrincipal,events:ClientUse[]) { + await persist(db,principal,events.map(e=>({...e,source:'client',outcome:'observed'}))); +} +/** A diagnostic failure must not alter the response or expose private errors. + * Critical lifecycle/attempt receipts instead use transactional SQL triggers. */ +export async function recordServerUse(env:Env,request:Request,response:Response,principal:AppPrincipal|undefined,started:number) { + if(env.AUTH_MODE!=='cail')return; + const url=new URL(request.url),path=url.pathname; + const split=path==='/api/jobs'&&request.method==='POST'; + const chat=/^\/api\/jobs\/[^/]+\/chat$/.test(path)&&request.method==='POST'; + const guide=/^\/api\/jobs\/[^/]+\/guide$/.test(path)&&request.method==='POST'; + const annotation=/^\/api\/jobs\/[^/]+\/annotations(?:\/[^/]+)?$/.test(path)&&!['GET','HEAD'].includes(request.method); + const download=/^\/api\/(?:files\/stems\/|shared-jobs\/)/.test(path)&&url.searchParams.has('download')&&request.method==='GET'; + if(!(split||chat||guide||annotation||download))return; + // Accepted operations already have a durable created event. Only request + // rejection is added here; retries with the same operation key coalesce. + if((split||chat||guide)&&response.status<400)return; + const type=split?'split_request':chat?'chat_request':guide?'guide_request':annotation?(request.method==='DELETE'?'annotation_delete':'annotation_save'):'download'; + const supplied=request.headers.get('Idempotency-Key'); + const key=(identifier(supplied)?supplied:crypto.randomUUID())+':'+response.status; + const job=/^\/api\/(?:jobs\/|files\/stems\/|shared-jobs\/)([a-zA-Z0-9_-]{1,80})(?:\/|$)/.exec(path)?.[1]; + try { + let code:unknown; + if(response.status>=400&&response.headers.get('content-type')?.includes('application/json')) { + const data=await response.clone().json() as {code?:unknown;error?:{code?:unknown}}; + code=data.code??(typeof data.error==='object'?data.error?.code:undefined); + } + await persist(env.DB,principal,[{id:key,type,source:'server',jobId:principal?job:undefined,outcome:response.status<400?'accepted':'failed', + code:safeEventCode(code)??(response.status>=400?'request_rejected':undefined),status:response.status,durationMs:Math.max(0,Math.min(86400000,Date.now()-started))}]); + } catch {console.warn(JSON.stringify({event:'usage_record_failed',type}));} +} +export async function usageSummary(db:D1Database,days:number) { + const since=Date.now()-days*86400000; + const [uses,operations]=await Promise.all([ + db.prepare(`SELECT event_type,source,actor_class,outcome,code,http_status,COUNT(*) AS count,ROUND(AVG(duration_ms)) AS mean_duration_ms + FROM usage_events WHERE at>=? GROUP BY event_type,source,actor_class,outcome,code,http_status ORDER BY count DESC LIMIT 250`).bind(since).all(), + db.prepare(`SELECT e.event_type,o.quota_class AS actor_class,e.kind,e.phase,e.state,e.code,e.model,e.fallback,e.quota_effect, + COUNT(*) AS count,ROUND(AVG(e.duration_ms)) AS mean_duration_ms + FROM operation_events e JOIN app_operations o ON o.id=e.operation_id WHERE e.at>=? + GROUP BY e.event_type,o.quota_class,e.kind,e.phase,e.state,e.code,e.model,e.fallback,e.quota_effect ORDER BY count DESC LIMIT 250`).bind(since).all(), + ]); + return {days,retentionDays:30,clientCapture:'best_effort',uses:uses.results??[],operations:operations.results??[]}; +} +export async function recordPageUse(env:Env,response:Response) { + if(env.AUTH_MODE!=='cail')return; + try {await persist(env.DB,undefined,[{id:crypto.randomUUID(),type:'page_response',source:'server',outcome:response.ok?'accepted':'failed',status:response.status}]);} + catch {console.warn(JSON.stringify({event:'usage_record_failed',type:'page_response'}));} +} diff --git a/src/reliability/prediction-json.ts b/src/reliability/prediction-json.ts new file mode 100644 index 0000000..8167990 --- /dev/null +++ b/src/reliability/prediction-json.ts @@ -0,0 +1,51 @@ +/** Strip a single inline audio value before JSON text allocation. JSON.parse + * still validates the complete metadata document and its exact output.audio + * location. The audio bytes are decoded in small base64 chunks, never as a + * whole UTF-16 string. Provider URLs follow the normal bounded download path. */ +export function parsePredictionJson(data:ArrayBuffer,maximumInlineBytes:number):{value:unknown;inlineAudio?:ArrayBuffer;inlineTooLarge?:boolean} { + const bytes=new Uint8Array(data),metadataLimit=256*1024; + const decoder=new TextDecoder('utf-8',{fatal:true,ignoreBOM:false}); + const white=(n:number)=>n===32||n===10||n===13||n===9; + let start=-1,end=-1,payload=-1; + for(let i=0;i=bytes.length)throw new Error('invalid_prediction_json'); + if(escaped||i-token!==6||decoder.decode(bytes.subarray(token+1,i))!=='audio')continue; + let next=i+1;while(white(bytes[next]))next++; + if(bytes[next++]!==58)continue;while(white(bytes[next]))next++; + if(bytes[next]!==34)continue; + let comma=next+1;while(comma=bytes.length)throw new Error('invalid_prediction_json'); + break; + } + if(start<0) { + if(bytes.length>metadataLimit)throw new Error('prediction_metadata_too_large'); + return {value:JSON.parse(decoder.decode(bytes))}; + } + if(bytes.length-(end-start+1)>metadataLimit)throw new Error('prediction_metadata_too_large'); + const length=end-payload; + if(length%4)throw new Error('invalid_prediction_base64'); + let padding=0;if(bytes[end-1]===61)padding++;if(bytes[end-2]===61)padding++; + const decodedLength=length/4*3-padding; + const marker='inline:'+crypto.randomUUID(); + const text=decoder.decode(bytes.subarray(0,start))+JSON.stringify(marker)+decoder.decode(bytes.subarray(end+1)); + const value=JSON.parse(text); + if(!value||typeof value!=='object'||value.output?.audio!==marker)throw new Error('invalid_prediction_audio_location'); + if(decodedLength>maximumInlineBytes)return {value,inlineTooLarge:true}; + const audio=new Uint8Array(decodedLength);let offset=0; + for(let at=payload;at { + const owner=crypto.randomUUID(); + // D1 serializes this statement. The subquery plus compare-and-set cannot + // oversubscribe even when unrelated Worker instances claim simultaneously. + const row=await db.prepare(`UPDATE app_operations SET state='running',lease_owner=?,lease_until=?,fence=fence+1,updated_at=? + WHERE id=(SELECT q.id FROM app_operations q + LEFT JOIN operation_fairness f ON f.subject=q.subject AND f.phase=q.phase + WHERE q.kind='split' AND q.phase=? AND q.state='queued' AND q.not_before<=? AND q.deadline>? + AND COALESCE((SELECT until_ms FROM provider_cooldowns WHERE provider='replicate'),0)<=? + AND (SELECT COUNT(*) FROM app_operations a WHERE a.kind='split' AND a.phase=q.phase AND a.state IN ('running','starting','processing','reconciling'))(); + if (row) await db.prepare(`INSERT INTO operation_fairness(subject,phase,last_started) VALUES(?,?,?) + ON CONFLICT(subject,phase) DO UPDATE SET last_started=excluded.last_started`).bind(row.subject,phase,now).run(); + return row; +} + +export async function transition(db: D1Database, operation: Operation, state: Operation['state'], changes: {phase?: Phase; providerId?: string | null; notBefore?: number; code?: string | null}={}) { + const result=await db.prepare(`UPDATE app_operations SET state=?,phase=?,provider_id=?,not_before=?,error_code=?,updated_at=? + WHERE id=? AND fence=? AND lease_owner IS ? AND state NOT IN (${TERMINAL})`) + .bind(state,changes.phase ?? operation.phase,changes.providerId === undefined ? operation.provider_id : changes.providerId, + changes.notBefore ?? 0,changes.code ?? null,Date.now(),operation.id,operation.fence,operation.lease_owner).run(); + // D1 includes audit-trigger writes in changes; SQLite's shim counts only + // the outer row. Zero is the portable losing-CAS signal. + return result.meta.changes>0; +} + +export async function recoverExpired(db: D1Database, now=Date.now()) { + // Cron and callbacks recover abandoned ingestion without a browser poll. + // The job lease is a separate output-write fence; reopening it invalidates + // a late old uploader's final CAS and preserves the known provider ID. + const ingestions=await db.prepare(`SELECT j.id,j.error FROM jobs j JOIN app_operations o ON o.job_id=j.id + WHERE j.status='ingesting' AND o.kind='split' AND o.state IN ('processing','reconciling') AND o.lease_until<=? LIMIT 100`) + .bind(now).all<{id:string;error:string|null}>(); + for(const job of ingestions.results) { + const timestamp=/^ingesting:(\d{10,16}):[a-zA-Z0-9-]+$/.exec(job.error??'')?.[1]; + if(timestamp&&Number(timestamp)+WORK_LEASE_MS<=now)await db.prepare(`UPDATE jobs SET status='processing',error=NULL + WHERE id=? AND status='ingesting' AND error=? AND EXISTS(SELECT 1 FROM app_operations WHERE job_id=? + AND state IN ('processing','reconciling') AND lease_until<=?)`).bind(job.id,job.error,job.id,now).run(); + } + + // Never blindly repeat an accepted or ambiguously accepted POST. Work that + // died before an attempt record is safe to enqueue; starts reconcile. + await db.prepare(`UPDATE app_operations SET state=CASE + WHEN EXISTS(SELECT 1 FROM operation_attempts a WHERE a.operation_id=app_operations.id AND a.phase=app_operations.phase AND a.outcome IN ('starting','accepted','uncertain')) + THEN 'reconciling' ELSE 'queued' END, + fence=fence+1,lease_owner=NULL,lease_until=0,updated_at=? + WHERE kind='split' AND state IN ('running','starting') AND lease_until'{}' OR result_json IS NOT NULL)`).bind(now,cutoff), + db.prepare(`UPDATE jobs SET status='failed',error='This split expired before it finished.' + WHERE id IN(SELECT job_id FROM app_operations WHERE kind='split' AND state='queued' AND created_at<=?)`).bind(cutoff), + ]); +} diff --git a/src/reliability/retry.ts b/src/reliability/retry.ts new file mode 100644 index 0000000..e282f22 --- /dev/null +++ b/src/reliability/retry.ts @@ -0,0 +1,28 @@ +/** Absolute deadlines: Retry-After is a floor, never capped to a shorter wait. */ +export function retryAt(header: string | null, now = Date.now(), attempt = 0, random = Math.random) { + if (header && /^\d+(?:\.\d+)?$/.test(header.trim())) { + const seconds=Number(header); + if (Number.isFinite(seconds) && seconds >= 0) return now+Math.ceil(seconds*1000); + } + if (header && !/^\s*[-+]?\d/.test(header)) { + const date=Date.parse(header); + if (Number.isFinite(date)) return Math.max(now,date); + } + return now+Math.ceil(Math.min(30000,1000*2**Math.min(attempt,5))*(1+Math.max(0,Math.min(1,random())))); +} +export class UpstreamError extends Error { + code: string; outcome: 'rejected' | 'uncertain' | 'terminal'; status: number; notBefore: number; + constructor(code: string, outcome: 'rejected' | 'uncertain' | 'terminal', status = 0, notBefore = 0) { + super(outcome === 'uncertain' ? 'The provider may have accepted this request. We are checking its status; do not submit it again.' + : 'The audio service could not complete this request. Your successful-split allowance has not been used.'); + this.code=code;this.outcome=outcome;this.status=status;this.notBefore=notBefore; + } +} +export async function setCooldown(db: D1Database, provider: string, until: number) { + await db.prepare(`INSERT INTO provider_cooldowns(provider,until_ms) VALUES(?,?) + ON CONFLICT(provider) DO UPDATE SET until_ms=MAX(until_ms,excluded.until_ms)`).bind(provider,until).run(); +} +export async function cooldown(db: D1Database, provider: string, now=Date.now()) { + const row=await db.prepare('SELECT until_ms FROM provider_cooldowns WHERE provider=?').bind(provider).first<{until_ms:number}>(); + return Math.max(now,row?.until_ms ?? now); +} diff --git a/src/reliability/splits.ts b/src/reliability/splits.ts new file mode 100644 index 0000000..b905885 --- /dev/null +++ b/src/reliability/splits.ts @@ -0,0 +1,244 @@ +import type { Env } from '../env.ts'; +import type { AppPrincipal } from '../identity.ts'; +import { archiveContentType, ArchiveError, fetchArchiveAudio, fetchArchiveItem, parseArchiveIdentifier } from '../archive.ts'; +import { parseYouTubeVideoId, pollYouTubeImport, startYouTubeImport, replicateConfiguration, CAIL_IMPORT_BYTES, YouTubeError } from '../youtube.ts'; +import { getBackend, type SeparationResult } from '../separation/index.ts'; +import { getSeparationOptions, modelIsAllowed, getReplicateRunner, replicateVersion } from '../separation/options.ts'; +import { presignDownload, presignAnalysisDownload } from '../r2.ts'; +import { serverAutoCapability, configuredAudioAnalysisProvider, resolveAutoRoutingWithSource, audioAnalysisTimeoutMs, AUTO_ROUTING_REQUEST, prepareAuthoritativeAutoSource } from '../analysis/index.ts'; +import { parseBrowserAutoSummary } from '../analysis/contract.ts'; +import { processingFeatureFlags } from '../features.ts'; +import { authorizeStoredCourseWork, courseAssignmentStatement } from '../classroom/access.ts'; +import { beginAttempt, finishAttempt, fingerprint, OperationError, readOperation, reserveOperation, TERMINAL, type Operation } from './ledger.ts'; +import { claimNext, recoverExpired, transition, WORK_LEASE_MS } from './queue.ts'; +import { cooldown, setCooldown, UpstreamError } from './retry.ts'; +import { activeGuest, ownershipTable } from '../guest/access.ts'; + +const MAX_BYTES=100*1024*1024; +interface SplitInput { + sourceType: 'upload'|'youtube'|'archive'; key?: string; filename: string; youtubeUrl?: string; + archiveId?: string; archiveFile?: string; model: string; auto: boolean; browserAnalysis?: unknown; +} +type Complete = (env: Env, jobId: string, result: SeparationResult,operation?:Operation) => Promise; +const title=(value: unknown) => typeof value==='string' ? value.replace(/[\x00-\x1f\x7f/\\]/g,' ').trim().slice(0,200) : ''; +export const operationWebhook=(env: Env,id: string,phase: 'fetch'|'split') => `${env.PUBLIC_BASE_URL}/api/webhooks/separation?job=${id}&phase=${phase}&token=${env.WEBHOOK_SECRET}`; + +export async function submitSplit(env: Env, principal: AppPrincipal, body: unknown, key: string | undefined) { + if (env.SPLIT_STARTS_DISABLED==='true') throw new OperationError('splits_paused',503,'New splits are temporarily paused. Saved audio still plays.'); + if (!body || typeof body!=='object'||Array.isArray(body)) throw new OperationError('invalid_input',400,'Choose an audio file or a supported source.'); + const value=body as Record; + const courseId=principal.courseId ?? null; + if (courseId && value.coursePolicy!=='course-work-v1') throw new OperationError('course_policy_required',400,'Review the course sharing notice before submitting.'); + const options=getSeparationOptions(env.SEPARATION_BACKEND), submitted=value.model ?? options.defaultModel; + const capability=serverAutoCapability(env); + const auto=Boolean(capability&&(submitted===AUTO_ROUTING_REQUEST||value.routingRequest===AUTO_ROUTING_REQUEST)); + if (typeof submitted!=='string'||(submitted===AUTO_ROUTING_REQUEST ? !auto||capability?.mode!=='authoritative' : !modelIsAllowed(env.SEPARATION_BACKEND,submitted))) throw new OperationError('invalid_model',400,'Choose an available split model.'); + const input: SplitInput={sourceType:'upload',filename:'Audio',model:submitted===AUTO_ROUTING_REQUEST?options.defaultModel:submitted,auto}; + if (auto) input.browserAnalysis=parseBrowserAutoSummary(value.browserAnalysis,options.models); + if ([value.youtubeUrl,value.archiveId,value.key].filter(v=>v!==undefined).length!==1) throw new OperationError('invalid_input',400,'Choose exactly one audio source.'); + if (value.youtubeUrl!==undefined) { + const id=parseYouTubeVideoId(value.youtubeUrl as string); + if (!id) throw new OperationError('invalid_youtube_url',400,'Paste a full YouTube video link.'); + if(!replicateConfiguration(env))throw new OperationError('youtube_unconfigured',503,'YouTube import is temporarily unavailable. Upload an original or licensed audio file.'); + input.sourceType='youtube'; input.youtubeUrl=`https://www.youtube.com/watch?v=${id}`; input.filename='YouTube audio'; + } else if (value.archiveId!==undefined) { + const id=typeof value.archiveId==='string'&&parseArchiveIdentifier(value.archiveId); + if (!id||value.archiveFile!==undefined&&(typeof value.archiveFile!=='string'||value.archiveFile.length>1024)) throw new OperationError('invalid_archive_id',400,'Choose an available Archive audio file.'); + input.sourceType='archive';input.archiveId=id;input.archiveFile=value.archiveFile as string|undefined;input.filename='Archive audio'; + } else { + if (typeof value.key!=='string'||!/^uploads\/[a-zA-Z0-9-]+\/[^/]+$/.test(value.key)||!title(value.filename)) throw new OperationError('invalid_upload',400,'Choose and upload an audio file.'); + const owned=await env.DB.prepare(`SELECT 1 FROM ${ownershipTable(principal,'upload')} WHERE object_key=? AND subject=? AND state='ready' AND expires_at>?`) + .bind(value.key,principal.subject,new Date().toISOString()).first(); + const source=owned&&await env.AUDIO.head(value.key); + if (!source) throw new OperationError('upload_not_found',404,'The upload is unavailable. Upload it again.'); + if (source.size<=0||source.size>MAX_BYTES) throw new OperationError('invalid_upload',400,'The upload is empty or too large. Upload it again.'); + input.key=value.key;input.filename=title(value.filename); + } + const id=crypto.randomUUID(); + return reserveOperation(env.DB,{subject:principal.subject,quotaClass:principal.quotaClass ?? 'member',courseId,kind:'split',key:key ?? '',fingerprint:await fingerprint(input),phase:input.sourceType==='upload'?'split':'fetch',jobId:id,request:input, + statements: opId=>{ + const statements=[env.DB.prepare(`INSERT INTO jobs(id,filename,source_key,status,model,source_type) + SELECT id,?,?,'queued',?,? FROM app_operations WHERE id=?`) + .bind(input.filename,input.key ?? `uploads/${id}/source.m4a`,input.model,input.sourceType,opId), + env.DB.prepare(`INSERT INTO ${ownershipTable(principal,'job')}(job_id,subject) SELECT id,subject FROM app_operations WHERE id=?`).bind(opId)]; + const course=courseAssignmentStatement(env,id,principal); + if (course) statements.push(course); + return statements; + }}); +} + +async function fail(env: Env, op: Operation, code: string, message='This split could not finish. No successful-split allowance was used. Try another source or upload an original or licensed audio file.') { + await env.DB.batch([ + env.DB.prepare(`UPDATE app_operations SET error_code=? WHERE id=? AND fence=? AND state NOT IN (${TERMINAL})`).bind(code,op.id,op.fence), + env.DB.prepare(`UPDATE jobs SET status='failed',error=? WHERE id=? AND EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND state NOT IN (${TERMINAL}))`).bind(message,op.job_id,op.id,op.fence), + ]); +} +async function imported(env: Env,op: Operation,input: SplitInput,audio: {data:ArrayBuffer;title:string;fileName?:string;attribution?:unknown}) { + if (!audio.data.byteLength||audio.data.byteLength>MAX_BYTES) throw new YouTubeError('Audio is empty or too large.','invalid_audio_response'); + const hash=await crypto.subtle.digest('SHA-256',audio.data); + const sha=Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join(''); + const extension=audio.fileName?.match(/\.[a-zA-Z0-9]+$/)?.[0] ?? '.m4a'; + const key=`uploads/${op.id}/source-${op.fence}${extension}`; + await env.AUDIO.put(key,audio.data,{httpMetadata:{contentType:audio.fileName?archiveContentType(audio.fileName):'audio/mp4'}}); + const current=await readOperation(env.DB,op.id); + if (!current||current.fence!==op.fence||current.cancel_requested||['failed','cancelled'].includes(current.state)) { + await env.AUDIO.delete(key); + if(current?.fence===op.fence&¤t.cancel_requested)await fail(env,op,'cancelled'); + return; + } + input.key=key;input.filename=title(audio.title)||input.filename; + await env.DB.batch([ + env.DB.prepare(`UPDATE jobs SET source_key=?,source_hash=?,filename=?,status='queued' WHERE id=? AND EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND cancel_requested=0 AND state NOT IN (${TERMINAL}))`).bind(key,sha,input.filename,op.job_id,op.id,op.fence), + env.DB.prepare(`UPDATE app_operations SET request_json=?,provider_id=NULL,phase='split',state='queued',lease_owner=NULL,lease_until=0,updated_at=? WHERE id=? AND fence=? AND cancel_requested=0 AND state NOT IN (${TERMINAL})`).bind(JSON.stringify(input),Date.now(),op.id,op.fence), + ...(audio.attribution?[env.DB.prepare(`INSERT OR REPLACE INTO job_attributions(job_id,attribution) SELECT ?,? WHERE EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND cancel_requested=0 AND state NOT IN (${TERMINAL}))`).bind(op.job_id,JSON.stringify(audio.attribution),op.id,op.fence)]:[]), + ]); + const saved=await env.DB.prepare('SELECT source_key FROM jobs WHERE id=?').bind(op.job_id).first<{source_key:string}>(); + if(saved?.source_key!==key)await env.AUDIO.delete(key); +} +async function storeArchive(env: Env,op: Operation,input: SplitInput) { + const scope=await fingerprint([op.subject,op.course_id]),source=await fingerprint([input.archiveId,input.archiveFile]); + // Every cache hit revalidates the public rights floor with Archive metadata. + await fetchArchiveItem(input.archiveId!); + const cached=await env.DB.prepare('SELECT object_key,metadata_json FROM import_cache WHERE scope=? AND source=? AND expires_at>? AND object_key IS NOT NULL') + .bind(scope,source,Date.now()).first<{object_key:string;metadata_json:string}>(); + if (cached) { + const object=await env.AUDIO.get(cached.object_key); + if (object && object.size<=MAX_BYTES) { await imported(env,op,input,{...JSON.parse(cached.metadata_json),data:await object.arrayBuffer()});return; } + } + const audio=await fetchArchiveAudio(input.archiveId!,input.archiveFile,env,{maximumBytes:CAIL_IMPORT_BYTES}); + await imported(env,op,input,audio); + const current=await readOperation(env.DB,op.id); + if (current?.fence===op.fence&¤t.phase==='split'&&input.key) await env.DB.prepare(`INSERT INTO import_cache(scope,source,operation_id,object_key,metadata_json,expires_at) + VALUES(?,?,?,?,?,?) ON CONFLICT(scope,source) DO UPDATE SET operation_id=excluded.operation_id,object_key=excluded.object_key,metadata_json=excluded.metadata_json,expires_at=excluded.expires_at`) + .bind(scope,source,op.id,input.key,JSON.stringify({title:audio.title,fileName:audio.fileName,attribution:audio.attribution}),op.created_at+24*60*60*1000).run(); +} + +async function start(env: Env,op: Operation) { + const input=JSON.parse(op.request_json) as SplitInput; + try { + if (op.quota_class === 'guest') { + if (!env.guestRuntimeReady || op.course_id !== null || !await activeGuest(env.DB,op.subject)) throw new Error('Guest access ended'); + } else await authorizeStoredCourseWork(env,op.subject,op.course_id); + } + catch { await fail(env,op,'access_unavailable','Course access could not be confirmed. No successful-split allowance was used.');return; } + if (op.cancel_requested) {await fail(env,op,'cancelled');return;} + if (op.phase==='fetch'&&input.sourceType==='archive') { + try {await storeArchive(env,op,input);} catch {await fail(env,op,'archive_import_failed');}return; + } + let attempt: string | null=null; + try { + let sourceUrl='';let model=input.model; + if (op.phase==='split') { + let sourceKey=input.key!; + const capability=serverAutoCapability(env); + if (input.auto&&capability) { + const options=getSeparationOptions(env.SEPARATION_BACKEND); + let expectedSourceBytes: number | undefined; + if (input.sourceType==='upload'&&capability.mode==='authoritative') { + const snapshot=await prepareAuthoritativeAutoSource(env,{jobId:op.id,sourceKey,allowExisting:true,isCurrent:async()=>{const current=await readOperation(env.DB,op.id);return current?.fence===op.fence&¤t.lease_owner===op.lease_owner&¤t.state==='running'&&!current.cancel_requested;}});sourceKey=snapshot.snapshotKey;expectedSourceBytes=snapshot.bytes; + } + const stored=await env.DB.prepare('SELECT source_hash FROM jobs WHERE id=?').bind(op.job_id).first<{source_hash:string|null}>(); + const head=stored?.source_hash?await env.AUDIO.head(sourceKey):null; + const resolution=await resolveAutoRoutingWithSource({sourceUrl:await presignAnalysisDownload(env,sourceKey),sourceType:input.sourceType,mode:capability.mode,currentModel:model,fallbackModel:options.defaultModel,coreModels:options.models, + ...(input.browserAnalysis?{browserAnalysis:parseBrowserAutoSummary(input.browserAnalysis,options.models)}:{}), + ...(stored?.source_hash&&head?{expectedSourceIdentity:{schemaVersion:'1' as const,sha256:stored.source_hash,bytes:head.size}}:{}), + ...(expectedSourceBytes!==undefined?{expectedSourceBytes}:{}), + provider:configuredAudioAnalysisProvider(env),timeoutMs:audioAnalysisTimeoutMs(env),instrumentDiscovery:processingFeatureFlags(env).instrumentDiscovery}); + model=resolution.decision.resolvedCoreModel; + const changed=await env.DB.prepare(`UPDATE jobs SET model=?,routing_request=?,analysis=?,source_key=? WHERE id=? + AND EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND lease_owner IS ? AND cancel_requested=0 AND state='running')`).bind(model,AUTO_ROUTING_REQUEST,JSON.stringify(resolution.decision),sourceKey,op.job_id,op.id,op.fence,op.lease_owner).run(); + if(!changed.meta.changes)return; + } + sourceUrl=await presignDownload(env,sourceKey); + } + const runner=getReplicateRunner(model); + const version=op.phase==='fetch'?env.REPLICATE_YT_MODEL_VERSION:runner?replicateVersion(env,runner):undefined; + if(!version||!env.REPLICATE_API_TOKEN) {await fail(env,op,'provider_configuration','Audio separation is temporarily unavailable. No successful-split allowance was used.');return;} + // This write precedes every paid POST. A crash from here reconciles, never retries. + if (!await transition(env.DB,op,'starting')) return; + attempt=await beginAttempt(env.DB,op,'replicate',version); + const providerId=op.phase==='fetch' + ? await startYouTubeImport(input.youtubeUrl!,env,operationWebhook(env,op.id,op.phase as 'fetch'|'split')) + : (await getBackend(env).start({jobId:op.id,audioUrl:sourceUrl,webhookUrl:operationWebhook(env,op.id,op.phase as 'fetch'|'split'),model})).externalId; + await finishAttempt(env.DB,attempt,'accepted',{externalId:providerId}); + if (await transition(env.DB,op,'processing',{providerId})) { + await env.DB.prepare('UPDATE app_operations SET lease_owner=NULL,lease_until=0 WHERE id=? AND fence=?').bind(op.id,op.fence).run(); + await env.DB.prepare(`UPDATE jobs SET status=?,external_id=? WHERE id=? AND status NOT IN ('failed','done') AND EXISTS(SELECT 1 FROM app_operations WHERE id=? AND fence=? AND state='processing')`) + .bind(op.phase==='fetch'?'importing':'processing',op.phase==='fetch'?null:providerId,op.job_id,op.id,op.fence).run(); + } + } catch(error) { + if (attempt) await finishAttempt(env.DB,attempt,error instanceof UpstreamError?error.outcome:'uncertain',{ + code:error instanceof UpstreamError?error.code:'start_uncertain',status:error instanceof UpstreamError?error.status:undefined}); + if (!attempt) {await fail(env,op,'preparation_failed');return;} + if (error instanceof UpstreamError&&error.outcome==='rejected') { + const count=await env.DB.prepare('SELECT COUNT(*) AS n FROM operation_attempts WHERE operation_id=? AND phase=?').bind(op.id,op.phase).first<{n:number}>(); + if (error.status===429) await setCooldown(env.DB,'replicate',error.notBefore); + if (error.status===429&&(count?.n ?? 0)<3&&error.notBefore(); + if(!claimed)return; + try { + if (claimed.phase==='fetch') { + const audio=await pollYouTubeImport(claimed.provider_id!,env); + if(audio)await imported(env,claimed,JSON.parse(claimed.request_json),audio); + } else { + const result=await getBackend(env).fetchStatus(claimed.provider_id!); + const current=await readOperation(env.DB,claimed.id); + if(current?.fence!==claimed.fence||current.lease_owner!==claimed.lease_owner)return; + if(result.status!=='processing') { + if(claimed.cancel_requested)await fail(env,claimed,'cancelled','Split cancelled. No successful-split allowance was used.'); + else await complete(env,claimed.job_id!,result,claimed); + } + } + } catch(error) { + if(error instanceof YouTubeError||error instanceof ArchiveError) await fail(env,claimed,error.code, + error.code==='audio_too_large'?'This import exceeds the 12 MiB audio limit. No successful-split allowance was used. Try a shorter clip or upload an original or licensed file.':undefined); + else if(error instanceof UpstreamError&&error.notBefore) await setCooldown(env.DB,'replicate',error.notBefore); + // Provider/storage outage retains the exact prediction, never creates another. + } finally { + await env.DB.prepare('UPDATE app_operations SET lease_owner=NULL,lease_until=0,not_before=? WHERE id=? AND fence=?') + .bind(Math.max(now+5000,await cooldown(env.DB,'replicate')),claimed.id,claimed.fence).run(); + } +} +export async function drainSplitQueue(env: Env,complete: Complete) { + if(env.AUTH_MODE!=='cail')return; + await recoverExpired(env.DB); + const active=await env.DB.prepare("SELECT * FROM app_operations WHERE kind='split' AND state IN ('processing','reconciling') AND provider_id IS NOT NULL AND not_before<=? ORDER BY updated_at LIMIT 4") + .bind(Date.now()).all(); + for(const op of active.results)await reconcileSplit(env,op,complete); + if(env.SPLIT_STARTS_DISABLED==='true')return; + for(const phase of ['fetch','split'] as const) {const op=await claimNext(env.DB,phase);if(op)await start(env,op);} +} + +export async function recoverCallback(env: Env,operation: Operation,externalId: string) { + if(operation.provider_id||!['starting','reconciling'].includes(operation.state))return; + // A capability is unique to the operation AND phase. The accepted prediction + // must also match the exact model version recorded before its paid POST. + const attempt=await env.DB.prepare(`SELECT id,model FROM operation_attempts WHERE operation_id=? AND phase=? + AND provider='replicate' AND outcome IN ('starting','uncertain') ORDER BY created_at DESC LIMIT 1`) + .bind(operation.id,operation.phase).first<{id:string;model:string|null}>(); + if(!attempt?.model)return; + if(await cooldown(env.DB,'replicate')>Date.now())return; + try {if(!await getBackend(env).confirmStart?.(externalId,operationWebhook(env,operation.id,operation.phase as 'fetch'|'split'),attempt.model))return;} + catch(error) {if(error instanceof UpstreamError&&error.notBefore) {await setCooldown(env.DB,'replicate',error.notBefore);await env.DB.prepare('UPDATE app_operations SET not_before=MAX(not_before,?) WHERE id=?').bind(error.notBefore,operation.id).run();}return;} + await finishAttempt(env.DB,attempt.id,'accepted',{externalId}); + const accepted=await env.DB.prepare(`UPDATE app_operations SET provider_id=?,state='processing',lease_until=0,lease_owner=NULL,updated_at=? + WHERE id=? AND fence=? AND phase=? AND provider_id IS NULL AND state IN ('starting','reconciling')`) + .bind(externalId,Date.now(),operation.id,operation.fence,operation.phase).run(); + if(accepted.meta.changes&&operation.phase==='split')await env.DB.prepare("UPDATE jobs SET external_id=?,status='processing' WHERE id=? AND status NOT IN ('done','failed')").bind(externalId,operation.job_id).run(); +} diff --git a/src/separation/replicate.ts b/src/separation/replicate.ts index 3b609fb..b368763 100644 --- a/src/separation/replicate.ts +++ b/src/separation/replicate.ts @@ -2,6 +2,7 @@ import type { Env } from '../env'; import { DEFAULT_DEMUCS_MODEL, getReplicateRunner, replicateVersion } from './options'; import type { SeparationBackend, SeparationResult, SeparationStartRequest } from './types'; import { readBoundedResponse } from '../http/bounded-response.ts'; +import { retryAt, UpstreamError } from '../reliability/retry.ts'; // Replicate-hosted Demucs (ryan5453/demucs), running the htdemucs_ft // fine-tuned model with MP3 output. ~$0.04–0.05/song on A40. @@ -15,6 +16,8 @@ interface ReplicatePrediction { status: 'starting' | 'processing' | 'succeeded' | 'failed' | 'canceled'; output?: Record; error?: unknown; + webhook?: string; + version?: string; } const API = 'https://api.replicate.com/v1'; @@ -34,18 +37,6 @@ export function replicateBackend(env: Env): SeparationBackend { 'Content-Type': 'application/json', }; - // Low-credit accounts get "burst of 1" rate limits; a YouTube import makes - // two predictions back-to-back (fetch, then separate), so honor 429s. - const fetchRetrying429 = async (url: string, init: RequestInit): Promise => { - for (let attempt = 0; ; attempt++) { - const res = await fetch(url, { ...init, redirect: 'manual', signal: AbortSignal.timeout(30000) }); - if (res.status !== 429 || attempt >= 3) return res; - await res.body?.cancel().catch(() => undefined); - const retryAfter = Number(res.headers.get('retry-after')) || 5; - await new Promise((r) => setTimeout(r, Math.min(retryAfter + 1, 15) * 1000)); - } - }; - const backend: SeparationBackend = { async start(req: SeparationStartRequest): Promise<{ externalId: string }> { // The catalogue owns the version and the input shape; this backend only @@ -59,7 +50,8 @@ export function replicateBackend(env: Env): SeparationBackend { if (!version) { throw new Error(`${runner.versionVar} is not configured`); } - const res = await fetchRetrying429(`${API}/predictions`, { + let res: Response; + try { res = await fetch(`${API}/predictions`, { method: 'POST', headers, body: JSON.stringify({ @@ -68,13 +60,16 @@ export function replicateBackend(env: Env): SeparationBackend { webhook: req.webhookUrl, webhook_events_filter: ['completed'], }), - }); + redirect: 'manual', signal: AbortSignal.timeout(30000), + }); } catch { throw new UpstreamError('start_uncertain','uncertain'); } if (!res.ok) { await res.body?.cancel().catch(() => undefined); - throw new Error(`The separator could not start (${res.status}). Please try again.`); + throw new UpstreamError(res.status === 429 ? 'provider_capacity' : res.status === 402 ? 'provider_credit' : 'provider_rejected', + res.status >= 500 ? 'uncertain' : 'rejected',res.status,res.status === 429 ? retryAt(res.headers.get('retry-after')) : 0); } - const prediction = await predictionJson(res); - if (typeof prediction.id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(prediction.id)) throw new Error('The separator returned an invalid prediction'); + let prediction: ReplicatePrediction; + try { prediction = await predictionJson(res); } catch { throw new UpstreamError('start_response_unreadable','uncertain'); } + if (typeof prediction.id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(prediction.id)) throw new UpstreamError('start_id_missing','uncertain'); return { externalId: prediction.id }; }, @@ -97,9 +92,19 @@ export function replicateBackend(env: Env): SeparationBackend { const res = await fetch(`${API}/predictions/${externalId}`, { headers, redirect: 'manual', signal: AbortSignal.timeout(20000) }); if (!res.ok) { await res.body?.cancel().catch(() => undefined); - throw new Error(`The separator status is unavailable (${res.status}).`); + throw new UpstreamError('provider_poll_unavailable','uncertain',res.status,res.status===429?retryAt(res.headers.get('retry-after')):0); } - return backend.parseResult(await predictionJson(res)); + const prediction=await predictionJson(res); + if(prediction.id!==externalId)throw new UpstreamError('provider_poll_identity','uncertain'); + return backend.parseResult(prediction); + }, + + async confirmStart(externalId: string, webhookUrl: string, version: string): Promise { + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(externalId)) return false; + const res=await fetch(`${API}/predictions/${externalId}`,{headers,redirect:'manual',signal:AbortSignal.timeout(20000)}); + if (!res.ok) { await res.body?.cancel();if(res.status===429)throw new UpstreamError('provider_poll_unavailable','uncertain',429,retryAt(res.headers.get('retry-after'))); return false; } + const prediction=await predictionJson(res); + return prediction.id===externalId && prediction.webhook===webhookUrl && prediction.version===version; }, }; diff --git a/src/separation/types.ts b/src/separation/types.ts index bfea9d5..9d96fb8 100644 --- a/src/separation/types.ts +++ b/src/separation/types.ts @@ -33,4 +33,6 @@ export interface SeparationBackend { parseResult(payload: unknown): SeparationResult; /** Poll the provider directly (reconciliation fallback if a webhook is missed). */ fetchStatus(externalId: string): Promise; + /** Prove an otherwise unknown accepted start belongs to this operation. */ + confirmStart?(externalId: string, webhookUrl: string, version: string): Promise; } diff --git a/src/youtube.ts b/src/youtube.ts index 5d02a3d..e7eeafb 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -1,3 +1,4 @@ +import { parsePredictionJson } from './reliability/prediction-json.ts'; // YouTube audio fetch behind the fetchYouTubeAudio() seam. // // Strategy: use the configured provider order, with a Replicate-hosted yt-dlp @@ -5,18 +6,22 @@ import { Innertube, type Types } from 'youtubei.js/cf-worker'; import type { Env } from './env'; import { readBoundedResponse, responseMediaType } from './http/bounded-response.ts'; +import { retryAt, UpstreamError } from './reliability/retry.ts'; const MAX_DURATION_SECONDS = 15 * 60; // cost/scope guard for class use const MAX_AUDIO_BYTES = 100 * 1024 * 1024; +// Cloudflare's 128 MiB isolate must also hold JSON/base64 and a split decoder. +export const CAIL_IMPORT_BYTES=12*1024*1024; +export const CAIL_INLINE_BYTES=12*1024*1024; +export const CAIL_PREDICTION_BYTES=Math.ceil(CAIL_INLINE_BYTES*4/3)+256*1024; const MIN_AUDIO_BYTES = 1024; const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MODEL_NAME_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const MODEL_VERSION_PATTERN = /^[0-9a-f]{64}$/; const SAFE_TOKEN_PATTERN = /^[^\s\u0000-\u001f\u007f]+$/; -// YouTube bot-checks the default WEB client from datacenter IPs; the app -// clients are usually exempt. Try them in order until one is playable. -const CLIENTS: Types.InnerTubeClient[] = ['IOS', 'ANDROID', 'TV', 'TV_EMBEDDED', 'WEB_EMBEDDED', 'MWEB', 'WEB']; +// One supported client. A source restriction is not a reason to rotate clients. +const CLIENTS: Types.InnerTubeClient[] = ['WEB']; const REPLICATE_API = 'https://api.replicate.com/v1'; const REPLICATE_POLL_MS = 2000; @@ -60,7 +65,7 @@ export interface YouTubeAudio { durationSec: number; } -function replicateConfiguration(env: Env) { +export function replicateConfiguration(env: Env) { const modelName = env.REPLICATE_YT_MODEL ?? ''; const modelVersion = env.REPLICATE_YT_MODEL_VERSION ?? ''; const apiToken = env.REPLICATE_API_TOKEN ?? ''; @@ -77,8 +82,15 @@ function replicateConfiguration(env: Env) { return { modelName, modelVersion, apiToken }; } +export function youtubeImportConfiguration(env: Env) { + const values=[env.REPLICATE_YT_MODEL,env.REPLICATE_YT_MODEL_VERSION,env.REPLICATE_API_TOKEN]; + if(values.every(v=>!v))return 'unconfigured'; + if(values.some(v=>!v))return 'incomplete'; + return replicateConfiguration(env)?'configured':'invalid'; +} + export function parseYouTubeVideoId(url: string): string | null { - if (url.length > 2048) return null; + if (typeof url !== 'string' || url.length > 2048) return null; let u: URL; try { u = new URL(url.trim()); @@ -95,15 +107,15 @@ export function parseYouTubeVideoId(url: string): string | null { const host = u.hostname.toLowerCase().replace(/^(www|m)\./, ''); let videoId: string | null = null; if (host === 'youtu.be') { - videoId = u.pathname.slice(1).split('/')[0] || null; + videoId = /^\/[A-Za-z0-9_-]{11}\/?$/.test(u.pathname) ? u.pathname.split('/')[1] : null; } else if ( host === 'youtube.com' || host === 'music.youtube.com' || host === 'youtube-nocookie.com' ) { if (u.pathname === '/watch') { - videoId = u.searchParams.get('v'); - } else if (/^\/(shorts|embed|live)\//.test(u.pathname)) { + videoId = u.searchParams.getAll('v').length===1 ? u.searchParams.get('v') : null; + } else if (/^\/(shorts|embed|live)\/[A-Za-z0-9_-]{11}\/?$/.test(u.pathname)) { videoId = u.pathname.split('/')[2] || null; } } @@ -134,7 +146,7 @@ export async function fetchYouTubeAudio(url: string, env: Env): Promiseundefined); + throw sourceDenied(); + } return isInnertubeAudioHost(url) ? response : boundedInnertubeControlResponse(response, deadline); @@ -343,11 +359,13 @@ async function fetchViaInnertube(videoId: string): Promise { try { info = await withDeadline(() => yt.getBasicInfo(videoId, { client }), deadline); } catch (error) { + if(error instanceof YouTubeError&&!error.retryable)throw error; if ( (error instanceof YouTubeError && error.code === 'youtube_fetch_timeout') || Date.now() >= deadline ) throw youtubeTimedOut(); - continue; // client-specific parse/availability failure — try the next one + if (/private|sign.?in|age.?restrict|region|country|unavailable|removed|drm|bot|captcha|challenge/i.test(error instanceof Error ? error.message : '')) throw sourceDenied(); + throw providerUnavailable(); } const basic = info.basic_info; @@ -362,7 +380,7 @@ async function fetchViaInnertube(videoId: string): Promise { ); } if (info.playability_status && info.playability_status.status !== 'OK') { - continue; + throw sourceDenied(); } // Audio-only M4A/AAC — Demucs ingests M4A directly, no transcoding needed. @@ -373,11 +391,13 @@ async function fetchViaInnertube(videoId: string): Promise { deadline ); } catch (error) { + if(error instanceof YouTubeError&&!error.retryable)throw error; if ( (error instanceof YouTubeError && error.code === 'youtube_fetch_timeout') || Date.now() >= deadline ) throw youtubeTimedOut(); - continue; // no matching format via this client — try the next one + if (/private|sign.?in|age.?restrict|region|country|unavailable|removed|drm|bot|captcha|challenge/i.test(error instanceof Error ? error.message : '')) throw sourceDenied(); + throw invalidAudioResponse(); } const data = await readBoundedResponse(new Response(stream), { maximumBytes: MAX_AUDIO_BYTES, @@ -398,7 +418,7 @@ async function fetchViaInnertube(videoId: string): Promise { throw new YouTubeError( 'Video is not playable with the available import clients.', 'youtube_fetch_blocked', - true + false ); } @@ -409,6 +429,8 @@ interface YtPrediction { status: 'starting' | 'processing' | 'succeeded' | 'failed' | 'canceled'; output?: { audio: string; title: string; duration: number }; error?: string; + inlineAudio?: ArrayBuffer; + inlineTooLarge?: boolean; } async function replicateFetch( @@ -434,14 +456,16 @@ async function replicateFetch( async function readPrediction( response: Response, timeoutMs: number, - expectedId?: string + expectedId?: string, + maximumBytes=MAX_PREDICTION_BYTES, + boundedInline=false ): Promise { if (responseMediaType(response) !== 'application/json') { await response.body?.cancel().catch(() => undefined); throw providerUnavailable(); } const bytes = await readBoundedResponse(response, { - maximumBytes: MAX_PREDICTION_BYTES, + maximumBytes, timeoutMs, errors: { tooLarge: providerUnavailable, @@ -450,9 +474,10 @@ async function readPrediction( }, }); - let value: unknown; + let value: unknown,inlineAudio:ArrayBuffer|undefined,inlineTooLarge:boolean|undefined; try { - value = JSON.parse( + if(boundedInline)({value,inlineAudio,inlineTooLarge}=parsePredictionJson(bytes,CAIL_INLINE_BYTES)); + else value = JSON.parse( new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes) ); } catch { @@ -502,7 +527,7 @@ async function readPrediction( return { id: candidate.id, status: candidate.status as YtPrediction['status'], - output, + output,inlineAudio,inlineTooLarge, error: typeof candidate.error === 'string' ? candidate.error.slice(0, 300) : undefined, }; } @@ -591,7 +616,8 @@ export async function fetchViaReplicate(url: string, env: Env): Promise { remainingTimeout(deadline, AUDIO_DOWNLOAD_TIMEOUT_MS); if (audio.startsWith('data:')) { @@ -605,8 +631,8 @@ async function downloadPredictionFile( throw invalidAudioResponse(); } const b64 = audio.slice(comma + 1); - if (b64.length > Math.ceil((MAX_AUDIO_BYTES * 4) / 3) + 4) { - throw audioTooLarge(); + if (b64.length > Math.ceil((maximumInlineBytes * 4) / 3) + 4) { + throw new YouTubeError(`This import exceeds the ${Math.floor(maximumInlineBytes/1024/1024)} MiB inline audio limit. Try a shorter clip or upload an original or licensed file.`,'audio_too_large'); } if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64) || b64.length % 4 !== 0) { throw invalidAudioResponse(); @@ -617,7 +643,7 @@ async function downloadPredictionFile( } catch { throw invalidAudioResponse(); } - if (bin.length > MAX_AUDIO_BYTES) throw audioTooLarge(); + if (bin.length > maximumInlineBytes) throw new YouTubeError(`This import exceeds the ${Math.floor(maximumInlineBytes/1024/1024)} MiB inline audio limit. Try a shorter clip or upload an original or licensed file.`,'audio_too_large'); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; @@ -654,10 +680,10 @@ async function downloadPredictionFile( throw invalidAudioResponse(); } return readBoundedResponse(res, { - maximumBytes: MAX_AUDIO_BYTES, + maximumBytes, timeoutMs: remainingTimeout(deadline, AUDIO_DOWNLOAD_TIMEOUT_MS), errors: { - tooLarge: audioTooLarge, + tooLarge: () => new YouTubeError(`This import exceeds the ${Math.floor(maximumBytes/1024/1024)} MiB audio limit. Try a shorter clip or upload an original or licensed file.`,'audio_too_large'), timedOut: youtubeTimedOut, unreadable: invalidAudioResponse, }, @@ -769,11 +795,56 @@ function normalizePredictionError(raw: string): YouTubeError { true ); } - return new YouTubeError( - 'YouTube blocked this import. Try a different video, upload your own audio, or browse the Crate for openly licensed music.', - 'youtube_fetch_blocked', - true - ); + if (/extractor_dependency_missing/.test(message)) return new YouTubeError('The audio importer needs maintenance. Upload an original or licensed file for now.','extractor_dependency_missing'); + if (/extractor_timeout/.test(message)) return new YouTubeError('The audio import timed out. Try a shorter source or upload an original or licensed file.','extractor_timeout',true); + if (/invalid_audio_response/.test(message)) return new YouTubeError('The source did not produce usable audio. Try another source or upload an original or licensed file.','invalid_audio_response'); + if (/extractor_failed|extractor_response_too_large/.test(message)) return new YouTubeError('The audio importer could not finish this source. Try an original or licensed file.','extractor_failed'); + if (/requested format.*(?:not available|unavailable)|no suitable format/i.test(message)) return new YouTubeError('This source has no supported audio format. Upload an original or licensed file.','youtube_format_unavailable'); + if (/source_denied|sign.?in|log.?in|captcha|challenge|bot|cookie|private|age.?restrict|not available|unavailable|removed|region|geo.?block|copyright|DRM/i.test(message)) return sourceDenied(); + return new YouTubeError('The audio importer could not finish this source. Try an original or licensed file.','extractor_failed'); +} + +function sourceDenied(): YouTubeError { + return new YouTubeError('This source cannot be imported. Upload an original or licensed audio file, use your own Studio/Takeout download, or choose an openly licensed Archive track.','youtube_source_denied',false); +} + +/** Durable coordinator API. Exactly one POST; no polling or client fallbacks + * are hidden inside it. A lost response cannot be classified as a failed start. + */ +export async function startYouTubeImport(url: string, env: Env, webhook: string): Promise { + const config=replicateConfiguration(env), videoId=parseYouTubeVideoId(url); + if (!config || !videoId) throw new UpstreamError('import_configuration','rejected'); + let res: Response; + try { res=await fetch(`${REPLICATE_API}/predictions`,{ + method:'POST',headers:{Authorization:`Bearer ${config.apiToken}`,'Content-Type':'application/json','Cancel-After':'4m'}, + body:JSON.stringify({version:config.modelVersion,input:{url:`https://www.youtube.com/watch?v=${videoId}`,max_duration:MAX_DURATION_SECONDS},webhook,webhook_events_filter:['completed']}), + redirect:'manual',signal:AbortSignal.timeout(20000), + }); } catch { throw new UpstreamError('import_start_uncertain','uncertain'); } + if (!res.ok) { + await res.body?.cancel().catch(()=>undefined); + throw new UpstreamError(res.status===429?'provider_capacity':res.status===402?'provider_credit':'import_start_rejected',res.status>=500?'uncertain':'rejected',res.status, + res.status===429?retryAt(res.headers.get('retry-after')):0); + } + try { return (await readPrediction(res,15000,undefined,CAIL_PREDICTION_BYTES,true)).id; } + catch { throw new UpstreamError('import_start_response_unreadable','uncertain'); } +} + +export async function pollYouTubeImport(id: string, env: Env): Promise { + if (!PREDICTION_ID_PATTERN.test(id)) throw providerUnavailable(); + const config=replicateConfiguration(env); + if (!config) throw providerUnavailable(); + const deadline=Date.now()+AUDIO_DOWNLOAD_TIMEOUT_MS; + let prediction: YtPrediction; + try { + const res=await replicateFetch(`${REPLICATE_API}/predictions/${id}`,{headers:{Authorization:`Bearer ${config.apiToken}`}},15000); + if (!res.ok) { await res.body?.cancel(); throw new UpstreamError('import_poll_unavailable','uncertain',res.status,res.status===429?retryAt(res.headers.get('retry-after')):0); } + prediction=await readPrediction(res,15000,id,CAIL_PREDICTION_BYTES,true); + } catch(error) {throw error instanceof UpstreamError?error:new UpstreamError('import_poll_uncertain','uncertain');} + if (prediction.status==='starting'||prediction.status==='processing') return null; + if (prediction.status!=='succeeded'||!prediction.output) throw normalizePredictionError(prediction.error ?? ''); + if(prediction.inlineTooLarge)throw new YouTubeError('This import exceeds the 12 MiB audio limit. Try a shorter clip or upload an original or licensed file.','audio_too_large'); + const data=prediction.inlineAudio??await downloadPredictionFile(prediction.output.audio,config.apiToken,deadline,CAIL_IMPORT_BYTES,CAIL_INLINE_BYTES); + return validateFetchedAudio({data,title:prediction.output.title,durationSec:prediction.output.duration}); } function invalidAudioResponse(): YouTubeError { diff --git a/tests/auto-source.test.mts b/tests/auto-source.test.mts index f78080e..f957c98 100644 --- a/tests/auto-source.test.mts +++ b/tests/auto-source.test.mts @@ -337,3 +337,22 @@ test('a failed snapshot write rolls back its app-owned key', async () => { ); assert.deepEqual(deleted, ['auto-inputs/v1/auto_job_rollback']); }); + +test('durable Auto recovery reuses its own verified snapshot and stale cleanup cannot delete it',async()=>{ + await withBucket(async(bucket,env)=>{ + // The lightweight Node fixture shim omits R2 custom metadata. Preserve the + // actual put metadata in this fixture to exercise the native R2 contract. + const metadata=new Map>(),nativePut=bucket.put.bind(bucket),nativeHead=bucket.head.bind(bucket); + bucket.put=async(key,body,options)=>{if(options?.customMetadata)metadata.set(key,options.customMetadata);return nativePut(key,body,options);}; + bucket.head=async(key)=>{const object=await nativeHead(key);if(object)Object.assign(object,{customMetadata:metadata.get(key)});return object;}; + await bucket.put(SOURCE_KEY,ORIGINAL); + const initial=await prepareAuthoritativeAutoSource(env,{jobId:'durable_auto',sourceKey:SOURCE_KEY}); + const replay=await prepareAuthoritativeAutoSource(env,{jobId:'durable_auto',sourceKey:SOURCE_KEY,allowExisting:true,isCurrent:async()=>true}); + assert.deepEqual(replay,initial); + await assert.rejects(prepareAuthoritativeAutoSource(env,{jobId:'durable_auto',sourceKey:SOURCE_KEY}),/already exists/); + const put=bucket.put.bind(bucket);let current=true; + bucket.put=async(...args)=>{await put(...args);current=false;throw new Error('old Worker lost acknowledgement');}; + await assert.rejects(prepareAuthoritativeAutoSource(env,{jobId:'stale_auto',sourceKey:SOURCE_KEY,isCurrent:async()=>current})); + assert.deepEqual(await storedBytes(bucket,'auto-inputs/v1/stale_auto'),ORIGINAL); + }); +}); diff --git a/tests/e2e/local-hosting.spec.mjs b/tests/e2e/local-hosting.spec.mjs index ac42f93..427522c 100644 --- a/tests/e2e/local-hosting.spec.mjs +++ b/tests/e2e/local-hosting.spec.mjs @@ -429,7 +429,11 @@ test('uploads and processes a real WAV through local R2 in a browser', async ({ expect(await page.evaluate((id) => localStorage.getItem(`coachChat:${id}`), jobId)).toBeNull(); await expect(page.locator('.coach-guide-text')).toBeVisible(); - const storedStemResponse = await server.fetch(`/api/files/stems/${jobId}/vocals.mp3`); + const savedJob=await(await server.fetch(`/api/jobs/${jobId}`)).json(); + expect(savedJob.stems.map(stem=>stem.name)).toEqual(['vocals','drums','bass','other']); + for(const stem of savedJob.stems)expect(stem.url).toMatch(new RegExp(`^/api/files/stems/${jobId}/[a-f0-9-]+/${stem.name}\\.mp3$`)); + const savedVocals=savedJob.stems.find(stem=>stem.name==='vocals'); + const storedStemResponse = await server.fetch(savedVocals.url); expect(storedStemResponse.status).toBe(200); expect(storedStemResponse.headers.get('content-length')).toBe( String(stemAudio.get('vocals').length) @@ -440,17 +444,14 @@ test('uploads and processes a real WAV through local R2 in a browser', async ({ expect(storedKeysResponse.status).toBe(200); const { keys: storedKeys } = await storedKeysResponse.json(); expect(storedKeys).toEqual([ - `stems/${jobId}/bass.mp3`, - `stems/${jobId}/drums.mp3`, - `stems/${jobId}/other.mp3`, - `stems/${jobId}/vocals.mp3`, + ...savedJob.stems.map(stem=>stem.url.replace('/api/files/','')).sort(), expect.stringMatching(/^uploads\/[0-9a-f-]+\/source\.wav$/), ]); expect(browserErrors).toEqual([]); await page.screenshot({ path: testInfo.outputPath('local-hosting-ready.png'), fullPage: false }); - await page.route(`**/api/files/stems/${jobId}/vocals.mp3`, (route) => + await page.route(`**${savedVocals.url}`, (route) => route.fulfill({ status: 404, body: 'Not found' }) ); await page.reload(); @@ -595,8 +596,10 @@ test('renames Demucs no_vocals to instrumental for the two-track split', async ( const storedKeysResponse = await e2eFetch(server, '/__e2e/audio'); const storedKeys = (await storedKeysResponse.json()).keys; // Stored under the contract name, so /api/files and the mixer agree. - expect(storedKeys).toContain(`stems/${created.id}/instrumental.mp3`); - expect(storedKeys).not.toContain(`stems/${created.id}/no_vocals.mp3`); + const instrumental=result.stems.find(stem=>stem.name==='instrumental'); + expect(instrumental.url).toMatch(new RegExp(`^/api/files/stems/${created.id}/[a-f0-9-]+/instrumental\\.mp3$`)); + expect(storedKeys).toContain(instrumental.url.replace('/api/files/','')); + expect(storedKeys.some(key=>key.startsWith(`stems/${created.id}/`)&&key.endsWith('/no_vocals.mp3'))).toBe(false); expect(browserErrors).toEqual([]); }); @@ -941,7 +944,9 @@ test('completes a six-track split whose guitar and piano tracks are near-silent' // The quiet tracks were stored verbatim rather than dropped or substituted. for (const name of quietNames) { - const stored = await server.fetch(`/api/files/stems/${created.id}/${name}.mp3`); + const track=result.stems.find(stem=>stem.name===name); + expect(track.url).toMatch(new RegExp(`^/api/files/stems/${created.id}/[a-f0-9-]+/${name}\\.mp3$`)); + const stored = await server.fetch(track.url); expect(stored.status).toBe(200); expect(Buffer.from(await stored.arrayBuffer()).equals(quietAudio)).toBe(true); } @@ -1045,8 +1050,11 @@ test('imports a YouTube link and renames no_vocals for the two-track split', asy const storedKeysResponse = await e2eFetch(server, '/__e2e/audio'); const { keys } = await storedKeysResponse.json(); - expect(keys).toContain(`stems/${created.id}/instrumental.mp3`); - expect(keys).not.toContain(`stems/${created.id}/no_vocals.mp3`); + const result=await (await server.fetch(`/api/jobs/${created.id}`)).json(); + const instrumental=result.stems.find(stem=>stem.name==='instrumental'); + expect(instrumental.url).toMatch(new RegExp(`^/api/files/stems/${created.id}/[a-f0-9-]+/instrumental\\.mp3$`)); + expect(keys).toContain(instrumental.url.replace('/api/files/','')); + expect(keys.some(key=>key.startsWith(`stems/${created.id}/`)&&key.endsWith('/no_vocals.mp3'))).toBe(false); expect(keys.some((key) => /^uploads\/[0-9a-f-]+\/source\.m4a$/.test(key))).toBe(true); expect(browserErrors).toEqual([]); }); @@ -1833,7 +1841,8 @@ test('gates the instructor console and persists a prompt amendment', async ({ pa await expect(page.locator('#signin-panel')).toBeVisible(); await expect(page.locator('#console-panel')).toBeHidden(); await expect(page.locator('.tagline')).toHaveCount(0); - expect(await page.locator('link[rel="stylesheet"]').getAttribute('href')).toMatch(/\?v=/); + expect(await page.locator('link[rel="stylesheet"][href^="/styles.css"]').getAttribute('href')).toMatch(/\?v=/); + expect(await page.locator('link[rel="stylesheet"][href^="/bug-report/stem.css"]').getAttribute('href')).toMatch(/\?v=/); expect(await page.locator('script[src^="\/teacher.js"]').getAttribute('src')).toMatch(/\?v=/); const signInButton = page.getByRole('button', { name: 'SIGN IN' }); const signInButtonBox = await signInButton.boundingBox(); diff --git a/tests/youtube.test.mts b/tests/youtube.test.mts index 564462c..2e09e9c 100644 --- a/tests/youtube.test.mts +++ b/tests/youtube.test.mts @@ -1,3 +1,4 @@ +import { parsePredictionJson } from '../src/reliability/prediction-json.ts'; import assert from 'node:assert/strict'; import test from 'node:test'; @@ -5,6 +6,7 @@ import { createInnertubeFetch, fetchViaReplicate, parseYouTubeVideoId, + pollYouTubeImport, startYouTubeImport, CAIL_IMPORT_BYTES, CAIL_INLINE_BYTES, CAIL_PREDICTION_BYTES, YouTubeError, } from '../src/youtube.ts'; @@ -608,3 +610,63 @@ function makeM4a(size = 2048): Uint8Array { bytes.set([0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20]); return bytes; } + + +test('durable importer bounds prediction and audio copies before reading oversized bodies', async () => { + const saved=globalThis.fetch; + try { + for(const stage of ['prediction','audio']) { + let cancelled=false, calls=0; + globalThis.fetch=async()=>{ + calls++; + if(stage==='audio'&&calls===1)return Response.json({id:'bounded',status:'succeeded',output:{audio:'https://audio.replicate.delivery/bounded.m4a',title:'Fixture',duration:1}}); + return new Response(new ReadableStream({pull(){return new Promise(()=>{});},cancel(){cancelled=true;}}),{ + headers:{'Content-Type':stage==='prediction'?'application/json':'audio/mp4','Content-Length':String((stage==='prediction'?CAIL_PREDICTION_BYTES:CAIL_IMPORT_BYTES)+1)}, + }); + }; + await assert.rejects(()=>pollYouTubeImport('bounded',TEST_ENV as never),(error:any)=>stage==='prediction'?error.code==='import_poll_uncertain':error.code==='audio_too_large'); + assert.equal(cancelled,true,stage); + assert.equal(calls,stage==='prediction'?1:2); + } + // Unknown acceptance stays uncertain; bounding a response never licenses a second paid POST. + globalThis.fetch=async()=>new Response('{}',{headers:{'Content-Type':'application/json','Content-Length':String(CAIL_PREDICTION_BYTES+1)}}); + await assert.rejects(()=>startYouTubeImport('https://youtu.be/jNQXAC9IVRw',TEST_ENV as never,'https://app.test/callback'),(error:any)=>error.code==='import_start_response_unreadable'&&error.outcome==='uncertain'); + // The inline limit is independently enforced after a bounded JSON parse. + for(const size of [2048,6*1024*1024,CAIL_INLINE_BYTES,CAIL_INLINE_BYTES+1]) { + const audio=Buffer.from(makeM4a(size)).toString('base64'); + globalThis.fetch=async()=>Response.json({id:'bounded',status:'succeeded',output:{audio:`data:audio/mp4;base64,${audio}`,title:'Fixture',duration:1}}); + if(size<=CAIL_INLINE_BYTES)assert.equal((await pollYouTubeImport('bounded',TEST_ENV as never))?.data.byteLength,size); + else await assert.rejects(()=>pollYouTubeImport('bounded',TEST_ENV as never),(error:any)=>error.code==='audio_too_large'); + } + } finally {globalThis.fetch=saved;} +}); + +test('durable extractor errors distinguish format, configuration and explicit source restrictions',async()=>{ + const saved=globalThis.fetch; + try { + for(const [message,code] of [ + ['Requested format is not available','youtube_format_unavailable'], + ['Unhandled Python internal exception','extractor_failed'], + ['Sign in to confirm you are not a bot','youtube_source_denied'], + ['This video is private','youtube_source_denied'], + ['extractor_dependency_missing','extractor_dependency_missing'], + ]) { + globalThis.fetch=async()=>Response.json({id:'classified',status:'failed',error:message}); + await assert.rejects(()=>pollYouTubeImport('classified',TEST_ENV as never),(error:any)=>error.code===code,`${message}: ${code}`); + } + }finally{globalThis.fetch=saved;} +}); + +test('bounded inline parsing validates metadata location, escapes, base64 and duplicate audio fields',()=>{ + const parse=(text:string)=>parsePredictionJson(new TextEncoder().encode(text).buffer,32); + const payload='data:audio/mp4;base64,AQIDBA=='; + assert.deepEqual(new Uint8Array(parse(JSON.stringify({output:{audio:payload,title:'a "title"',duration:1}})).inlineAudio!),new Uint8Array([1,2,3,4])); + for(const text of [ + JSON.stringify({error:{audio:payload},output:{audio:'https://safe.test'}}), + '{"output":{"audio":"'+payload+'","audio":"https://safe.test"}}', + '{"output":{"audio":"'+payload+'"},"invalid":}', + JSON.stringify({output:{audio:'data:audio/mp4;base64,AAAA=A=='}}), + JSON.stringify({output:{audio:payload},logs:'x'.repeat(256*1024)}), + ])assert.throws(()=>parse(text)); + assert.deepEqual(parse('{"output":{"audio":"https://safe.test"}}').value,{output:{audio:'https://safe.test'}}); +});