diff --git a/.github/workflows/cloudflare-candidate.yml b/.github/workflows/cloudflare-candidate.yml index 467191d..8f46388 100644 --- a/.github/workflows/cloudflare-candidate.yml +++ b/.github/workflows/cloudflare-candidate.yml @@ -45,6 +45,8 @@ jobs: run: bun run typecheck && bun run test - name: Install Chrome run: bash .github/scripts/install-media-tools.sh chrome + - name: Install Chromium for native browser zoom + run: ./node_modules/.bin/playwright install --with-deps --no-shell chromium - name: Exercise recording, account and Listening Guide browser paths working-directory: cloudflare run: ../node_modules/.bin/playwright test --config playwright.config.mjs diff --git a/TODO.md b/TODO.md index 2d5defa..9f61f81 100644 --- a/TODO.md +++ b/TODO.md @@ -1,136 +1,274 @@ -# STEM Splitter — completion checklist - -Updated September 29, 2026. This is the current progression. Work is -Cloudflare-only; preserve Railway and its `main` release path. - -## Released baseline - -Canonical application: https://stem-splitter.ailab-452.workers.dev/ - -Release branch: `codex/cloudflare-migration`. PRs #11 and #13–16 are merged. -Latest deployed marker: `cloudflare-playback-retention-20260929`; Worker version -`be74510a-f837-4fa5-8269-a500d0841d88`; merged source `be7b8b4` (PR #16). -The alias serves the same Worker/data, not a second installation. - -- [x] CUNY WorkerIdentity handoff, current Admission checks and account-owned saves. -- [x] Server-side rack recovery, pagination and local cross-account tests. -- [x] Ten submitted runs per signed-in person per UTC day; no shared class run cap. -- [x] Listening Guide through CAIL Gateway with the signed-in person's identity. -- [x] 90-day Cloudflare audio retention, account listing and bucket lifecycle readback. -- [x] Instructor grants, optional end date and governed prompt revisions. -- [x] Crate below the workspace, expanded YouTube input and current subtitle. -- [x] Mixer timer/playback-race repairs and desktop/mobile regression tests. -- [x] Canonical asset hashes, signed-in rack reload and existing four-stem playback verified after PR #16. - -These receipts do not establish a fresh student/provider journey or classroom -stress qualification. Remixer, server Auto, discovery and isolation remain off. -Visitor paid runs are not implemented or enabled. - -## 1. Reliable sign-in and recovery - -- [ ] Reproduce the reported first-attempt failure with its site/stage identified. - Bounded Doorway logs show successful authentication followed by stale - callbacks, but cannot establish the person or the cause of the first failure. -- [ ] Resolve repeated/stale callback recovery in its owning service without - replaying codes, accepting invalid state or bypassing Admission. - [Doorway PR #279](https://github.com/CUNY-AI-Lab/cail-tools-admission/pull/279) - redirects an already verified session to the Lab tools home without - granting/renewing access. - Local tests pass; the shared source gate is blocked by the dependency audit - already addressed in prerequisite PR #278. Review, release and fresh CUNY - acceptance remain required. -- [ ] Release state-aware STEM sign-in recovery: verified sessions return home; - expired sessions get CUNY Login; unavailable verification gets Retry; denied - access gets Lab help. Local implementation: `codex/classroom-readiness-20260929`, - not deployed. -- [ ] Verify fresh CUNY login, app logout, expiry, revocation and shared-device - switching. Existing Doorway-session reuse is not fresh CUNY MFA acceptance. - -## 2. Classroom acceptance - -- [ ] Actual student: sign in → authorized source → completed split → playback → - note/rename → Guide/chat → logout → login → account recovery/export. -- [ ] Actual instructor: correct access and prompt-history readback. Do not edit - app-wide teaching instructions just to produce a test receipt. -- [ ] Class-only membership, expired/revoked membership, second-student isolation - and exhausted daily runs. Do not spend a real class's allowance just to force - the limit; synthetic tests and live acceptance stay separate. -- [ ] Record exact release, source provenance, browser and result; keep personal - identifiers and private audio out of Git. - -## 3. Three daily visitor runs - -- [ ] Provision/verify Turnstile for exact hosts through its approved credential - path. Latest setup probe: API token absent. No widget/secret was created. -- [ ] Separate private visitor sessions/ownership, atomic three-run reservations, - bounded import/concurrency, abuse protection and expiry. -- [ ] Exhausted-state CUNY Login / request-access / reset-time UI. -- [ ] Real challenge success and replay denial; fourth-run, concurrency, - cookie-reset, visitor/member isolation and provider-failure tests. -- [ ] Promote only after those checks. Do not invent CAIL subjects, downgrade - denied members to guests or advertise the tier before it is live. - -See [daily allowance contract](docs/superpowers/specs/2026-09-29-daily-split-allowances.md). - -## 4. Class-specific tools and unified accounting - -- [ ] Resolve class context through Admission; add class-scoped Guide settings, - revision/cache keys and cross-class tests. Current instructions are app-wide. -- [ ] Define explicit submissions and instructor/course ownership before - exposing student recordings to a class. -- [ ] Authorized directory-backed admin labels, not inferred email/subject - mapping or a second roster/password store. -- [ ] Shared Gateway asynchronous Replicate contract and real cost settlement, - including idempotent callbacks, failures, cancellations and uncertain starts. -- [ ] Prove per-person allowance drawdown and rejection before new paid work. - -Existing Replicate billing remains approved and unchanged. Daily counts are -not monetary accounting. See [class access and usage](docs/class-access-and-usage.md). - -## 5. Legacy recordings - -- [ ] Authorized inventory and recoverable source backup. -- [ ] Verified destination identity and explicit ownership; resolve shared or - ambiguous records rather than assigning the whole class to an instructor. -- [ ] Journaled/idempotent import and batch-only rollback, preserving dates, - provenance, notes, prompt history and applicable source retention. -- [ ] Destination reload/playback/export and isolation acceptance with the person. - -No legacy transfer has been performed. See [migration readiness](docs/account-migration-readiness.md). - -## 6. Remixer release - -- [ ] Validate existing gated Crate → layers → edit → record → download on live - desktop/mobile, including stem handoff, Archive delivery, memory bounds and credits. -- [ ] Prove license-conflict/unknown-license handling before enabling the flag. -- [ ] Account project persistence: ownership, schema, versioned arrangement/media - references, expiry and recovery. Browser-session layers/takes are not account saves. -- [ ] Student acceptance and explicit feature promotion. - -## 7. Auto and instrument discovery - -- [ ] Reviewed Worker-reachable authenticated analyzer connection; a Railway - private hostname cannot be used directly by this Worker. -- [ ] Upload/YouTube/Archive parity, frozen 2/4/6 choice, outage fallback and - evidence before shadow → teacher beta → authoritative Auto. -- [ ] Human-reviewed corpus/negative controls; select exactly one replacement - classifier and calibrate family thresholds/abstention. Rejected CLAP evidence - is not permission to provision it. -- [ ] Then evaluate optional AudioSep/SAM-Audio isolation with license, quality, - cost, concurrency and output-integrity gates. -- [ ] Broaden genre/instrument coverage while rerunning the frozen baseline. - Coherent long-tail multi-stem research is a later decision. - -Exact pins and reviews remain in the [historical roadmap](docs/archive/roadmap-through-2026-09-29.md), -[processing changelog](docs/model-processing-changelog.md) and acceptance files. - -## 8. Final qualification and retirement decision - -- [ ] Real provider 2/4/6 splits across upload/authorized import types, webhooks - and polling, Guide streams/tools and safe upstream failures. -- [ ] Large files, classroom concurrency, memory, leases/retries, storage expiry - and operational alerts under the actual Worker runtime. -- [ ] Backup/restore, ownership-aware migration rehearsal and rollback including - reconciliation of new Cloudflare writes—not just a URL switch. -- [ ] Classroom/soak acceptance. Railway retirement needs separate approval and - confirmation that no retained analyzer/service depends on it. +# Stem Splitter — next changes in three PRs + +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. + +**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. + +**Additional explicit scope — October 6, 19:11 UTC:** implement signed-out guest use with **5 successful splits and 25 human Listening Guy inputs per UTC day**, alongside signed-in 15/50. Guest ownership and sessions must be separate from CUNY identities and course authority, with bot/abuse controls and honest limits on anonymous per-person accounting. Instrument meaningful use and operation lifecycles using redacted, access-controlled existing infrastructure, with bounded retention and explicit loss/replay semantics. Every track waveform must also support direct pointer/touch/keyboard seeking through the existing hardened shared playback logic. Guest sessions/accounting and usage events belong in PR3; waveform seeking belongs in PR2. The maximum remains three actual PRs. These additions expand testing and final independent review; they do not authorize production activation, new persistent credentials or a material security-setting change. + +| 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. | + +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. + +**Priority YouTube regression investigation — October 6:** read-only Railway MCP/CLI inspection identifies the canonical service's latest deployment `d36f9724-ea89-4558-8bad-cbbbf19188f6` as **REMOVED** on October 5, with no active deployment and a public health 404. It cannot currently serve as a live working comparison. Its CLI redeployment metadata lacks a source SHA; the last recorded accepted Railway source `4c00b32fab96c8f9405095742ce3ca7170ace75b` has identical importer, extractor and package sources to Cloudflare baseline `e1918da`. Retained build logs confirm Node 22.23.1, Bun 1.3.14 and youtubei.js 17.2.0. Both hosts expose the expected configuration names; Cloudflare stores the four YouTube settings as secret bindings, whose values were not read. A synthetic 128 KiB inline-audio reproduction succeeds on both host paths and proves that legacy missing configuration and provider 402/422 failures are incorrectly reported as a YouTube block. PR3's durable path separates these categories; unknown failures must not become unsupported source-denial claims. The specific reported live failure remains unconfirmed pending an exact error/URL/time. This execution environment's Cloudflare health read is denied with error 1010; no bypass was attempted. No Railway settings or deployment were changed, no paid prediction was started, and no fourth PR is proposed. + +**Baseline:** deployed source `e1918da3cb6fc37f9715d4cd143af5a5c6c880c6`, release branch `codex/cloudflare-migration`, [annotation fix PR #27](https://github.com/CUNY-AI-Lab/stem-splitter/pull/27). The canonical site is https://stem-splitter.ailab-452.workers.dev/. Health and remote branch were checked during planning; both matched this source and no open Stem PRs were listed. The original owner checkout was clean and was not edited. + +Keep the PR27 note-input spacing and narrow-screen fix. Preserve CUNY sign-in, current Admission checks, account ownership, 90-day retention, playback and downloads. Work remains Cloudflare-only. The plan now includes read-only inspection and a future PR in the tools portal repository; no Railway, Mapping, OWUI, shared-service deployment, credential changes, paid test jobs, merge or production mutation is authorized by this planning document. + +## Three actual PRs across two repositories + +The new My Classes request crosses a repository boundary. **Revised grouping:** move the former standalone UI PR into Stem's course/collaboration PR, reserve one real PR for the portal, and retain one for reliability. This replaces the earlier three-Stem-PR grouping; it does not count two repository PRs as one. + +| PR | Repository and change | Student/instructor outcome | Dependency | +| --- | --- | --- | --- | +| 1 | `CUNY-AI-Lab/cail-tools-admission`: course eligibility contract and My Classes destination | Only students currently enrolled in Agustina's exact class see **Open Stem Splitter** in **Ready to use the Lab?**, alongside the existing three destinations. | Use the verified class ID and linked instructor record below; coordinate the authoritative contract and receiver checks with PR2. | +| 2 | `CUNY-AI-Lab/stem-splitter`: course permissions, teaching, collaboration and compact UI | Server-enforced student access; instructor roster/conversation review and course prompts; shared folders, attributed notes, Refresh, compact controls and **Stem Splitter** casing. | Consume PR1's reviewed authority contract; preserve PR27 and the approved historical-data policy. | +| 3 | `CUNY-AI-Lab/stem-splitter`: allowances, guests, usage events, imports and model fallback | Signed-in 15 successful splits/50 inputs and guest 5/25 daily; failed work releases successful-use reservations; private guest ownership, redacted usage evidence, recoverable imports and ordered model fallback. | Build on PR2's course checks and shared controls; verify existing Gateway mappings and the separate guest sponsor/bot-verification activation requirements. | + +Use separate commits within each PR for service logic, migrations, tests and UI. Sequence the two Stem PRs to avoid competing edits to `src/index.ts`, `public/app.js` and shared styles. PR3 adopts PR2's controls and includes its new quota/recovery states in final UI regression coverage; there is no later styling PR. + +**Cross-repository release dependency:** PR numbers group the reviews, not the deployment order. Review PR1/PR2 against the same contract and require receiver enforcement before the new destination is released. A concrete three-PR sequence is Stem PR2 first, followed by portal PR1; if PR1 introduces a new Admission RPC, Stem must fail closed with an explicit unavailable state until that service arrives. Review that temporary availability impact before any release. The portal's existing pipeline releases changed Admission sources before Doorway, so contract availability precedes its new link. Do not assume an extra feature-toggle commit, configuration PR or fourth activation PR. A different sequence requires a verified existing control and an explicit revision of this plan. These are later release gates, not authorization to merge, deploy or change configuration now. The portal repository automatically releases changed Workers on a push to `main`; neither main nor its workflows may be mutated in this planning task. [P8] + +Exactly three planned PRs are allowed. Include any necessary Doorway/Admission shared-contract change inside PR1's existing monorepo. If implementation proves that another repository must change (for example a missing Gateway capability), stop that dependency and explicitly revise the grouping before proposing a fourth PR; do not silently expand scope or bypass a gate. + +## Facts and decisions that shape the work + +**Verified current behavior:** instructor is an app role, not a course assignment; ordinary instructors still cannot read another account's jobs. Conversations are private even from admins, stored as up to 60 client-supplied entries per split/subject. Folders are instructor-created, and public split links expose audio while excluding notes, names and conversations. Notes have no author field. Guide amendments use one app-wide settings row. The daily guard reserves ten attempts before body validation and never refunds failures; Gateway traffic bypasses the legacy local guide counter. The Gateway adapter currently makes one model attempt. [S1], [S2], [S3], [S4], [S5], [S12] + +**Verified portal ownership:** the target is `/my-classes` in `CUNY-AI-Lab/cail-tools-admission`, inspected at `7371f7389a826392fa85017649324033e9fcf362` on `main`. Current Cloudflare readback now confirms that source on both serving Workers, as recorded below. `apps/doorway/src/access-destinations.ts` owns the three static links; `owner-ui.ts` renders their **Ready to use the Lab?** section. The page currently calls owner-only class-list operations, and the navigation's class flag looks for `sourceKind: owner`. Student access needs a deliberate landing/navigation state, without granting roster administration. [P1], [P2], [P3], [P6] + +Admission's server summary already supplies canonical `classId`, owner/participant source, state and dates, but its self-service class-source query is capped at 50 records. Use a complete exact-class authorization resolver for the new capability; a truncated presentation summary or broad `resolveMembership` success is insufficient. [P4], [P5] + +**Verified course and ownership — October 6, 2026, 17:12 UTC:** the configured CAIL Admin MCP's supported `search_classes` and `get_class` reads identified **MSH 245 The American Musical Experience**, **Fall 2026**, **section 01**, canonical class ID **`msh-245-the-american-musical-experience-fall-2026-01`**. The class is **active**, version **3**, with recorded class dates **August 28–December 23, 2026**. Its single instructor is linked to a CUNY identity. Verification matched the class's application record ID to Agustina Checa's approved application, matched term/section, and matched the instructor email to that application's email, with `subjectLinked: true` and no teacher truncation. The name search located a candidate; these exact-record relationships established ownership. No student identities, instructor email, subject identifiers or invitation capabilities are included here. [K1], [K2], [K3] + +**Active enrollment authority:** Admission owns class enrollment, participant/owner provenance, withdrawal and time boundaries; app roles and OpenWebUI group membership are not substitutes. Runtime access must require current Admission permission plus a current active participant source for that exact class ID. Re-resolve the requesting subject server-side on protected requests and treat instructor access through its separate verified class-owner relationship. The class's invitation-claim window is September 29–October 29, 2026; that window controls joining, not the December course-access end. Read current canonical boundaries from Admission instead of deriving permission from these display dates, cached counts or an approved application alone. The administrator MCP supplied this operational verification; student applications must use a narrow private service contract and must never receive administrator MCP credentials. [K2], [P4], [P5] + +**Cloudflare serving-source verification — October 6, 2026:** read-only deployment/version inspection found Doorway version `3c963a34-f1f1-45eb-97ef-82691f84fbc6` and Admission version `a34bdb37-a25e-4b2b-8787-9a06c72e2017`, each at **100%**, both tagged/annotated with source `7371f7389a826392fa85017649324033e9fcf362`. This confirms that the portal integration source inspected for PR1 is serving. It does not prove a new CUNY MFA journey or the planned course-aware Stem receiver. The knowledge-base notes establish the component boundaries; these fresh MCP and Cloudflare records supersede their older dated release snapshots. [K2], [K4] + + +| Decision or prerequisite | Proposed default / action before implementation | +| --- | --- | +| Which exact class and instructor identity belong to Agustina? | **Resolved:** `msh-245-the-american-musical-experience-fall-2026-01`, MSH 245, Fall 2026 section 01; active and owned by the linked instructor matched to Agustina's approved application. Use this returned canonical ID in controlled configuration and recheck live state before implementation/release. Current active participant enrollment in this exact class governs the student link and protected student app access. A matching name, another active course, generic Lab membership or app Instructor role is insufficient. Use current server time and the complete exact-class resolver; do not infer a negative from a truncated summary. | +| Which existing work becomes visible to instructors? | **Resolved:** only new course-assigned work follows the course policy with a visible notice. Existing personal/unassigned splits and formerly private transcripts stay private; no backfill or historical transcript import is included. Do not infer course from filename, folder, email or overlapping enrollment. Reset removes content from both student and instructor views; withdrawal and retention are enforced at the current authority and resource boundaries. | +| What does “50 Listening Guy inputs” mean? | **Resolved:** per student per UTC day across courses: one accepted human message, counted once across tools/fallbacks; generated opening guides and cached reads do not count as human inputs. Release a reservation when no usable reply was delivered; a partially delivered or completed reply counts once. Keep content-free accounting separate from the conversation store. | +| What is a “day”? | **Resolved:** midnight UTC; show the reset in local time with its timezone. A split's successful charge settles against its submission/reservation day, even if it finishes after midnight. | +| What does sharing a folder mean? | Default private or enrolled-course sharing with explicit read/comment permissions. Anonymous public folder sharing, or exposing named notes/conversations through existing public audio links, is not included without a separate explicit decision. | +| Are the exact fallback models usable through the current Gateway? | Verify canonical aliases, actual provider routes, account entitlement, streaming/tools and model-specific error classification. Preserve `glm-5.2` then `deepseek-v4-flash-0731`; do not substitute a newer alias silently. Catalog verification is described under PR3. | +| Where does “Having trouble?” go? | Reuse the existing `mailto:ailab@gc.cuny.edu` support route found in sign-in recovery unless the owner supplies another destination. The label is “Having trouble?”; do not invent a helpdesk URL or send diagnostics automatically. [S10] | + +The exact class ID and linked ownership are verified. Implementing the complete runtime course-authority contract remains joint PR1/PR2 engineering work. The prospective-only disclosure policy and UTC quota semantics above are resolved implementation decisions, not claims about currently deployed behavior. Model account access remains an engineering verification gate; public catalog presence alone does not prove entitlement. + +## PR 1 — Enrollment-gated My Classes link and course contract + +**Repository:** `CUNY-AI-Lab/cail-tools-admission` (Doorway, Admission and their shared contract in one PR). **Suggested title:** `Add a course-authorized Stem Splitter destination to My Classes` + +### Build checklist + +- [ ] **Centralize the verified target class.** Use `msh-245-the-american-musical-experience-fall-2026-01`, established through the matched approved application and linked instructor record above. Re-read its current state and ownership before wiring/releasing the feature; do not ask the user to supply information already available through the supported authority. Define a narrow, typed, server-derived student eligibility result for that class. Require a current authenticated subject, current Admission permission and an active participant enrollment whose start/end boundaries permit access now. Preserve the existing meaning of global suspension/revocation and class withdrawal. Never grant access from display names, app-wide roles, client course IDs, browser storage or a remembered successful launch. +- [ ] **Reuse the authority without copying its database.** Resolve exact subject/class access through a supported private service contract. If an additive resolver or authorized roster contract is needed, include it in this PR under `packages/admission-contract` with its Admission implementation and caller tests. Do not treat the 50-record welcome summary as a complete permission lookup or expose roster/profile data to the browser merely to decide one link. Define denial versus unavailable outcomes, time/revision boundaries and minimal disclosure. Keep internal registry schemas Admission-owned. [P4], [P5] +- [ ] **Add the stateful destination at the requested location.** In **My Classes → Ready to use the Lab?**, add **Open Stem Splitter** beside **Open the Sandbox**, **Explore Tools** and **Read the FAQ**, only when the server says this student is currently eligible. Pass a page-specific capability/destination into the renderer; do not append a universal fourth item to the static array shared with other surfaces. Preserve the three existing destinations and their policies. Unknown, expired, revoked, unenrolled and signed-out states expose no working course launch; show a useful sign-in/recheck state where appropriate without disclosing another user's enrollment. +- [ ] **Support students without owner privileges.** Make the target landing section and appropriate My Classes navigation reachable for enrolled participants even though the current page loads an owner class list. Render participant information separately from owner controls; do not grant a student owner roster reads, invitations, quota administration or enrollment mutations just to show the link. A multi-course student qualifies only through the exact target class; retain their other courses and tool access. [P2], [P3], [P6] +- [ ] **Recheck launch and stale pages.** Use a reviewed destination for the canonical Stem app and its existing safe sign-in/return flow; do not invent a Doorway app mount or expose credentials in a URL. Recheck eligibility at the server launch boundary and independently in Stem under PR2. Keep personalized responses private/no-store; refresh eligibility on return/back-forward-cache restoration and after sign-in/out or known enrollment changes. A stale open tab, copied URL or cached positive result never grants access after revocation. An unavailable authority fails closed for this feature while retaining unrelated destinations. +- [ ] **Coordinate the receiving app and active work.** Document the minimum contract/configuration revisions for both repositories and the receiver-first release sequence above, including fail-closed behavior while any new authority is unavailable. Recheck the portal's open work, especially PR #279 (session callback recovery) and PR #292 (instructor/API quota reads), before implementation. Keep the portal link text in the exact requested case; remove any applicable uppercase styling without restyling unrelated tools. The Stem-side access enforcement, product naming and classroom UI belong in PR2, not an uncounted fourth PR. + +**Main files:** `apps/doorway/src/access-destinations.ts`, `owner-ui.ts`, `my-classes.ts`, `welcome.ts` and the narrow existing configuration/launch boundaries; when needed `packages/admission-contract/src/`, `apps/admission/src/index-self.ts` or the appropriate private RPC, and `registry-access.ts`. Extend the My Classes browser and Worker boundary tests. Read the root and affected-app `AGENTS.md` before implementation. [P1], [P2], [P3], [P4], [P5], [P6], [P8] + +### Acceptance tests + +- [ ] **P1 — Exact-course eligibility:** active target-class participant sees exactly one correctly cased link in the requested section. Unenrolled, unrelated-course-only, scheduled/not-yet-started, ended, expired, withdrawn/revoked, suspended and signed-out fixtures do not gain access. Multi-course enrollment does not replace the exact-class check. Display-name collisions, forged browser fields and tampered course IDs cannot grant it; class owner/instructor status alone does not satisfy the student-link predicate. +- [ ] **P2 — Session and cache transitions:** fresh and resumed sessions produce the same current result; expiry, logout/relogin as a second account, revocation while the page is open, browser Back/forward restore, stale cookies/cache and authority timeout remove or disable stale eligibility. A stale visible link and direct/copied Stem URL still fail at the receiver. No cross-account cached launch or enrollment data survives sign-out. +- [ ] **P3 — Complete authority and boundaries:** a valid target enrollment beyond the summary's first 50 records still works through the exact-class resolver. Exercise start/end instants, empty and malformed authority responses, stale revisions, unavailable service, and owner/participant distinctions. Redirects preserve only approved return paths; neither subjects, JWTs nor session tokens enter browser URLs or logs. +- [ ] **P4 — Student landing and policy preservation:** an enrolled participant reaches the destination without a successful owner-roster request, and cannot access owner APIs. The three original links remain in place with their prior policies; welcome/other portal pages gain no unconditional Stem link. Verify keyboard access, mobile wrapping and computed text casing. PR1/PR2 integration covers denied direct API/upload/model/media requests after student enrollment ends, while retaining separately authorized instructor/admin paths and the existing anonymous audio-only policy. + +**Risks and dependencies:** hiding a link provides no access control. The receiver, not browser UI, must reject unauthorized student work. The exact class ID and linked instructor are verified; real student/MFA acceptance and the new receiving implementation remain later gates. This PR may include an additive service contract in this repository; it authorizes no live roster, binding or shared-service change now. + +## PR 2 — Course permissions, collaboration and compact UI + +**Suggested title:** `Add course-authorized classroom work and a compact Stem Splitter interface` + +### Build checklist: course access and collaboration + +- [ ] **Establish course authorization and enforce the portal restriction.** Consume PR1's supported exact-class contract through a small typed course-access module. Require current active enrollment in Agustina's verified class for protected student workspace access and actions, including direct URLs, API/upload/import/model requests and protected reads; a visible or previously successful portal link is never authority. Resolve instructor-course assignments independently so legitimate teaching/administrative paths keep their reviewed scope. Keep app admission/suspension checks and the existing anonymous audio-only projection. Deny on unavailable or stale authority, and validate requested course IDs on every request; a client-selected ID is never permission. +- [ ] **Bind work to a course deliberately.** Add additive schema/migrations for explicit split-course associations, sharing policy and necessary indexes. Keep stable account ownership. Offer a clear course selector for multi-course students; distinguish personal work. Associate only newly created work after the course disclosure is acknowledged; do not backfill existing records. +- [ ] **Add the instructor roster entry point.** On the Instructor page, add a button leading to the instructor's permitted course selector/roster. Include enrolled students with zero splits, preferred display names, paginated split lists, status, dates, retained audio and conversation links. Scope every count, search, cursor, detail and media download to the authorized course; avoid a global “all students” query or one query per roster row. +- [ ] **Provide instructor conversation review.** Add a separate course-authorized, read-only review path; do not weaken the existing owner route or give admins a blanket transcript bypass. Explain student visibility before new course work is shared. No instructor view may generate a new answer, spend allowance, edit a student's conversation or expose it to classmates/public links. +- [ ] **Make “all conversations” honest.** The current 60-entry replacement document cannot recover overwritten history. Add bounded, paginated durable messages for new course conversations, with message IDs, revision/concurrency checks and provenance; do not import private historical entries. Separate student text from server-produced assistant/tool results. Keep reset/retention behavior explicit and consistent across student/instructor views; no hidden transcript copy remains in replay caches or accounting records after reset. +- [ ] **Scope prompts to the selected course.** Replace the singleton amendment assumption with a course-keyed setting and append-only course revision history. Keep the code-owned base prompt read-only. Preserve required change notes, optimistic revisions, no-op behavior and audit history. Key guide caches by job, course, base version, course revision and effective fingerprint; reject stale generation after a course prompt changes. Personal/unassigned work uses the base prompt and no guessed course amendment. +- [ ] **Share folders within authorized courses.** Add server-enforced folder/course grants and read/comment permissions, a clear sharing control, revocation and a stable course link. Adding an item must independently verify its course and the caller's authority. Folder access must not reveal a private item, its title, notes, transcript or audio through a different route. Preserve expired-item placeholders without extending retention. +- [ ] **Attribute each new note.** Persist the verified author subject plus a bounded, escaped display-name snapshot from the trusted SSO preferred-name mapping. The pinned identity package currently exposes optional `name`; verify that mapping rather than assuming a `preferred_name` claim exists. Use a neutral “Student” fallback for a known author with no usable name. Historical notes with no author show “Author unavailable”; never assign them to the current viewer/owner. Listening Guy-created notes record the initiating student and optional assistant provenance. +- [ ] **Define collaboration permissions.** Course viewers may read only the shared material; permitted commenters may add notes as themselves. Authors edit/delete their own notes; any instructor moderation must be explicit, course-scoped and audited. Peers cannot impersonate authors, delete a split, reset someone else's chat or alter the course prompt. Keep existing anonymous links audio-only unless the owner separately changes that policy. +- [ ] **Document the access contract.** Centralize checks for roster, split, folder, note, conversation, prompt, export and media paths. Recheck authorization on every request and invalidate/reject cached access after revocation. Keep roster/operational logs free of transcript bodies, raw JWTs and unnecessary personal identifiers. + +**Main files:** `src/identity.ts`, `src/index.ts`, `src/assistant/index.ts`, `src/teacher/auth.ts`, `schema.sql`, additive migrations after the existing `0020`, `public/teacher.html`, `public/teacher.js`, `public/app.js`. Extend the local signed-identity/Admission fixtures and ownership/sharing tests. [S1], [S2], [S3], [S4] + +### Acceptance tests: course access and collaboration + +- [ ] **C1 — Access matrix:** two courses, two instructors, a multi-course student, an unrelated student, an admin, an anonymous visitor, and expired/disabled/revoked users. Course A instructor sees all retained course-A work and zero course-B/personal work. App Instructor alone grants no roster/transcript access. An otherwise admitted student outside the exact target class cannot bypass the portal through the Stem origin, copied URL or API. Test list/search/count/detail/export/audio, upload/import/model actions, enrollment revocation with a valid app session, and tampered/encoded paths and pagination cursors. Preserve unrelated tool policies and public audio-only access. +- [ ] **C2 — Roster:** a student with zero splits appears; more than one page of students/splits remains stable and has no duplicates. Finished, pending, failed, unavailable and expired items have accurate states. A query-count assertion prevents an N+1 roster implementation. +- [ ] **C3 — Conversations:** authorized instructor opens a student's course transcript without any model call or quota charge. Peer/public/other-course requests cannot retrieve it. More than 60 new entries paginate without loss; duplicate writes are idempotent and stale writes return a recoverable conflict. Reset, student withdrawal and 90-day expiry follow the approved visibility policy. +- [ ] **C4 — Prompts:** course A edit changes only A's next request/cache; B and personal work retain their own policy. Two instructor saves conflict correctly, a no-op creates no revision, and a stale generation cannot publish under a newer prompt revision. Instructor cannot edit the base prompt or forge course/history fields. +- [ ] **C5 — Folders:** course members can open permitted items after sign-out/sign-in; outsiders cannot. A cross-course/private item cannot be inserted to broaden access. Removing a grant invalidates the shared URL's protected reads immediately; expired audio remains unavailable. +- [ ] **C6 — Notes:** two students' notes show their own verified preferred names across browsers and exports. Ignore forged author fields; escape markup/control characters. Missing names and historical unknown authors render distinctly. Assistant-created notes preserve the initiating author; unauthorized edit/delete/moderation fails. +- [ ] **C7 — Migration:** fresh schema and upgraded schema behave identically; an interrupted/replayed backfill does not duplicate assignments or broaden access. Retention purges new conversation/grant/cache data as designed. Rehearse disabling the feature while preserving existing owner-only access and approved new data. + +**Risks and dependencies:** this intentionally changes instructor access to student material. The present code says transcripts are private even from admins, so disclosure/backfill cannot be a blanket role-check bypass. Course identity and preferred-name provenance must come from trusted services. Do not claim the current transcript store is a complete, tamper-proof conversation archive. [S1], [S2] + + + +### Compact UI and shared-split refresh (included in PR2) + +#### Build checklist + +- [ ] **Use the requested copy.** Change “Or paste a YouTube link” to **“Paste a link to fetch audio”**. Use **“Split apart a song, listen one layer at a time, annotate as you go.”** as the tagline. Keep supported-source guidance/validation honest; the broader label is not a promise of arbitrary URL support. +- [ ] **Use “Stem Splitter” throughout the product.** Apply the requested casing to **every visible occurrence**, explicitly including the **application main/home page**, its masthead and headings, all other application screens, browser/page titles, navigation and links, buttons, account and instructor screens, roster/folder/shared views, sign-in/out recovery, loading/empty/error states, accessible names and generated user-facing/export copy. Include the tools portal wherever the product appears; its new link is **Open Stem Splitter**. Audit both static markup and runtime-generated strings. Update the runtime station metadata so initialization or switching back to the splitter cannot restore uppercase text. Remove or override `text-transform: uppercase` on affected product/navigation controls, including inherited/shared button rules; changing HTML alone is insufficient. Preserve technical identifiers such as audiences, environment variables, route IDs and database names. [S13], [S14], [S15] +- [ ] **Compact the masthead.** Use two compact desktop rows: title at left and the CUNY AI Lab logo flush right on the brand row; tagline at left and My account flush right on the tagline row. Keep title/brand order recognizable. Wrap to a readable mobile layout without crowding sign-in, long names or the title; preserve the logo's existing destination. +- [ ] **Unify button styling.** Define shared base/control-size tokens for action links and buttons across home, account, instructor, roster, folder, import and mixer surfaces. Match height, typography, radius, borders, padding and interaction states. Equalize sibling action widths where helpful; avoid forcing play buttons and every long label into one width. Retain distinct danger/toggle states, visible keyboard focus, accessible names and comfortable hit areas. +- [ ] **Promote return navigation.** Add a prominent **Back to Splitter** control near the top of every non-home application page, including account, instructor, roster/folder details, recovery and sign-out states. Make it usable by keyboard and ensure compact mobile placement. Preserve current draft/unsaved-change behavior. +- [ ] **Add shared-split Refresh.** Use the appropriate protected course endpoint or existing public audio endpoint, depending on sharing mode. Fetch current metadata/permissions/notes and merge updates without reloading or reconstructing audio. Keep play position, speed, mute/solo, volume, loop, focus and unsaved note drafts. Show progress, last-updated and a calm failure state; block duplicate clicks, reject stale responses, and handle expired/revoked access. Public refresh must not fetch private notes or conversations. [S9] +- [ ] **Add bottom support.** Place **Having trouble?** at the very bottom of the application content between two horizontal rules, using the verified Lab email route. Keep copyright/retention notices and navigation readable. Do not auto-attach account details, conversation text or source URLs to support mail. +- [ ] **Apply economical spacing consistently.** Refine headers, section gaps, form rows and control groups alongside this PR's course features. PR3 must reuse these controls and review its new usage/recovery states against them. Include instructor roster/prompt states and usage/recovery states in the visual review, not just the homepage. Update asset cache versions on all affected entry pages so returning users receive one consistent stylesheet. + +**Main files:** `public/index.html`, `public/app.js`, `public/styles.css`, `public/account.html`, `public/account.js`, `public/teacher.html`, `public/teacher.js`, new course views in this PR and recovery markup in `cloudflare/sso.ts`. Preserve permission semantics while changing presentation. [S9], [S10], [S11] + +#### Acceptance tests + +- [ ] **U1 — Responsive layout:** inspect 320/360/390/414/540/768/1024/1440px plus 200% native browser zoom where supported and a separately labeled CSS-zoom check. No horizontal overflow, clipped buttons, logo/account collision or inaccessible controls. Include long student/course names and validation/error states. +- [ ] **U2 — Preserve PR27:** retain the existing annotation test for positive gap above the speed row, input/SAVE fit, initial focus, duplicate-open/Escape, repeated save/reload, all speeds and playback while typing. Exercise a shared course split with multiple author names too. Do not restore the negative-margin collision. [S11] +- [ ] **U3 — Refresh without interruption:** two signed-in course members add/delete permitted notes; Refresh retrieves changes while audio clocks keep advancing. Position, speed, mute/solo, loop, volume and typed draft remain unchanged. Test offline, delayed/out-of-order response, repeated clicks, revoked sharing, expiry and public audio-only mode. +- [ ] **U4 — Navigation and support:** top Back to Splitter works on every non-home page and preserves the correct safe return path. Footer help sits between both rules, opens the verified destination, and adds no personal data. Keyboard focus order is logical at desktop/mobile widths. +- [ ] **U5 — Control states and accessibility:** compare enabled/disabled/loading/pressed/danger/focus states across pages. Require at least 44px hit height for primary touch actions and at least 24px targets with adequate separation for compact mixer controls; verify readable contrast, reduced motion and forced-colors behavior. Similar actions have consistent sizes without sacrificing labels or expanding page height unnecessarily. +- [ ] **U6 — Browser regression:** Chrome, Firefox and WebKit cover the new screens, public/course shares, quota messages, refresh and the existing playback/seek/mute/download/reload flows. Capture real screenshots and review them; passing bounding-box assertions alone is insufficient. + +- [ ] **U7 — Product casing:** inspect every visible product-name occurrence on the main/home page and all other application and portal surfaces, including headings, navigation, instructor screens, accessible names, browser titles, loading/error states and generated credits/download copy. Product labels read **Stem Splitter** and the portal link reads **Open Stem Splitter** at every viewport. Check computed `text-transform` through inherited/shared styles, runtime initialization and supported station-switching paths; screenshot review must show the requested casing. Technical identifiers remain stable. + +- [ ] **U8 — Seek directly on every waveform:** each track waveform in every song supports pointer click/drag, touch and named keyboard controls. Reuse the main slider's canonical preview/commit/cancel path and synchronized audio clocks; preserve playing/paused state and playback rate. Cover unloaded/invalid durations, both bounds, off-element release/cancellation, repeated rapid seeks, switching tracks/jobs, duplicate-listener and stale-timer prevention, narrow screens and actual native zoom in Chrome, Firefox and WebKit. Keep all prior slider, playback and PR27 regression checks. + +**Risks and dependencies:** global CSS selectors can regress specialist mixer controls and PR27. Refresh must use the right authorization projection and must not create a second audio engine. Compact layout is not permission to reduce touch targets or hide support/navigation. Course states ship with their authorization in this PR; PR3 adds and verifies the later accounting states using these same components. + +## PR 3 — Correct allowances, reliable imports and model fallback + +**Suggested title:** `Count successful splits and make upstream failures recoverable` + +### Added guest and observability work + +- [ ] **Separate guest authority:** implement a signed, high-entropy Secure/HttpOnly/SameSite guest cookie with server-side expiry/revocation and separate guest ownership records. Do not fabricate CUNY subjects or insert guest identities into `app_users`. Allow only the guest's own private app scope, same-origin writes and explicitly public audio projections. Existing member cookies always use the verified SSO path; no automatic guest downgrade after invalid, expired, suspended or revoked membership. Course prompts, course-assigned work, rosters, instructor/admin APIs and other people's transcripts remain inaccessible. +- [ ] **Guest 5/25 successful-use accounting:** reuse the durable reservation/settlement/replay mechanism for five successful splits and 25 human Listening Guy inputs per guest per UTC day. Failed work without usable output releases once; partial output counts once; ambiguous provider starts cannot be blindly repeated. Enforce independent attempt/cookie-reset abuse protection and shared concurrency limits. Explain that an anonymous browser session cannot establish a unique human; an IP is not ownership. Show remaining/in-progress/completed counts and the reset, with sign-in offered for the separate eligible-member allowance. +- [ ] **Guest model and bot-verification boundary:** use an existing documented Gateway application-principal capability only after a separately approved sponsor grant/credential and bot-verification configuration. Do not repurpose a person's JWT, revive the old OpenRouter key, bypass Gateway policy or change shared Admission. Local fixture tests may exercise the complete opt-in path; missing authority/configuration fails closed. Record concrete activation gates without pretending guest use is already deployed. The September 29 record shows guest access was left unimplemented pending private ownership and bot verification; its older 3-attempt policy is superseded by this explicit 5-success/25-input request. +- [ ] **Redacted operation and use events:** use existing authorized D1/Worker logging only. Record meaningful app visits, import/split/chat/guide lifecycle, playback/seek/download and annotation outcomes with correlation/event/operation IDs, duration, bounded error category, model/fallback and quota effect as applicable. No raw audio, chat text, credentials, signed/source URLs, names or unnecessary student identifiers. Do not send data to a new external telemetry destination. +- [ ] **Event retention, reliability and access:** retain detailed use events for a bounded documented period (proposed 30 days), separately from the minimal operation evidence needed for unresolved reconciliation. Critical server settlement events are durable and deduplicated; browser activity is best effort, validated/rate-limited and deduplicated. State possible losses/replays and avoid claiming perfect capture. Keep event reads access-controlled and provide a useful documented way to assess outcomes and improvement areas. +- [ ] **Guest and event acceptance:** test fresh/resumed/expired/tampered/replayed guest sessions, logout/login transitions, two guests and member isolation, forbidden course paths, simultaneous fifth/sixth splits and 25th/26th inputs, midnight, cookie-reset abuse controls, failure releases, partial/unknown output, signed-out UI and event redaction/deduplication/retention. Real bot verification, sponsor entitlement and production activation remain separate evidence. + +### Build checklist: accounting + +- [ ] **Replace attempt counting with explicit settlement.** Set the student allowance to 15 successful splits per UTC submission day across courses. Validate identity, course access, body, URL and model before reserving capacity. Atomically require `successful + active reservations <= 15`; show “completed”, “in progress” and “available” separately so pending work is not described as used success. +- [ ] **Track one durable operation through every stage.** Use a stable idempotency key bound to subject, authorized scope and request fingerprint; reject the same key with different input. Tie the reservation, import, separation prediction, job and settlement together. Commit one success only after valid, decodable stems are durably stored and the job reaches `done`. Duplicate submit, reconnect, webhook, poll and rerun of the same operation must not charge twice. +- [ ] **Release failed work without erasing cost evidence.** Invalid links, failed YouTube fetches, failed provider starts/processing, canceled operations and terminal invalid audio do not consume successful-split allowance. Refund/release exactly once using an auditable event/state transition. Provider failures may still cost money: retain separate cost/attempt records and abuse controls; do not call this a provider-billing refund. +- [ ] **Handle unknown outcomes and restarts.** A lost provider-start response is not proof that no job started. Persist an uncertain state, reconcile using available provider evidence and avoid blind resubmission. Leases need expiry, a recovery owner and fencing against late completion; do not free a reservation while a late callback can still commit success without rechecking capacity. Settling an old operation never debits the new day or produces negative counters. +- [ ] **Add the 50-input guard.** Enforce the agreed Listening Guy rule server-side even with `assistantTransport` enabled. Reserve once per human message, settle once across retries/fallbacks/tools, and preserve Gateway's separate monetary allowance. Cached reads, instructor transcript review and automatic opening guides do not consume a human-input slot. Bound automatic guide generation independently so cache races cannot bypass cost controls. +- [ ] **Update account and failure states.** Show the two app counters and reset time distinctly from estimated Lab model budget. Failed split/import returns leave the success balance unchanged and display a useful next action. Keep HTTP/status semantics and operator correlation IDs while removing raw provider errors, internal hosts and secrets from student-facing text. + +### Build checklist: import reliability + +- [ ] **Diagnose the reported regression before replacing a provider.** Record a sanitized failing/working pair, canonical video ID, request correlation, UTC time, failure stage, provider status, pinned extractor/runtime versions and sequential/concurrent results. Distinguish URL/validation, account or quota, network/region, source restrictions, fetcher build, provider capacity/credit, media conversion and application timeout/storage faults. No failing example was supplied or reproduced in this planning turn. +- [ ] **Address concrete source weaknesses.** `replicate-yt-audio/cog.yaml` currently leaves `yt-dlp` unpinned and declares FFmpeg but no JavaScript runtime/EJS dependency; `_run()` has no subprocess timeout. Verify the running image separately from this source, then pin/test compatible dependencies, bounded subprocess execution, cancellation and cleanup. The fetcher already allocates a unique temporary directory; preserve that isolation and test concurrent cleanup rather than assuming a filename collision. [S6], [S7], [E4] +- [ ] **Respect source restrictions.** Audit the existing client-rotation loops and stop on explicit challenge/authentication/private/age/region/DRM/removed-content denials. Do not add cookie harvesting, CAPTCHA workarounds, proxy rotation, identity disguise or retries designed to defeat a block. Maintain supported extractor compatibility for permitted sources. Neither a third-party fetcher nor YouTube Data API is a guarantee or permission to download every URL. [E4], [E5] +- [ ] **Coordinate requests durably.** Add a shared import coordinator/queue with per-user fairness, provider-wide concurrency, bounded queue length and separate fetch/separation capacity. The existing 30/minute IP limiter is not a concurrency limit. Persist queued/importing/separating/reconciling/terminal state so closing a tab or restarting a Worker does not lose work. Select conservative numeric caps from provider/account evidence; local fragment-download settings do not cap app-wide requests. +- [ ] **Coalesce safe duplicates and cache selectively.** One authorized logical operation gets one upstream start. Use distributed single-flight for identical permitted fetches within the approved authorization scope; concurrent waiters have bounded deadlines and cancellation rules. Reuse only authorized stored bytes, with ACL checks, provenance and expiry no later than the original 90-day retention/rights boundary. Do not cache signed URLs, cookies, failed HTML/challenges or unknown-permission content, and do not leak another course's activity through cache responses. +- [ ] **Make retry policy precise.** Retry only classified transient failures, with a total deadline, capped attempts, exponential backoff/jitter and a shared cooldown. Honor `Retry-After` seconds and HTTP dates without shortening the server's wait; defer in the queue if it exceeds the interactive deadline. The current separator retry caps a wait at 15 seconds, which can retry too early. Never multiply retries across browser, Worker and provider layers or repeat an ambiguously accepted paid start. [S8], [E6], [E7] +- [ ] **Keep inputs and media bounded.** Reuse canonical YouTube parsing for watch/short/share/music/embed/live-link forms while rejecting actual live streams, playlists without a selected video, malformed IDs, misleading hosts, credentials/ports and unsupported schemes. Allowlisted redirects must not reach private/internal addresses; subprocess URLs stay separate arguments after `--`. Retain the 15-minute/100MB limits, verify decoded nonzero duration, correct container/audio and playable stems, and clean partial objects on terminal failure. +- [ ] **Offer useful permitted alternatives.** Keep local upload and authorized Archive sources. If authorized direct-download links are added, use an explicit provider/host policy plus SSRF/redirect controls; changing the label alone must not enable arbitrary fetches. For a blocked YouTube source, offer the original/licensed audio file or the user's own Studio/Takeout download. Do not promise universal YouTube success. [E5] + +### Build checklist: Listening Guy fallback + +- [ ] **Verify exact model identity and routing.** Preferred order is GLM-5.2 then DeepSeek-V4-Flash-0731. Z.ai documents `glm-5.2`; OpenRouter lists `z-ai/glm-5.2` and `deepseek/deepseek-v4-flash-0731`. DeepSeek direct currently documents `deepseek-flash` serving V4.1 and redirects older aliases, so that route is not proof of the requested dated model. The current Stem Gateway accepts canonical names without `/`; resolve its approved alias-to-provider mapping rather than putting provider-qualified slugs into the existing variable or bypassing Gateway. Verify account access and required streaming/tool behavior; do not silently substitute models. [S5], [E1], [E2], [E3] +- [ ] **Own fallback in one layer.** Use a configured ordered policy, persistent attempt IDs and shared capacity cooldowns. Preserve the same verified app/Gateway subject and course prompt; do not fall back on Admission denial, exhausted personal/app/Gateway allowance, invalid input or a non-retryable policy rejection. Distinguish a model/provider capacity 429 from a user-budget 429. Never override Gateway `should_retry: false` without a documented distinct fallback permission from its owner. +- [ ] **Bound and settle attempts.** On a confirmed eligible rejection before output, automatically try the backup once, within one logical input reservation and total deadline. Record the actual model and each provider attempt's outcome/cost; one student input is not necessarily one billable provider attempt. Do not issue a parallel speculative call. A timeout with uncertain acceptance needs cancellation/reconciliation rather than a blind duplicate. +- [ ] **Protect streams and tools.** Avoid mixing text from two attempts. Once content or tool effects are committed, do not silently regenerate or replay `add_note`/other actions; preserve partial text and offer a plain-language recovery path. Validate and execute tool calls at most once using operation/call IDs. Both-model failure yields a calm recoverable message, keeps the mixer working and settles usage under the agreed rule. This fallback affects Listening Guy, not YouTube fetching or Demucs separation. + +**Main files:** `src/daily-allowance.ts`, API middleware/job lifecycle in `src/index.ts`, new focused operation/settlement/import modules, `src/youtube.ts`, `replicate-yt-audio/`, `src/separation/replicate.ts`, `cloudflare/gateway.ts`, `src/assistant/index.ts`, schema/migrations, account/import/chat UI state. Preserve the separation-backend seam and reviewed model pins; no blind `latest` upgrade. [S5], [S6], [S7], [S8] + +### Acceptance tests + +- [ ] **R1 — Daily limits:** 30 simultaneous split requests for one student across two Worker instances admit at most 15 unresolved/successful operations. A second student has an independent 15. Mixed success/failure settlement releases only failures and allows exactly that many replacements; 16th success cannot appear through callback races. Course switching, logout/relogin and multiple browsers do not reset the counter. +- [ ] **R2 — No failed-split charge:** malformed body/model/URL, failed YouTube fetch, provider 429/402/5xx, rejected/unknown start, failed/canceled processing, corrupt/empty audio and failed R2 ingestion each have an explicit expected ledger state. Terminal failures leave success count unchanged. A repeated failure/callback/refund cannot create credit or duplicate provider starts. +- [ ] **R3 — Time and recovery:** test UTC midnight, month/year boundaries, DST display, an operation crossing midnight, expired leases, worker crash, lost start response, duplicate/out-of-order webhook plus polling, retry after network loss, late success after cancellation and storage outage. Old reservations cannot charge/refund the wrong day or allow an unfenced late success. +- [ ] **R4 — Fifty inputs:** 60 simultaneous distinct human messages admit no more than 50 reservations; exact replay uses one. Test the 51st message, rollover, automatic guide/cache hits, tool calls, failed/partial replies, fallback and instructor review. One chat cannot create two quota charges or two notes. App and Gateway limits remain independently enforced. +- [ ] **R5 — Queue and duplicate control:** run a local 20-student fixture against at least two Worker/coordinator instances. Assert configured global/per-user fetch and split caps, fair progress, finite queue length and one upstream start per idempotent operation. Same-source coalescing preserves ownership and handles one waiter's cancellation without dropping authorized others. No production load test is implied. +- [ ] **R6 — Import fault matrix:** cover supported URL forms, shell/SSRF attempts, allowed/disallowed redirects, missing extractor dependencies, provider timeout/429/5xx, challenge/auth/region/removed denial, missing format, corrupt media, disk-full and cancellation/restart cleanup. Explicit denials stop attempts; transient retries obey cooldown and attempt/deadline bounds. A successful metadata fetch alone is not a successful audio import. +- [ ] **R7 — Retry timing:** injected `Retry-After: 90`, HTTP-date, missing and malformed headers; assert no retry before valid server deadlines and no shortened wait from the old 15-second cap. Confirm retry budgets do not multiply across layers and a known provider ID is polled instead of recreated. +- [ ] **R8 — Fallback:** primary success makes one attempt; eligible primary capacity rejection makes exactly one ordered backup attempt; quota/auth/policy denial makes none. Missing/mis-mapped backup fails configuration checks. Test both failures, timeout before/after accepted start, cancellation, first-token/trailing-stream failure, malformed tool calls and duplicate tool IDs. Student UI never displays raw provider output or suggests that model switching fixes audio import. +- [ ] **R9 — Migration/rollback:** preserve existing attempt records as historical attempt evidence, not manufactured successful charges. Rehearse conversion/start date, queue draining and rollback with in-flight work; an old ten-attempt reader must not reinterpret the new success ledger. Document reconciliation and disable switches before any production migration. + +**Risks and dependencies:** refunding student allowance does not reverse provider spend. Unbounded retries or failed-job refunds without independent abuse controls can create unlimited cost. Cancellation and lost POST responses require explicit uncertain states. Source compatibility, permission, geographic availability and provider capacity remain separate from an application defect. No current production root cause or exact account-level backup entitlement has been established by this planning pass. + +## Shared completion and release gates + +- [ ] For each PR, use an isolated branch from its repository's current reviewed lane: portal `main`, Stem `codex/cloudflare-migration`. Recheck active work and owner chats, preserve owner edits and keep the PR description scoped to final behavior. These are three planned PRs; none is created, merged or deployed by this document update. +- [ ] Keep business rules in focused shared modules; Cloudflare code adapts identity/Gateway/storage. Avoid spreading new course, quota and retry logic through unrelated route branches. Do not introduce a second password/roster authority or bypass the reusable session contract. +- [ ] Add migrations and canonical `schema.sql` together; validate fresh and existing databases using local/test fixtures. Document data classification, indexes, retention, feature-disable behavior and rollback compatibility. Production migrations need a reviewed backup/recovery plan and separate action authorization. +- [ ] **Stem PR2/PR3 verification:** run meaningful changed-path tests plus repository-required checks: `bun run test`; from `cloudflare/`, `bun run typecheck`, `bun run test`, the full Chrome suite, Firefox `playback.spec.mjs`, targeted WebKit changes, production dependency audit and the candidate dry run. Require normal GitHub Source gate, Pinned analysis image and Cloudflare checks. Update test counts only from completed runs. +- [ ] **Portal PR1 verification:** follow the root README and affected-app guides. Run changed My Classes browser tests and Node/Vitest Worker boundary tests (not `bun test` for `createTestHarness` files), Doorway `check:format`, `check:lint`, `check:anti-slop`, `check:types` and dry `build`. If Admission/contracts change, also run affected Admission tests/static/build checks, `check:workspace`, callers/receivers and `test:integration`; broad shared changes require root `bun run check`. PR CI always runs the complete workspace gate. Do not describe local harness tests as production E2E. [P7], [P8] +- [ ] Before a later authorized release, test the exact merged tree, verify current CUNY account/bindings/retention, use an immutable archive and retain the previous Worker version. Compare live source marker and assets, then verify desktop/mobile behavior and existing audio playback. No deployment is part of this TODO-writing task. +- [ ] Separate fixture evidence from real acceptance: fresh CUNY MFA, real course roster/Agustina access, two-account shared work and exact-provider model behavior need appropriate approved test accounts/access. Paid inference, production load, credentials and changes to shared services require their own scoped authorization. Never describe local mocks as live classroom acceptance. + +## Existing backlog retained outside these three PRs + +Visitor paid runs/Turnstile, broad legacy-audio migration, Remixer promotion, server Auto/discovery/isolation promotion, new separation-model research and Railway retirement remain deferred. Do not fold them into this request. Preserve their historical plans in [the baseline TODO](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/TODO.md), `docs/archive/roadmap-through-2026-09-29.md`, `MIGRATION.md` and existing acceptance records. Reconcile old prose with actual receipts before asserting that a migration or deployment never happened. + +## Verified references + +Stem source links below are pinned to deployed `e1918da`, not the repository's unrelated default branch. Portal links are pinned to source `7371f738`, now matched to current Doorway and Admission Cloudflare deployment/version reads. Knowledge-base links are pinned to `e5cd54ad`; course ownership was separately rechecked against current Admission records at 17:12 UTC. Comments describing older class-wide/Railway behavior are historical; current CAIL route checks control. External documentation was checked October 6, 2026; recheck model availability when implementing. No live media fetch, model inference, roster change or production data write was performed for this plan. + +[S1]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/identity.ts#L37 +[S2]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L1722 +[S3]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/schema.sql#L310 +[S4]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/assistant/index.ts#L76 +[S5]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/gateway.ts#L9 +[S6]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/youtube.ts#L112 +[S7]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/replicate-yt-audio/predict.py#L23 +[S8]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/separation/replicate.ts#L36 +[S9]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/app.js#L2482 +[S10]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/sso.ts#L78 +[S12]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/daily-allowance.ts#L1 +[S11]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/playback.spec.mjs#L54 + +- Current quota and reservation timing: [daily-allowance.ts](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/daily-allowance.ts#L1), [middleware](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L173), [successful ingestion](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L1917). +- Public audio-only projection and folder authorization: [shared routes](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L206), [folder routes](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L670). +- Existing app-wide instructor copy and extractor build: [teacher.html](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/teacher.html#L37), [cog.yaml](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/replicate-yt-audio/cog.yaml), [pinned identity dependency](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/package.json). + +[S13]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/index.html#L27 +[S14]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/app.js#L3524 +[S15]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/styles.css#L1642 +[P1]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/access-destinations.ts#L1 +[P2]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/owner-ui.ts#L57 +[P3]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/my-classes.ts#L451 +[P4]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/packages/admission-contract/src/self.ts#L24 +[P5]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/admission/src/registry-access.ts#L109 +[P6]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/welcome.ts#L78 +[P7]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/README.md#L23 +[P8]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/AGENTS.md#L19 + +[K1]: https://github.com/CUNY-AI-Lab/cail-knowledge-base/blob/e5cd54ad3925c8a78f9e7bfddd508c8f8eb95036/07%20Teaching%20and%20Workshops/CALI%202%20T%28h%29inkering%20Tools%20%28Spring%202026%29.md#L18 +[K2]: https://github.com/CUNY-AI-Lab/cail-knowledge-base/blob/e5cd54ad3925c8a78f9e7bfddd508c8f8eb95036/05%20Infrastructure/CAIL%20Tools%20Admission%20Control.md#L73 +[K3]: https://tools.ailab.gc.cuny.edu/admin/mcp +[K4]: https://github.com/CUNY-AI-Lab/cail-knowledge-base/blob/e5cd54ad3925c8a78f9e7bfddd508c8f8eb95036/05%20Infrastructure/CAIL%20Doorway.md#L13 + +[E1]: https://docs.z.ai/guides/llm/glm-5.2 +[E2]: https://openrouter.ai/deepseek/deepseek-v4-flash-0731 +[E3]: https://api-docs.deepseek.com/quick_start/pricing/ +[E4]: https://github.com/yt-dlp/yt-dlp/wiki/EJS +[E5]: https://developers.google.com/youtube/terms/developer-policies +[E6]: https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_limit_retries.html +[E7]: https://replicate.com/docs/topics/predictions/rate-limits + +- **Model identifiers:** [Z.ai API example][E1], [OpenRouter GLM-5.2](https://openrouter.ai/z-ai/glm-5.2), [OpenRouter dated DeepSeek][E2], [DeepSeek direct current model mapping][E3], [DeepSeek July 31 release history](https://api-docs.deepseek.com/updates/). Catalog presence does not establish CAIL Gateway routing or this account's entitlement. +- **Extractor/runtime compatibility:** [official yt-dlp repository](https://github.com/yt-dlp/yt-dlp), [EJS requirements][E4], [YouTube extractor notes](https://github.com/yt-dlp/yt-dlp/wiki/Extractors#youtube). Use these for supported maintenance, not access-control circumvention. +- **Permitted alternatives:** [YouTube API audiovisual restrictions][E5], [YouTube terms](https://www.youtube.com/static?template=terms), [download your own uploads](https://support.google.com/youtube/answer/56100?hl=en). The Data API is not an audio-download fallback; permitted original-file upload remains necessary. +- **Asynchronous work and retry limits:** [Replicate prediction lifecycle](https://replicate.com/docs/topics/predictions/create-a-prediction), [Replicate rate limits][E7], [bounded backoff/jitter guidance][E6]. These inform the design; they do not prove the current fetcher image or live account's behavior. diff --git a/cloudflare/README.md b/cloudflare/README.md index f5efdc6..6dacd03 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -1,5 +1,15 @@ # Cloudflare deployment +## Classroom candidate — October 6, 2026 + +The prospective course implementation and migration `0021` are described in +[the classroom access contract](../docs/classroom-access.md). This is source and +local/test evidence, not a deployment receipt. Historical work stays private; +production migration, Admission release, and live acceptance require their +separate release gates. `TODO.md` records the coordinated PR1–PR3 status. +The [local acceptance record](../docs/acceptance/classroom-2026-10-06.md) lists +test results, screenshots, migration rehearsal and dependency-audit limits. + ## Current authority — September 29, 2026 The canonical app is https://stem-splitter.ailab-452.workers.dev/. Release from diff --git a/cloudflare/account.spec.mjs b/cloudflare/account.spec.mjs index 3c4b9ff..a2c8804 100644 --- a/cloudflare/account.spec.mjs +++ b/cloudflare/account.spec.mjs @@ -94,11 +94,11 @@ test('Verified sessions return to Splitter directly; unavailable verification of available = true; await page.getByRole('link', { name: 'Retry', exact: true }).click(); await expect(page).toHaveURL(url.href); - await expect(page.getByRole('heading', { name: 'STEM SPLITTER', exact: true })).toBeVisible(); + 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.getByRole('heading', { name: 'STEM SPLITTER', exact: true })).toBeVisible(); + 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); @@ -135,7 +135,7 @@ test('Sign out submits a trusted origin, clears cookies and returns to Splitter' } 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' }); + 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); @@ -152,7 +152,7 @@ test('Sign out submits a trusted origin, clears cookies and returns to Splitter' await expect(page).toHaveURL(url.href); 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(); + await expect(page.getByRole('heading', { name: 'Stem Splitter', exact: true })).toBeVisible(); expect(errors).toEqual([]); } finally { await server.close(); } }); @@ -180,7 +180,7 @@ test('Account stays simple; administration is deliberate, responsive, and recove await context.setExtraHTTPHeaders({ 'x-fixture-identity': identities.carol }); await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(new URL('/account.html', url).href); - await expect(page).toHaveTitle('STEM Splitter · Account'); + 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'); @@ -206,22 +206,22 @@ test('Account stays simple; administration is deliberate, responsive, and recove await expect(page.getByRole('button', { name: 'Save changes', exact: true })).toBeHidden(); await page.getByRole('combobox', { name: 'Account', exact: true }).selectOption(TEST_SUBJECTS.alice); await expect(page.getByLabel('Instructor access ends', { exact: true })).toBeHidden(); - await expect(page.getByLabel('Allow access to STEM Splitter', { exact: true })).toBeChecked(); + await expect(page.getByLabel('Allow access to Stem Splitter', { exact: true })).toBeChecked(); await page.getByRole('combobox', { name: 'Access level', exact: true }).selectOption('instructor'); await expect(page.getByLabel('Instructor access ends', { exact: true })).toBeVisible(); await expect(page.getByLabel('Instructor access ends', { exact: true })).toHaveAttribute('required', ''); await expect(page.getByLabel('No end date', { exact: true })).not.toBeChecked(); await page.getByLabel('Instructor access ends', { exact: true }).fill('2026-12-01T12:00'); await page.getByRole('combobox', { name: 'Access level', exact: true }).selectOption('student'); - await page.getByLabel('Allow access to STEM Splitter', { exact: true }).uncheck(); + await page.getByLabel('Allow access to Stem Splitter', { exact: true }).uncheck(); const [update] = await Promise.all([ page.waitForRequest(request => request.method() === 'PUT'), page.getByRole('button', { name: 'Save changes', exact: true }).click(), ]); expect(update.postDataJSON()).toMatchObject({ role: 'student', disabled: true, expiresAt: null }); await expect(page.locator('#access-status')).toHaveText('Access updated.'); - await expect(page.getByLabel('Allow access to STEM Splitter', { exact: true })).not.toBeChecked(); - await page.getByLabel('Allow access to STEM Splitter', { exact: true }).check(); + await expect(page.getByLabel('Allow access to Stem Splitter', { exact: true })).not.toBeChecked(); + await page.getByLabel('Allow access to Stem Splitter', { exact: true }).check(); await page.getByRole('button', { name: 'Save changes', exact: true }).click(); await expect.poll(async () => (await page.request.get(new URL('/api/admin/users', url).href)).json().then(body => body.users.find(user => user.subject === TEST_SUBJECTS.alice).disabled)).toBe(0); diff --git a/cloudflare/browser.spec.mjs b/cloudflare/browser.spec.mjs index ba974b0..b6ff009 100644 --- a/cloudflare/browser.spec.mjs +++ b/cloudflare/browser.spec.mjs @@ -148,6 +148,7 @@ test('CAIL student, instructor and admin surfaces; one Crate and attribution-bea await expect(page.locator('#access-status')).toHaveText('Access updated.'); if (receipts) await page.screenshot({ path: `${receipts}/04-admin-access-fixture.png`, fullPage: true, animations: 'disabled' }); await context.setExtraHTTPHeaders({ 'x-fixture-identity': alice }); + await context.setExtraHTTPHeaders({ 'x-fixture-identity': alice, 'x-fixture-course-role':'owner' }); await page.goto(new URL('/teacher.html', url).href); await expect(page.locator('#console-panel')).toBeVisible(); await page.locator('#amendment').fill('Ask students to compare two layers.'); diff --git a/cloudflare/classroom.spec.mjs b/cloudflare/classroom.spec.mjs new file mode 100644 index 0000000..f79edf1 --- /dev/null +++ b/cloudflare/classroom.spec.mjs @@ -0,0 +1,263 @@ +import { test, expect, chromium } from '@playwright/test'; +import { createTestHarness } from 'wrangler'; +import { createTestIdentityIssuer, TEST_SUBJECTS } from '@cuny-ai-lab/cail-identity/testing'; +import { readFile, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { schemaStatements } from '../tests/e2e/schema-statements.mjs'; +const course='msh-245-the-american-musical-experience-fall-2026-01'; +const member=subject=>'stem-member-v1-'+createHash('sha256').update(`fixture:${course}:${subject}`).digest('hex'); +const screenshots=process.env.STEM_SCREENSHOT_DIR||'/tmp/stem-classroom-screenshots'; +function tone(){const rate=8000,n=rate*35,buffer=Buffer.alloc(44+n*2);buffer.write('RIFF');buffer.writeUInt32LE(buffer.length-8,4);buffer.write('WAVEfmt ',8);buffer.writeUInt32LE(16,16);buffer.writeUInt16LE(1,20);buffer.writeUInt16LE(1,22);buffer.writeUInt32LE(rate,24);buffer.writeUInt32LE(rate*2,28);buffer.writeUInt16LE(2,32);buffer.writeUInt16LE(16,34);buffer.write('data',36);buffer.writeUInt32LE(n*2,40);for(let i=0;i{const result=await server.fetch('/__fixture/schema',{method:'POST',headers:{'x-fixture':'local-only'},body:JSON.stringify(sql)});expect(result.status).toBe(200);}; + const sql=schemaStatements(await readFile(new URL('../schema.sql',import.meta.url),'utf8')); + sql.push(`INSERT INTO app_users(subject) VALUES('${TEST_SUBJECTS.alice}'),('${TEST_SUBJECTS.bob}')`, + `INSERT INTO jobs(id,filename,source_key,status,model,stems) VALUES('course-song','A long course recording title for responsive review','uploads/source.wav','done','htdemucs_ft','[{"name":"vocals","key":"stems/course-song/vocals.mp3"}]')`, + `INSERT INTO job_owners VALUES('course-song','${TEST_SUBJECTS.alice}')`, + `INSERT INTO job_courses(job_id,course_id,member_id,assigned_by,policy_version) VALUES('course-song','${course}','${member(TEST_SUBJECTS.alice)}','${TEST_SUBJECTS.alice}','course-work-v1')`, + `INSERT INTO course_folders(id,course_id,name,created_by,permission) VALUES('course-folder','${course}','Listening together','${TEST_SUBJECTS.alice}','comment')`, + `INSERT INTO course_folder_items(folder_id,job_id,filename,model) VALUES('course-folder','course-song','Course recording','htdemucs_ft')`, + `INSERT INTO annotations(id,job_id,at_seconds,text,author_subject,author_name,provenance) VALUES('first-note','course-song',3,'First student note','${TEST_SUBJECTS.alice}','Alex Rivera','student')`); + await seed(sql);await mkdir(screenshots,{recursive:true}); + await page.route('**/api/files/stems/course-song/*',fulfillTone); + return {server,url,alice,bob,gateway,seed}; +} +test('course share Refresh preserves live audio, controls and draft; protects names and handles revocation',async({page,context})=>{ + const f=await setup(page);const errors=[];page.on('pageerror',e=>errors.push(e.message)); + try{ + await context.setExtraHTTPHeaders({'x-fixture-identity':f.bob});await page.goto(new URL('/?job=course-song',f.url).href); + await expect(page).toHaveTitle('Stem Splitter');await expect(page.locator('.console')).toBeVisible();await expect(page.locator('.note-author')).toHaveText('Alex Rivera'); + await expect(page.locator('.coach')).toBeHidden();await expect(page.locator('.note-del')).toBeHidden(); + await page.locator('.note-btn').click();await page.getByRole('textbox',{name:'Note text',exact:true}).fill('Unsaved note draft'); + await page.evaluate(async()=>{const m=mixers.get('course-song');m.rate=.75;m.audios[0].playbackRate=.75;m.loop={start:0,end:30};await m.play();m.audios[0].volume=.6;}); + await expect.poll(()=>page.evaluate(()=>mixers.get('course-song').audios[0].currentTime)).toBeGreaterThan(.2); + const before=await page.evaluate(()=>{const m=mixers.get('course-song');window.originalAudio=m.audios[0];return {time:m.audios[0].currentTime,volume:m.audios[0].volume};}); + await f.seed([`INSERT INTO annotations(id,job_id,at_seconds,text,author_subject,author_name,provenance) VALUES('new-note','course-song',4,'New peer observation','${TEST_SUBJECTS.alice}','Morgan Chen','student')`]); + await page.locator('.refresh-btn').click();await expect(page.locator('.refresh-status')).toContainText('Updated');await expect(page.locator('.note-author')).toHaveText(['Alex Rivera','Morgan Chen']); + await expect(page.getByRole('textbox',{name:'Note text',exact:true})).toHaveValue('Unsaved note draft'); + const after=await page.evaluate(()=>{const m=mixers.get('course-song');return {same:m.audios[0]===window.originalAudio,time:m.audios[0].currentTime,rate:m.rate,loop:m.loop,volume:m.audios[0].volume,playing:m.playing};}); + expect(after.same).toBe(true);expect(after.time).toBeGreaterThan(before.time);expect(after.rate).toBe(.75);expect(after.loop).toEqual({start:0,end:30});expect(after.volume).toBe(before.volume);expect(after.playing).toBe(true); + let reads=0;let release;await page.route('**/api/jobs/course-song',async route=>{reads++;await new Promise(resolve=>{release=resolve;});await route.continue();}); + await page.locator('.refresh-btn').click();await expect(page.locator('.refresh-btn')).toBeDisabled();await page.evaluate(()=>mixers.get('course-song').refresh());expect(reads).toBe(1);release();await expect(page.locator('.refresh-btn')).toBeEnabled();await page.unroute('**/api/jobs/course-song'); + await page.route('**/api/jobs/course-song',route=>route.abort('failed'));await page.locator('.refresh-btn').click();await expect(page.locator('.refresh-status')).toContainText('Could not refresh');await expect(page.getByRole('textbox',{name:'Note text',exact:true})).toHaveValue('Unsaved note draft');await page.unroute('**/api/jobs/course-song'); + for(const width of [320,360,390,414,540,768,1024,1440]){await page.setViewportSize({width,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await expect(page.locator('h1')).toHaveText('Stem Splitter');expect(await page.locator('h1').evaluate(e=>getComputedStyle(e).textTransform)).toBe('none');} + await page.screenshot({path:`${screenshots}/classroom-refresh-desktop-${process.env.STEM_BROWSER||'chrome'}.png`,fullPage:true,animations:'disabled'});await page.setViewportSize({width:390,height:844});await page.locator('.console').screenshot({path:`${screenshots}/classroom-refresh-mobile-${process.env.STEM_BROWSER||'chrome'}.png`}); + await page.setViewportSize({width:1280,height:900});await page.evaluate(()=>document.documentElement.style.zoom='2');expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await page.evaluate(()=>document.documentElement.style.zoom='1'); + await context.setExtraHTTPHeaders({'x-fixture-identity':f.bob,'x-fixture-course-state':'revoked'});await page.locator('.refresh-btn').click();await expect(page.locator('.refresh-status')).toContainText('Access ended');await expect(page.locator('.note-text')).toHaveCount(0);expect(await page.evaluate(()=>mixers.get('course-song').playing)).toBe(false);expect(errors).toEqual([]); + }finally{await f.server.close();} +}); +test('instructor roster, course prompt, readonly conversations and folder grants render at desktop and mobile',async({page,context})=>{ + const f=await setup(page);const errors=[];page.on('pageerror',e=>errors.push(e.message)); + try{ + await context.setExtraHTTPHeaders({'x-fixture-identity':f.alice,'x-fixture-gateway-identity':f.gateway,'x-fixture-course-role':'owner'}); + await page.goto(new URL('/teacher.html',f.url).href);await expect(page).toHaveTitle('Stem Splitter · Instructor');await expect(page.locator('#console-panel')).toBeVisible();await expect(page.getByRole('link',{name:'Course roster and conversations'})).toBeVisible(); + await page.locator('#amendment').fill('Compare the voices in this course.');await page.locator('#change-note').fill('New listening exercise');await page.getByRole('button',{name:'SAVE',exact:true}).click();await expect(page.locator('#prompt-status')).toContainText('Saved'); + await page.screenshot({path:`${screenshots}/classroom-prompt-desktop-${process.env.STEM_BROWSER||'chrome'}.png`,fullPage:true,animations:'disabled'}); + 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); + 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); + await page.screenshot({path:`${screenshots}/classroom-roster-folders-desktop-${process.env.STEM_BROWSER||'chrome'}.png`,animations:'disabled'});await page.locator('#folder-detail').screenshot({path:`${screenshots}/classroom-folder-detail-${process.env.STEM_BROWSER||'chrome'}.png`});await page.setViewportSize({width:390,height:844});await page.screenshot({path:`${screenshots}/classroom-roster-folders-mobile-${process.env.STEM_BROWSER||'chrome'}.png`,fullPage:true,animations:'disabled'}); + await page.getByRole('button',{name:'Use in folder',exact:true}).click();await page.locator('#folder-name').fill('A new course folder');await page.getByRole('button',{name:'Create private folder',exact:true}).click();await page.getByRole('button',{name:'Add selected split',exact:true}).click();await expect(page.locator('#folder-items')).toContainText('A long course recording'); + await f.seed([`INSERT INTO course_conversations(job_id,subject,revision,expires_at) VALUES('course-song','${TEST_SUBJECTS.alice}',1,datetime('now','+1 day'))`,...Array.from({length:70},(_,i)=>`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) VALUES('page-${i}','course-song','${TEST_SUBJECTS.alice}','turn-${i}','${i%2?'coach':'you'}','${i%2?'server-assistant':'student'}','Saved message ${i}')`)]); + await page.getByRole('button',{name:'Read conversation',exact:true}).click();await expect(page.locator('#conversation-messages li')).toHaveCount(40);await page.getByRole('button',{name:'More messages',exact:true}).click();await expect(page.locator('#conversation-messages li')).toHaveCount(70); + await page.locator('.top-return a').click();await expect(page).toHaveTitle('Stem Splitter');await expect(page.locator('.compact-masthead h1')).toHaveText('Stem Splitter');expect(errors).toEqual([]); + }finally{await f.server.close();} +}); + +test('second-account transitions clear prior roster, prompt drafts and live split drafts',async({page,context})=>{ + const f=await setup(page); + try{ + await context.setExtraHTTPHeaders({'x-fixture-identity':f.alice,'x-fixture-course-role':'owner'}); + await page.goto(new URL('/classroom.html',f.url).href);await expect(page.locator('#roster-list')).toContainText('Zero split student'); + await context.setExtraHTTPHeaders({'x-fixture-identity':f.bob});await page.evaluate(()=>window.dispatchEvent(new Event('focus'))); + await expect(page.locator('#classroom-content')).toBeVisible();await expect(page.locator('#classroom-status')).toHaveText('Course access checked.'); + await expect(page.locator('#classroom-roster')).toBeHidden();await expect(page.locator('#roster-list')).not.toContainText('Zero split student'); + await page.close();page=await context.newPage(); + await context.setExtraHTTPHeaders({'x-fixture-identity':f.alice,'x-fixture-course-role':'owner'}); + await page.goto(new URL('/teacher.html',f.url).href);await expect(page.locator('#console-panel')).toBeVisible();await page.locator('#amendment').fill('Private unsaved course instruction'); + await context.setExtraHTTPHeaders({'x-fixture-identity':f.bob});await page.evaluate(()=>window.dispatchEvent(new Event('focus'))); + await expect(page.locator('#signin-panel')).toBeVisible();await expect(page.locator('#signin-panel')).toContainText('Current course ownership'); + await expect(page.locator('#console-panel')).toBeHidden();await expect(page.locator('#amendment')).not.toHaveValue('Private unsaved course instruction'); + await page.close();page=await context.newPage();await page.route('**/api/files/stems/course-song/*',fulfillTone); + await page.goto(new URL('/?job=course-song',f.url).href);await page.locator('.note-btn').click();await page.getByRole('textbox',{name:'Note text',exact:true}).fill('Previous account draft'); + await context.setExtraHTTPHeaders({});await page.evaluate(()=>window.dispatchEvent(new Event('focus'))); + await expect(page.locator('.console')).toHaveCount(0);await expect(page.getByText('Previous account draft',{exact:true})).toHaveCount(0); + }finally{await f.server.close();} +}); +test('native Chromium 200 percent browser zoom keeps the shared note and controls usable',async({page})=>{ + test.skip(Boolean(process.env.STEM_BROWSER),'Native zoom uses Chromium browser extension API; other engines retain layout coverage.'); + const f=await setup(page);const temp=await mkdtemp('/tmp/stem-native-zoom-');let context; + try{ + await f.seed([`UPDATE jobs SET stems='[{"name":"vocals","key":"stems/course-song/vocals.mp3"},{"name":"drums","key":"stems/course-song/drums.mp3"}]' WHERE id='course-song'`]); + await mkdir(`${temp}/extension`);await writeFile(`${temp}/extension/manifest.json`,JSON.stringify({manifest_version:3,name:'Local zoom fixture',version:'1.0',permissions:['tabs'],host_permissions:[''],background:{service_worker:'worker.js'}}));await writeFile(`${temp}/extension/worker.js`,'chrome.runtime.onInstalled.addListener(()=>{});'); + context=await chromium.launchPersistentContext(`${temp}/profile`,{channel:'chromium',headless:process.env.STEM_NATIVE_HEADFUL!=='true',viewport:null,args:[`--disable-extensions-except=${temp}/extension`,`--load-extension=${temp}/extension`,'--window-size=1280,1000']}); + await context.setExtraHTTPHeaders({'x-fixture-identity':f.bob});const zoomPage=await context.newPage();await zoomPage.route('**/api/files/stems/course-song/*',fulfillTone);await zoomPage.goto(new URL('/?job=course-song',f.url).href);await expect(zoomPage.locator('.console')).toBeVisible();await expect.poll(()=>zoomPage.evaluate(()=>mixers.get('course-song').audios[0].duration)).toBeGreaterThan(30); + const worker=context.serviceWorkers()[0]||await context.waitForEvent('serviceworker');const before=await zoomPage.evaluate(()=>({width:innerWidth,dpr:devicePixelRatio})); + const nativeZoom=await worker.evaluate(async origin=>{const tabs=await chrome.tabs.query({});const tab=tabs.find(t=>t.url?.startsWith(origin));await chrome.tabs.setZoom(tab.id,2);return chrome.tabs.getZoom(tab.id);},f.url.origin);expect(nativeZoom).toBe(2); + await expect.poll(()=>zoomPage.evaluate(()=>devicePixelRatio)).toBe(before.dpr*2);expect(await zoomPage.evaluate(()=>innerWidth)).toBeLessThan(before.width); + await expect.poll(()=>zoomPage.evaluate(()=>mixers.get('course-song').audios.every(audio=>audio.duration>30))).toBe(true); + const waveform=zoomPage.locator('.waveform-seek').last();await waveform.scrollIntoViewIfNeeded();const waveBox=await waveform.boundingBox(); + await zoomPage.mouse.click(waveBox.x+waveBox.width*.5,waveBox.y+waveBox.height/2); + await expect.poll(()=>zoomPage.evaluate(()=>{const audio=mixers.get('course-song').audios;return audio.length===2&&audio.every(track=>track.paused&&Math.abs(track.currentTime-17.5)<.4);})).toBe(true); + await waveform.press('Home');await waveform.press('ArrowRight'); + await expect.poll(()=>zoomPage.evaluate(()=>{const audio=mixers.get('course-song').audios;return audio.every(track=>track.currentTime>0&&track.currentTime<.1&&Math.abs(track.currentTime-audio[0].currentTime)<.01);})).toBe(true); + await zoomPage.locator('.note-btn').click();await zoomPage.getByRole('textbox',{name:'Note text',exact:true}).fill('Native zoom note');expect(await zoomPage.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + const form=await zoomPage.locator('.note-form').boundingBox(),speed=await zoomPage.getByRole('group',{name:'Playback speed'}).boundingBox();expect(speed.y).toBeGreaterThanOrEqual(form.y+form.height+4); + await zoomPage.locator('.console').scrollIntoViewIfNeeded();await zoomPage.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await zoomPage.evaluate(()=>document.querySelector('.console').scrollIntoView({block:'center'}));await expect(zoomPage.locator('.console')).toBeInViewport(); + const screenshot=await worker.evaluate(async origin=>{const tab=(await chrome.tabs.query({})).find(t=>t.url?.startsWith(origin));return chrome.tabs.captureVisibleTab(tab.windowId,{format:'png'});},f.url.origin); + await writeFile(`${screenshots}/classroom-native-browser-200-chromium.png`,Buffer.from(screenshot.split(',')[1],'base64')); + }finally{await context?.close();await f.server.close();await rm(temp,{recursive:true,force:true});} +}); + +test('author note edits survive Refresh and save under the original verified name',async({page,context})=>{ + const f=await setup(page); + try{ + await context.setExtraHTTPHeaders({'x-fixture-identity':f.alice});await page.goto(new URL('/?job=course-song',f.url).href);await page.getByRole('button',{name:'Edit note',exact:true}).click(); + const input=page.getByRole('textbox',{name:'Edit note text',exact:true});await input.fill('Unsaved author edit'); + await f.seed([`INSERT INTO annotations(id,job_id,at_seconds,text,author_subject,author_name,provenance) VALUES('edit-peer','course-song',5,'Peer changed this','${TEST_SUBJECTS.bob}','Morgan Chen','student')`]); + await page.locator('.refresh-btn').click();await expect(page.locator('.notes')).toContainText('Peer changed this');await expect(input).toHaveValue('Unsaved author edit'); + await page.locator('.note-edit-form').getByRole('button',{name:'Save',exact:true}).click();await expect(input).toHaveCount(0);await expect(page.locator('.note-text').first()).toContainText('Unsaved author edit');await page.reload();await expect(page.locator('.note-text').first()).toContainText('Unsaved author edit');await expect(page.locator('.note-author').first()).toHaveText('Alex Rivera'); + }finally{await f.server.close();} +}); + +async function addSecondCourse(page) { + const other = 'fixture-course-b'; + await page.route('**/api/classroom/courses', async route => { + const response = await route.fetch(), body = await response.json(); + body.courses.push({ ...body.courses[0], classId: other, className: 'Course B', section: '02', owner: true }); + await route.fulfill({ response, json: body }); + }); + return other; +} + +test('account rechecks conceal and disable private views through delay, outage and retry', async ({ page, context }) => { + const f = await setup(page); + try { + await context.setExtraHTTPHeaders({ 'x-fixture-identity': f.alice, 'x-fixture-course-role': 'owner' }); + await page.goto(new URL('/teacher.html', f.url).href); + await expect(page.locator('#amendment')).toBeEnabled(); await page.locator('#amendment').fill('Same-account private draft'); + let mode = 'delay', release; + await page.route('**/api/account', async route => { + if (mode === 'delay') { await new Promise(resolve => { release = resolve; }); await route.fulfill({ status: 503, json: { error: 'Unavailable' } }); } + else if (mode === 'offline') await route.abort('failed'); + else await route.continue(); + }); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(page.locator('main')).toBeHidden(); expect(await page.locator('main').evaluate(node => node.inert)).toBe(true); + await expect(page.locator('#session-check')).toContainText('Checking your account'); + await expect.poll(() => Boolean(release)).toBe(true); release(); + await expect(page.locator('#session-check')).toContainText('Could not verify your account'); + await expect(page.locator('main')).toBeHidden(); + await page.screenshot({ path: `${screenshots}/classroom-account-unavailable-${process.env.STEM_BROWSER || 'chrome'}.png` }); + mode = 'offline'; await page.locator('#session-check button').click(); + await expect(page.locator('#session-check button')).toBeVisible(); await expect(page.locator('main')).toBeHidden(); + mode = 'normal'; await page.locator('#session-check button').click(); + await expect(page.locator('main')).toBeVisible(); expect(await page.locator('main').evaluate(node => node.inert)).toBe(false); + await expect(page.locator('#amendment')).toHaveValue('Same-account private draft'); + await page.unroute('**/api/account'); + await page.goto(new URL('/?job=course-song', f.url).href); + await page.locator('.note-btn').click(); await page.getByRole('textbox', { name: 'Note text', exact: true }).fill('Same-account note draft'); + await page.locator('.play-btn').click(); await expect.poll(() => page.evaluate(() => mixers.get('course-song').playing)).toBe(true); + await page.route('**/api/account', route => route.fulfill({ status: 503, json: { error: 'Unavailable' } })); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(page.locator('main')).toBeHidden(); await expect(page.locator('#session-check button')).toBeVisible(); + expect(await page.evaluate(() => mixers.get('course-song').audios.every(audio => audio.paused))).toBe(true); + await page.unroute('**/api/account'); await page.locator('#session-check button').click(); + await expect(page.locator('main')).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Note text', exact: true })).toHaveValue('Same-account note draft'); + expect(await page.evaluate(() => mixers.get('course-song').playing)).toBe(false); + } finally { await f.server.close(); } +}); + +test('prompt loads, writes, preview and history remain bound to the selected course generation', async ({ page, context }) => { + const f = await setup(page); + try { + await context.setExtraHTTPHeaders({ 'x-fixture-identity': f.alice, 'x-fixture-course-role': 'owner' }); + const other = await addSecondCourse(page), holds = new Set(), releases = new Map(), writes = []; + let failB = true; + const record = id => ({ amendment: id === course ? 'A saved instructions' : 'B saved instructions', revision: 1, basePrompt: `Base for ${id}`, history: [], historyHasMore: true, historyNextBeforeId: 50 }); + await page.route('**/api/classroom/courses/*/**', async route => { + const path = new URL(route.request().url()).pathname, id = path.split('/')[4], method = route.request().method(); + const kind = path.endsWith('/preview') ? 'preview' : path.endsWith('/history') ? 'history' : method === 'PUT' ? 'save' : 'load'; + const key = `${id}:${kind}`; + if (method === 'PUT') writes.push({ id, body: route.request().postDataJSON() }); + if (holds.has(key)) await new Promise(resolve => releases.set(key, resolve)); + if (id === other && kind === 'load' && failB) { await route.fulfill({ status: 503, json: { error: 'Course B unavailable' } }); return; } + const body = kind === 'save' ? { changed: true } : kind === 'preview' ? { prompt: `Private preview ${id}` } : kind === 'history' ? { history: [{ settingsRevision: 0, changeNote: `Old history ${id}`, createdAt: '2026-10-06T12:00:00Z', updatedBy: 'Instructor', amendment: `Old ${id}`, basePromptVersion: 'v1' }] } : record(id); + await route.fulfill({ json: body }); + }); + const waitHeld = key => expect.poll(() => releases.has(key)).toBe(true); + const release = key => { holds.delete(key); releases.get(key)(); releases.delete(key); }; + await page.goto(new URL('/teacher.html', f.url).href); await expect(page.locator('#amendment')).toHaveValue('A saved instructions'); + await page.locator('#teacher-course').selectOption(other); + await expect(page.locator('#prompt-status')).toContainText('Course B unavailable'); await expect(page.locator('#amendment')).toHaveValue(''); + await expect(page.locator('#amendment')).toBeDisabled(); await expect(page.locator('#prompt-form button[type=submit]')).toBeDisabled(); + await page.locator('#prompt-form').dispatchEvent('submit'); expect(writes).toEqual([]); + failB = false; await page.locator('#prompt-reload').click(); await expect(page.locator('#amendment')).toHaveValue('B saved instructions'); + holds.add(`${course}:load`); await page.locator('#teacher-course').selectOption(course); await waitHeld(`${course}:load`); + await expect(page.locator('#amendment')).toHaveValue(''); await expect(page.locator('#preview-btn')).toBeDisabled(); + await page.locator('#teacher-course').selectOption(other); await expect(page.locator('#amendment')).toHaveValue('B saved instructions'); + release(`${course}:load`); await page.waitForLoadState('networkidle'); await expect(page.locator('#amendment')).toHaveValue('B saved instructions'); + holds.add(`${other}:preview`); await page.locator('#preview-btn').click(); await waitHeld(`${other}:preview`); + holds.add(`${other}:history`); await page.getByText('History', { exact: true }).click(); await page.locator('#prompt-history-more').click(); await waitHeld(`${other}:history`); + await page.locator('#teacher-course').selectOption(course); await expect(page.locator('#amendment')).toHaveValue('A saved instructions'); + release(`${other}:preview`); release(`${other}:history`); await page.waitForLoadState('networkidle'); + await expect(page.locator('#preview-wrap')).toBeHidden(); await expect(page.locator('#prompt-history')).not.toContainText(other); + await page.locator('#amendment').fill('A new instructions'); await page.locator('#change-note').fill('A update'); + holds.add(`${course}:save`); await page.locator('#prompt-form button[type=submit]').click(); await waitHeld(`${course}:save`); + page.once('dialog', dialog => dialog.accept()); await page.locator('#teacher-course').selectOption(other); + await expect(page.locator('#amendment')).toHaveValue('B saved instructions'); release(`${course}:save`); await page.waitForLoadState('networkidle'); + await expect(page.locator('#teacher-course')).toHaveValue(other); await expect(page.locator('#amendment')).toHaveValue('B saved instructions'); + expect(writes).toEqual([{ id: course, body: { amendment: 'A new instructions', changeNote: 'A update', expectedRevision: 1 } }]); + } finally { await f.server.close(); } +}); + +test('rapid course changes clear stale classroom content and keep later folder writes in the visible course', async ({ page, context }) => { + const f = await setup(page); + try { + await context.setExtraHTTPHeaders({ 'x-fixture-identity': f.alice, 'x-fixture-course-role': 'owner' }); + const other = await addSecondCourse(page), releases = [], writes = [], detailReads = []; + let delayB = true, failB = false, releaseWrite; + await page.route('**/api/classroom/courses/*/**', async route => { + const path = new URL(route.request().url()).pathname, id = path.split('/')[4], method = route.request().method(), label = id === course ? 'A' : 'B'; + if (method === 'POST') { + writes.push({ id, name: route.request().postDataJSON().name }); + await new Promise(resolve => { releaseWrite = resolve; }); + await route.fulfill({ json: { folder: { id: 'created-A' } } }); return; + } + if (/\/folders\//.test(path)) { detailReads.push(path); await route.fulfill({ json: { folder: { id: 'created-A', name: 'Created', permission: 'private' }, items: [] } }); return; } + if (id === other && delayB) await new Promise(resolve => releases.push(resolve)); + if (id === other && failB) { await route.fulfill({ status: 503, json: { error: 'Course B unavailable' } }); return; } + await route.fulfill({ json: path.endsWith('/roster') ? { participants: [{ memberId: `member-${label}`, displayName: `${label} student`, splitCount: 0, retainedCount: 0 }], nextCursor: null } : { folders: [{ id: `folder-${label}`, name: `${label} folder`, permission: 'private' }] } }); + }); + await page.goto(new URL('/classroom.html', f.url).href); await expect(page.locator('#roster-list')).toContainText('A student'); + await page.locator('#classroom-course').selectOption(other); await expect.poll(() => releases.length).toBe(2); + await expect(page.locator('#roster-list')).toBeEmpty(); await expect(page.locator('#course-folders')).toBeEmpty(); await expect(page.locator('#folder-create')).toBeHidden(); + await page.locator('#classroom-course').selectOption(course); await expect(page.locator('#classroom-status')).toHaveText('Course access checked.'); + delayB = false; releases.splice(0).forEach(release => release()); await page.waitForLoadState('networkidle'); + await expect(page.locator('#classroom-course')).toHaveValue(course); await expect(page.locator('#roster-list')).toContainText('A student'); + await expect(page.locator('#roster-list')).not.toContainText('B student'); await expect(page.locator('#course-folders')).toContainText('A folder'); + await expect(page.locator('#classroom-prompt')).toHaveAttribute('href', `/teacher.html?course=${course}`); + await page.locator('#folder-name').fill('A new folder'); await page.getByRole('button', { name: 'Create private folder' }).click(); await expect.poll(() => Boolean(releaseWrite)).toBe(true); + await page.locator('#classroom-course').selectOption(other); await expect(page.locator('#roster-list')).toContainText('B student'); + await page.locator('#folder-name').fill('B draft'); releaseWrite(); await page.waitForLoadState('networkidle'); + await expect(page.locator('#folder-name')).toHaveValue('B draft'); await expect(page.locator('#folder-detail')).toBeHidden(); + expect(writes).toEqual([{ id: course, name: 'A new folder' }]); expect(detailReads).toEqual([]); + await page.locator('#classroom-course').selectOption(course); await expect(page.locator('#roster-list')).toContainText('A student'); + failB = true; await page.locator('#classroom-course').selectOption(other); + await expect(page.locator('#classroom-status')).toContainText('Course B unavailable'); await expect(page.locator('#roster-list')).toBeEmpty(); + await expect(page.locator('#course-folders')).toBeEmpty(); await expect(page.locator('#classroom-prompt')).toBeHidden(); await expect(page.locator('#folder-create')).toBeHidden(); + } finally { await f.server.close(); } +}); diff --git a/cloudflare/classroom.test.ts b/cloudflare/classroom.test.ts new file mode 100644 index 0000000..8421c3a --- /dev/null +++ b/cloudflare/classroom.test.ts @@ -0,0 +1,220 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import app from '../src/index.ts'; +import { SqliteD1 } from '../server/d1.ts'; +import type { Env } from '../src/env.ts'; +import type { AppPrincipal } from '../src/identity.ts'; +import { STEM_COURSE_ID as A, validAccess, validAssignment, validPage, validFailure } from '../src/classroom/contract.ts'; +import { courseFixture } from './course-fixture.ts'; +import { beginCourseTurn, finishCourseTurn, conversationPage, resetCourseConversation, recoverExpiredCourseTurn } from '../src/classroom/conversations.ts'; +import { cacheGuideIfPromptCurrent, getGuide } from '../src/assistant/index.ts'; +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'; +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; +async function setup(){ + const db=new SqliteD1(':memory:');db.applySchema(readFileSync(new URL('../schema.sql',import.meta.url),'utf8')); + const revoked=new Set();let calls=0; + const assignments=async(subject:string,classId:string)=>{ + const role=(classId===A&&subject===ids.ownerA)||(classId===B&&subject===ids.ownerB)?'owner' + :[ids.alice,ids.bob].includes(subject as never)||classId===B&&subject===ids.outsider?'participant':null; + if(!role||revoked.has(`${subject}:${classId}`))return {ok:false,code:'not_enrolled',retryable:false}; + return {...await courseFixture(subject,classId,role==='owner'),displayName:subject===ids.alice?'Alice':'Student'}; + }; + const env={AUTH_MODE:'cail',PUBLIC_BASE_URL:'https://split.test',CAIL_COURSE_IDS:`${A},${B}`,DB:db, + verifyCailIdentity:async(token:string)=>Object.values(ids).includes(token as never)?{subject:token,name:'Ignored JWT fallback'}:null, + ADMISSION_RESOLVER:{resolveMembership:async({subject}:{subject:string})=>({ok:true,accessRole:subject===ids.admin?'admin':'member',budgetScope:subject===ids.admin?'admin':'person',revision:1,expiresAt:new Date(Date.now()+60000).toISOString()}), + resolveCourseAccess:async({subject,classId}:{subject:string;classId:string})=>assignments(subject,classId), + listCourseAssignments:async({subject}:{subject:string})=>{const a=await assignments(subject,A),b=await assignments(subject,B);return {ok:true,checkedAt:new Date().toISOString(),expiresAt:new Date(Date.now()+60000).toISOString(),revision:1,assignments:[a,b].filter(a=>a.ok).map(({ok,...row})=>row),nextCursor:null};}, + listCourseRoster:async({subject,classId,cursor}:{subject:string;classId:string;cursor?:string})=>{const authority=await assignments(subject,classId);if(!('owner'in authority)||!authority.owner)return {ok:false,code:'not_owner',retryable:false};const people=await Promise.all(Array.from({length:45},(_,i)=>courseFixture(i===0?ids.alice:`cail-${i.toString(16).padStart(32,'0')}`,classId)));const start=cursor==='next'?40:0;return {ok:true,classId,checkedAt:authority.checkedAt,expiresAt:authority.expiresAt,revision:1,participants:people.slice(start,start+40).map(({memberId,displayName,displayNameSource,startsAt,expiresAt})=>({memberId,displayName,displayNameSource,startsAt,expiresAt})),nextCursor:start===0?'next':null};}}, + ASSISTANT_MODEL:'fixture',assistantTransport:async(_env:unknown,input:{messages:Array<{content:string}>},delta:(text:string)=>Promise)=>{calls++;await delta('A server reply.');return {content:'A server reply.',model:'fixture',toolCalls:[],finishReason:'stop'};}, + } as unknown as Env; + const request=(path:string,who:Person='alice',method='GET',body?:unknown,headers:Record={})=>app.fetch(new Request(`https://split.test${path}`,{method,headers:{'x-cail-identity-jwt':ids[who],origin:'https://split.test','content-type':'application/json',...headers},...(body===undefined?{}:{body:JSON.stringify(body)})}),env); + for(const subject of Object.values(ids))await db.prepare('INSERT INTO app_users(subject) VALUES(?)').bind(subject).run(); + async function job(id:string,who:Person='alice',courseId:string|null=A){ + await db.prepare(`INSERT INTO jobs(id,filename,source_key,status,model,stems) VALUES(?,?,?,'done','htdemucs_ft','[]')`).bind(id,`${id} private title`,`uploads/${id}/source.wav`).run(); + await db.prepare('INSERT INTO job_owners(job_id,subject) VALUES(?,?)').bind(id,ids[who]).run(); + if(courseId)await db.prepare("INSERT INTO job_courses(job_id,course_id,member_id,assigned_by,policy_version) VALUES(?,?,?,?,'course-work-v1')").bind(id,courseId,(await courseFixture(ids[who],courseId)).memberId,ids[who]).run(); + } + return {db,env,request,job,revoked,calls:()=>calls,principal:async(who:Person='alice',courseId=A)=>({subject:ids[who],role:'student',displayName:'Alice',course:await courseFixture(ids[who],courseId),courseId} as AppPrincipal)}; +} +test('current target enrollment and independent course ownership protect direct APIs and course boundaries',async()=>{ + const {request,job,revoked,env}=await setup();await job('a');await job('b','alice',B);await job('personal','alice',null); + assert.equal((await request('/api/account','outsider')).status,403); + assert.equal((await request('/api/uploads','outsider','POST',{filename:'x.wav'},{'x-stem-course':B})).status,403); + assert.equal((await request('/api/classroom/courses/'+B+'/folders','outsider')).status,403); + assert.equal((await request('/api/jobs/b','alice')).status,200); + assert.equal((await request('/api/jobs/a','ownerA')).status,200); + for(const who of ['bob','ownerB','admin'] as Person[])assert.equal((await request('/api/jobs/a',who)).status,who==='bob'?404:403); + assert.equal((await request('/api/jobs/personal','ownerA')).status,404); + assert.equal((await request('/api/jobs/personal','admin')).status,200); + assert.equal((await request('/api/jobs/personal/listening-conversation','admin')).status,404); + assert.equal((await request('/api/classroom/courses/'+B+'/roster','ownerA')).status,403); + assert.equal((await request('/api/admin/users','ownerA')).status,403); + assert.equal((await request('/api/teacher/folders','ownerA')).status,403); + assert.equal((await request('/api/jobs','alice','POST',{youtubeUrl:'https://www.youtube.com/watch?v=fixture'},{'x-stem-course':A})).status,400); + assert.equal((await request('/api/account','ownerB')).status,200); + assert.equal((await request('/api/jobs/%61','alice')).status,400); + revoked.add(`${ids.alice}:${A}`); + for(const path of ['/api/jobs/a','/api/jobs/b','/api/jobs','/api/uploads','/api/jobs/a/chat','/api/files/stems/a/vocals.mp3'])assert.equal((await request(path,'alice',path.endsWith('chat')||path.endsWith('uploads')?'POST':'GET',path.endsWith('chat')||path.endsWith('uploads')?{}:undefined)).status,403); + env.ADMISSION_RESOLVER!.resolveCourseAccess=async()=>{throw new Error('private detail');};assert.equal((await request('/api/account')).status,503); + const anonymous=await app.fetch(new Request('https://split.test/api/account'),env);assert.equal(anonymous.status,401); +}); +test('roster includes zero-work students and stable pages; scoped counts and split cursors',async()=>{ + const {request,job,db}=await setup();await job('a');await job('b','alice',B); + let queries=0;const original=db.prepare.bind(db);db.prepare=((sql:string)=>{queries++;return original(sql);}) as typeof db.prepare; + const first=await(await request(`/api/classroom/courses/${A}/roster`,'ownerA')).json() as any; + assert.equal(first.participants.length,40);assert.equal(first.participants[0].splitCount,1);assert.equal(first.participants[1].splitCount,0);assert.ok(queries<=5,`queries ${queries}`); + const second=await(await request(`/api/classroom/courses/${A}/roster?cursor=next`,'ownerA')).json() as any;assert.equal(second.participants.length,5);assert.equal(new Set([...first.participants,...second.participants].map(p=>p.memberId)).size,45); + const member=first.participants[0].memberId;const jobs=await(await request(`/api/classroom/courses/${A}/participants/${member}/jobs`,'ownerA')).json() as any;assert.deepEqual(jobs.jobs.map((j:any)=>j.id),['a']); + const forged=btoa(JSON.stringify({courseId:B,memberId:member,id:'a'}));assert.equal((await request(`/api/classroom/courses/${A}/participants/${member}/jobs?cursor=${encodeURIComponent(forged)}`,'ownerA')).status,400); +}); +test('new course conversations are complete, server-produced, paginated, private and reset-fenced',async()=>{ + const {env,request,job,principal,calls}=await setup();await job('a');const p=await principal();let revision=0; + for(let i=0;i<35;i++){const start=await beginCourseTurn(env,'a',p,`Student ${i}`,`message-${String(i).padStart(16,'0')}`,revision);revision=(await finishCourseTurn(env,'a',p.subject,start.turnId,start.claimId,{reply:`Server ${i}`,toolCalls:[],finishReason:'stop'}))!;} + let response=await request(`/api/classroom/courses/${A}/jobs/a/conversation`,'ownerA');assert.equal(response.status,200);const page=await response.json() as any;assert.equal(page.entries.length,40);assert.equal(calls(),0); + const next=await(await request(`/api/classroom/courses/${A}/jobs/a/conversation?cursor=${page.nextCursor}&revision=${page.revision}`,'ownerA')).json() as any;assert.equal(next.entries.length,30);assert.equal(next.nextCursor,null); + assert.equal((await request('/api/jobs/a/listening-conversation','ownerA')).status,404); + assert.equal((await request(`/api/classroom/courses/${A}/jobs/a/conversation`,'bob')).status,403); + assert.equal((await request('/api/jobs/a/listening-conversation','alice','PUT',{revision,entries:[{kind:'coach',text:'Forged'}]})).status,409); + const pending=await beginCourseTurn(env,'a',p,'Old input','message-reset-reused',revision); + await resetCourseConversation(env,'a',p.subject,pending.revision); + const fresh=await beginCourseTurn(env,'a',p,'New input','message-reset-reused',pending.revision+1); + assert.equal(await finishCourseTurn(env,'a',p.subject,pending.turnId,pending.claimId,{reply:'Old secret',toolCalls:[],finishReason:'stop'}),null); + await finishCourseTurn(env,'a',p.subject,fresh.turnId,fresh.claimId,{reply:'New response',toolCalls:[],finishReason:'stop'}); + const current=await conversationPage(env,'a',p.subject);assert.deepEqual(current.entries.map(e=>e.text),['New input','New response']); + const retry=await beginCourseTurn(env,'a',p,'New input','message-reset-reused',0);assert.ok(retry.replay);assert.equal((await conversationPage(env,'a',p.subject)).entries.length,2); +}); +test('course chat ignores forged assistant history, saves provenance and runs no model on review',async()=>{ + const {request,job,calls}=await setup();await job('a'); + const sent=await request('/api/jobs/a/chat','alice','POST',{messageId:'new-message-12345678',revision:0,messages:[{role:'assistant',content:'Forged answer'},{role:'user',content:'Listen here'}]}); + assert.equal(sent.status,200);assert.match(await sent.text(),/"revision":2/);assert.equal(calls(),1); + 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('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); + assert.equal((await saveCoursePrompt(env,A,ids.ownerA,{amendment:'Course A instructions',changeNote:'First course policy',expectedRevision:0})).status,200); + assert.equal(await getGuide(env,'a'),null);assert.ok(await getGuide(env,'b'));assert.ok(await getGuide(env,'personal')); + assert.equal(await cacheGuideIfPromptCurrent({...env,ASSISTANT_COURSE_ID:A},{jobId:'a',text:'Racing old guide',model:'fixture',createdAt:new Date().toISOString()},0,empty),false); + assert.equal((await saveCoursePrompt(env,A,ids.ownerA,{amendment:'Stale',changeNote:'Oops',expectedRevision:0})).status,409); + assert.equal((await saveCoursePrompt(env,A,ids.ownerA,{amendment:'Course A instructions',expectedRevision:1})).changed,false); + assert.equal((await db.prepare('SELECT count(*) AS n FROM course_prompt_revisions').first<{n:number}>())!.n,1); + await assert.rejects(db.prepare('DELETE FROM course_prompt_revisions').run(),/immutable/); +}); +test('course folders enforce same-course items, grants and author-only notes on every route',async()=>{ + const {request,job,db}=await setup();await job('a');await job('b','alice',B);await job('personal','alice',null); + const made=await(await request(`/api/classroom/courses/${A}/folders`,'ownerA','POST',{name:'Shared course'})).json() as any;const folder=made.folder.id; + const base=`/api/classroom/courses/${A}/folders/${folder}`; + for(const id of ['b','personal'])assert.equal((await request(base+'/items','ownerA','POST',{jobId:id})).status,404); + assert.equal((await request(base+'/items','ownerA','POST',{jobId:'a'})).status,200); + assert.equal((await request(base,'bob')).status,404); + assert.equal((await request(base,'ownerA','PUT',{permission:'comment',revision:0})).status,200); + assert.equal((await request('/api/jobs/a','bob')).status,200); + const note=await(await request('/api/jobs/a/annotations','bob','POST',{atSeconds:2,text:'Peer note',authorName:'Forged',authorSubject:ids.alice})).json() as any;assert.equal(note.authorName,'Student'); + assert.equal((await request(`/api/jobs/a/annotations/${note.id}`,'alice','PUT',{text:'Impersonated'})).status,404); + assert.equal((await request(`/api/jobs/a/annotations/${note.id}`,'bob','PUT',{text:'Edited by author',authorName:'Forged'})).status,200); + assert.equal((await request(`/api/jobs/a/annotations/${note.id}`,'alice','DELETE')).status,404); + assert.equal((await request(`/api/jobs/a/annotations/${note.id}`,'ownerA','DELETE')).status,404); + assert.equal((await request(`/api/jobs/a/annotations/${note.id}`,'bob','DELETE')).status,200); + await db.prepare("INSERT INTO annotations(id,job_id,at_seconds,text) VALUES('historical','a',0,'Old note')").run(); + const state=await(await request('/api/jobs/a','bob')).json() as any;assert.equal(state.annotations[0].authorName,'Author unavailable');assert.equal(state.annotations[0].canDelete,false); + assert.equal((await request(base,'ownerA','PUT',{permission:'private',revision:1})).status,200);assert.equal((await request('/api/jobs/a','bob')).status,404);assert.equal((await request('/api/files/stems/a/vocals.mp3','bob')).status,404); +}); +test('fresh and additive migration preserve private history, no backfill; retained course messages purge',async()=>{ + const fresh=readFileSync(new URL('../schema.sql',import.meta.url),'utf8'); + const legacy=readFileSync(new URL('./fixtures/pre-classroom-schema.sql',import.meta.url),'utf8'); + assert.equal(createHash('sha256').update(legacy).digest('hex'),'daa7333b25af106c1b2db9cf71bcee70601ed59c51642ca4ff8a0c6eb3f13362'); + 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); + 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); + const schema=async(db:SqliteD1)=>(await db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all()).results; + assert.deepEqual(await schema(db),await schema(clean)); + const columns=async(database:SqliteD1,name:string)=>(await database.prepare(`PRAGMA table_info(${name})`).all()).results.map(({cid,...column})=>column).sort((a,b)=>String(a.name).localeCompare(String(b.name))); + for(const {name} of await schema(db) as {name:string}[])assert.deepEqual(await columns(db,name),await columns(clean,name));assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM job_courses').first()).n,0);assert.equal((await db.prepare('SELECT revision FROM listening_conversations').first()).revision,3); + const fixture=await setup();await fixture.job('expired');const p=await fixture.principal();const start=await beginCourseTurn(fixture.env,'expired',p,'Hi','retained-message-1234',0);await finishCourseTurn(fixture.env,'expired',p.subject,start.turnId,start.claimId,{reply:'Retained reply',toolCalls:[],finishReason:'stop'});await fixture.db.prepare("UPDATE jobs SET created_at=datetime('now','-91 days') WHERE id='expired'").run(); + assert.equal((await conversationPage(fixture.env,'expired',p.subject)).entries.length,0);await purgeExpiredListeningConversations(fixture.env.DB);assert.equal((await fixture.db.prepare('SELECT count(*) AS n FROM course_messages').first()).n,0); +}); + +test('the exact shared private-wire fixture validates, while stale/extra/mismatched authority fails closed',async()=>{ + const fixture=JSON.parse(readFileSync(new URL('./fixtures/stem-course-v1.json',import.meta.url),'utf8')); + const now=Date.parse(fixture.access.checkedAt);assert.equal(validAccess(fixture.access,now),true);assert.equal(validPage(fixture.roster,now),true);assert.equal(validFailure(fixture.denied),true); + assert.equal(validAccess({...fixture.access,subject:ids.alice},now),false);assert.equal(validAccess(fixture.access,now+31000),false); + const {env,request}=await setup();for(const altered of [{startsAt:new Date(Date.now()+60000).toISOString()},{expiresAt:new Date(Date.now()-1).toISOString()},{classId:B},{revision:-1},{owner:false,participant:false},{subject:ids.alice}]){ + env.ADMISSION_RESOLVER!.resolveCourseAccess=async()=>({...await courseFixture(ids.alice,A),...altered});assert.equal((await request('/api/account')).status,503); + } + for(const code of ['scheduled','expired','revoked']){env.ADMISSION_RESOLVER!.resolveCourseAccess=async()=>({ok:false,code,retryable:false});assert.equal((await request('/api/account')).status,403);} +}); + +test('concurrent duplicate course turns claim once; assistant notes retain the verified initiating author',async()=>{ + const {env,job,principal,db}=await setup();await job('race');const p=await principal(); + const attempts=await Promise.allSettled([beginCourseTurn(env,'race',p,'Listen','concurrent-turn-123456',0),beginCourseTurn(env,'race',p,'Listen','concurrent-turn-123456',0)]); + const successes=attempts.filter(a=>a.status==='fulfilled');assert.equal(successes.length,1); + const conflict=attempts.find(a=>a.status==='rejected') as PromiseRejectedResult;assert.equal(conflict.reason.status,409); + const start=(successes[0] as PromiseFulfilledResult>>).value; + const result={reply:'Heard it',toolCalls:[{name:'add_note' as const,args:{seconds:2,text:'Server note'}}],finishReason:'stop'}; + await finishCourseTurn(env,'race',p.subject,start.turnId,start.claimId,result,false,p.displayName); + assert.equal(await finishCourseTurn(env,'race',p.subject,start.turnId,start.claimId,result,false,p.displayName),null); + const notes=(await db.prepare('SELECT author_subject,author_name,provenance FROM annotations').all()).results; + assert.deepEqual(JSON.parse(JSON.stringify(notes)),[{author_subject:p.subject,author_name:'Alice',provenance:'server-assistant'}]); + assert.deepEqual((await conversationPage(env,'race',p.subject)).entries.map((v:any)=>v.provenance),['student','server-assistant','server-tool']); +}); +test('maintenance pause preserves assignments and private owner reads while stopping course disclosure and starts',async()=>{ + const {env,request,job,db}=await setup();await job('a');env.CAIL_CLASSROOM_ENABLED='false'; + assert.equal((await request('/api/jobs/a')).status,200); + assert.equal((await request('/api/jobs/a','ownerA')).status,404); + assert.equal((await request(`/api/classroom/courses/${A}/roster`,'ownerA')).status,503); + assert.equal((await request('/api/jobs/a/chat','alice','POST',{})).status,503); + assert.equal((await request('/api/jobs','alice','POST',{coursePolicy:'course-work-v1'},{'x-stem-course':A})).status,503); + await assert.rejects(authorizeStoredCourseWork(env,ids.alice,A),/course_work_paused/); + assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM job_courses').first()).n,1); + env.CAIL_CLASSROOM_ENABLED='true';assert.equal((await request('/api/jobs/a','ownerA')).status,200); +}); + +test('private course mirror rejects impossible UTC dates, inverted validity windows and access markers on plain assignments',async()=>{ + const fixture=JSON.parse(readFileSync(new URL('./fixtures/stem-course-v1.json',import.meta.url),'utf8'));const now=Date.parse(fixture.access.checkedAt);const {ok,...plain}=fixture.access; + assert.equal(validAssignment(plain,now),true);assert.equal(validAssignment({...plain,ok:true},now),false);assert.equal(validAssignment({...plain,ok:false},now),false); + assert.equal(validAccess({...plain,ok:false},now),false);assert.equal(validAccess(plain,now),false); + assert.equal(validAccess({...fixture.access,startsAt:'2026-02-31T00:00:00.000Z'},now),false); + assert.equal(validAccess({...fixture.access,checkedAt:new Date(now+2000).toISOString(),expiresAt:new Date(now+1000).toISOString()},now),false); + assert.equal(validPage({...fixture.roster,checkedAt:new Date(now+2000).toISOString(),expiresAt:new Date(now+1000).toISOString()},now),false); + const {env,request}=await setup();env.ADMISSION_RESOLVER!.listCourseAssignments=async()=>{const access=await courseFixture(ids.alice);return {ok:true,checkedAt:access.checkedAt,expiresAt:access.expiresAt,revision:1,assignments:[{...access,ok:false}],nextCursor:null};}; + assert.equal((await request('/api/classroom/courses')).status,503); +}); + +test('expired course turn leases recover without deleting prior messages or retrying uncertain inference',async()=>{ + const {env,db,job,principal}=await setup();await job('orphan');const p=await principal(); + const first=await beginCourseTurn(env,'orphan',p,'Earlier question','earlier-turn-123456',0);await finishCourseTurn(env,'orphan',p.subject,first.turnId,first.claimId,{reply:'Earlier verified answer',toolCalls:[],finishReason:'stop'}); + const lost=await beginCourseTurn(env,'orphan',p,'Uncertain request','orphaned-turn-123456',2); + assert.equal(await recoverExpiredCourseTurn(env,'orphan',p.subject),false); + await db.prepare("UPDATE course_conversations SET pending_expires_at=datetime('now','-1 second') WHERE job_id='orphan'").run(); + assert.equal(await finishCourseTurn(env,'orphan',p.subject,lost.turnId,lost.claimId,{reply:'Late answer',toolCalls:[{name:'add_note',args:{seconds:1,text:'Late note'}}],finishReason:'stop'}),null); + const recovered=await Promise.all([recoverExpiredCourseTurn(env,'orphan',p.subject),recoverExpiredCourseTurn(env,'orphan',p.subject)]);assert.equal(recovered.filter(Boolean).length,1); + const page=await conversationPage(env,'orphan',p.subject);assert.equal(page.pending,false);assert.equal(page.revision,4);assert.deepEqual(page.entries.slice(0,3).map(m=>m.text),['Earlier question','Earlier verified answer','Uncertain request']);assert.equal(page.entries[3].kind,'status');assert.match(page.entries[3].text,/will not be retried automatically/); + assert.equal((await db.prepare('SELECT count(*) AS n FROM annotations').first()).n,0); + await assert.rejects(beginCourseTurn(env,'orphan',p,'Uncertain request',lost.turnId,page.revision),error=>(error as any).code==='conversation_interrupted'); + const fresh=await beginCourseTurn(env,'orphan',p,'Fresh question','recovered-turn-1234',page.revision);assert.equal(await recoverExpiredCourseTurn(env,'orphan',p.subject),false); + assert.equal(await finishCourseTurn(env,'orphan',p.subject,lost.turnId,lost.claimId,{reply:'Very late answer',toolCalls:[],finishReason:'stop'}),null); + await finishCourseTurn(env,'orphan',p.subject,fresh.turnId,fresh.claimId,{reply:'Fresh answer',toolCalls:[],finishReason:'stop'});assert.equal((await conversationPage(env,'orphan',p.subject)).entries.length,6); +}); +test('storage failures before and after finish remain recoverable and never duplicate conversation effects',async()=>{ + const {env,db,job,principal}=await setup();await job('storage');const p=await principal();const original=db.batch.bind(db); + const pending=await beginCourseTurn(env,'storage',p,'Storage request','storage-turn-123456',0); + db.batch=(async()=>{throw new Error('injected storage unavailable');}) as typeof db.batch; + await assert.rejects(finishCourseTurn(env,'storage',p.subject,pending.turnId,pending.claimId,{reply:'Unsaved reply',toolCalls:[],finishReason:'stop'}));db.batch=original; + await db.prepare("UPDATE course_conversations SET pending_expires_at=datetime('now','-1 second') WHERE job_id='storage'").run(); + const page=await conversationPage(env,'storage',p.subject);assert.equal(page.pending,false);assert.equal(page.entries.length,2); + const fresh=await beginCourseTurn(env,'storage',p,'Another request','storage-next-123456',page.revision); + db.batch=(async(statements)=>{await original(statements);throw new Error('injected lost acknowledgment');}) as typeof db.batch; + await assert.rejects(finishCourseTurn(env,'storage',p.subject,fresh.turnId,fresh.claimId,{reply:'Saved once',toolCalls:[],finishReason:'stop'}));db.batch=original; + const retry=await beginCourseTurn(env,'storage',p,'Another request',fresh.turnId,fresh.revision);assert.ok(retry.replay);assert.equal(retry.replay.reply,'Saved once');assert.equal((await conversationPage(env,'storage',p.subject)).entries.length,4); +}); diff --git a/cloudflare/course-fixture.ts b/cloudflare/course-fixture.ts new file mode 100644 index 0000000..2dda921 --- /dev/null +++ b/cloudflare/course-fixture.ts @@ -0,0 +1,9 @@ +// Synthetic local authority only. Never imported by the production entrypoint. +import { STEM_COURSE_ID, type CourseAssignment } from '../src/classroom/contract.ts'; +export async function courseFixture(subject: string, classId=STEM_COURSE_ID, owner=false): Promise { + const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(`fixture:${classId}:${subject}`)); + return {ok:true,classId,className:'The American Musical Experience',term:'Fall 2026',section:'01', + memberId:`stem-member-v1-${Array.from(new Uint8Array(digest),b=>b.toString(16).padStart(2,'0')).join('')}`, + displayName:'Fixture Student',displayNameSource:'verified_profile',participant:!owner,owner, + checkedAt:new Date().toISOString(),startsAt:new Date(Date.now()-86400000).toISOString(),expiresAt:new Date(Date.now()+60000).toISOString(),revision:1}; +} diff --git a/cloudflare/fixtures/pre-classroom-schema.md b/cloudflare/fixtures/pre-classroom-schema.md new file mode 100644 index 0000000..663f462 --- /dev/null +++ b/cloudflare/fixtures/pre-classroom-schema.md @@ -0,0 +1,11 @@ +# Migration baseline fixture + +`pre-classroom-schema.sql` is byte-for-byte `schema.sql` from Stem Splitter +commit `e1918da3cb6fc37f9715d4cd143af5a5c6c880c6`, before migration 0021. + +SHA-256: `daa7333b25af106c1b2db9cf71bcee70601ed59c51642ca4ff8a0c6eb3f13362` + +The classroom upgrade test checks this digest before adding synthetic private +rows and applying migration 0021. Keeping the baseline here makes the upgrade +test deterministic in shallow CI checkouts, without Git history or network +access. This is a test fixture, not a schema to apply to a live database. diff --git a/cloudflare/fixtures/pre-classroom-schema.sql b/cloudflare/fixtures/pre-classroom-schema.sql new file mode 100644 index 0000000..f40b58b --- /dev/null +++ b/cloudflare/fixtures/pre-classroom-schema.sql @@ -0,0 +1,521 @@ +-- Job tracking for stem separation requests. +CREATE TABLE IF NOT EXISTS public_split_links ( + job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + source_key TEXT NOT NULL, -- R2 key of the uploaded original + status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | done | failed + external_id TEXT, -- id of the job at the separation backend (e.g. Replicate prediction id) + stems TEXT, -- JSON array: [{ "name": "vocals", "key": "stems//vocals.mp3" }, ...] + error TEXT, + model TEXT, -- catalogue contract id (src/separation/options.ts), not a provider model name + -- current: vocals_instrumental (2) | htdemucs_ft (4) | htdemucs_6s (6) | bs_roformer_vocals (2, local) + -- NULL on pre-2026-07 rows; read as htdemucs_ft + routing_request TEXT, -- NULL for legacy/explicit jobs; "auto" when server analysis was requested + source_type TEXT, -- upload | youtube | archive for analyzed jobs + source_hash TEXT -- server-verified lowercase SHA-256 of stored source bytes + CHECK (source_hash IS NULL OR ( + length(source_hash) = 64 AND source_hash NOT GLOB '*[^0-9a-f]*' + )), + analysis TEXT, -- versioned AutoRoutingDecision JSON; never source audio/URL/credentials + labels TEXT, -- JSON map: { "": "" } + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at); + +-- Once recorded, exact source identity may be repeated but never rebound to +-- different bytes or cleared. Legacy rows can still transition NULL -> hash. +CREATE TRIGGER IF NOT EXISTS jobs_source_hash_immutable +BEFORE UPDATE OF source_hash ON jobs +FOR EACH ROW +WHEN OLD.source_hash IS NOT NULL AND NEW.source_hash IS NOT OLD.source_hash +BEGIN + SELECT RAISE(ABORT, 'jobs.source_hash is immutable once set'); +END; + +-- A stored digest identifies the bytes at this exact source locator. Neither +-- the object key nor its source class may be rebound underneath that digest. +CREATE TRIGGER IF NOT EXISTS jobs_source_locator_immutable +BEFORE UPDATE OF source_key, source_type ON jobs +FOR EACH ROW +WHEN OLD.source_hash IS NOT NULL + AND ( + NEW.source_key IS NOT OLD.source_key + OR NEW.source_type IS NOT OLD.source_type + ) +BEGIN + SELECT RAISE(ABORT, 'jobs source locator is immutable once source_hash is set'); +END; + +-- Independently queried long-tail targets. These rows never alter jobs.stems, +-- and their cache identity binds the source bytes, prompt, and exact provider. +CREATE TABLE IF NOT EXISTS instrument_isolations ( + id TEXT PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT '1' CHECK (schema_version = '1'), + job_id TEXT NOT NULL, + requested_by TEXT NOT NULL, + source_hash TEXT NOT NULL + CHECK (length(source_hash) = 64 AND source_hash NOT GLOB '*[^0-9a-f]*'), + source_type TEXT NOT NULL CHECK (source_type IN ('upload', 'youtube', 'archive')), + normalized_target TEXT NOT NULL CHECK (length(normalized_target) BETWEEN 2 AND 80), + analysis_vocabulary_version TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL, + provider_model TEXT NOT NULL, + provider_version TEXT NOT NULL + CHECK (length(provider_version) = 64 AND provider_version NOT GLOB '*[^0-9a-f]*'), + provider_contract_version TEXT NOT NULL, + cache_key TEXT NOT NULL + CHECK ( + length(cache_key) = 83 + AND substr(cache_key, 1, 19) = 'query-isolation/v1/' + AND substr(cache_key, 20) NOT GLOB '*[^0-9a-f]*' + ), + rollout_stage TEXT NOT NULL DEFAULT 'shadow' + CHECK (rollout_stage IN ('shadow', 'teacher_beta')), + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'processing', 'succeeded', 'failed')), + external_id TEXT, + target_key TEXT, + residual_key TEXT, + failure_code TEXT, + failure_retryable INTEGER CHECK (failure_retryable IS NULL OR failure_retryable IN (0, 1)), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + max_attempts INTEGER NOT NULL DEFAULT 2 CHECK (max_attempts BETWEEN 1 AND 5), + deadline_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE, + UNIQUE (job_id, cache_key), + CHECK (attempts <= max_attempts), + CHECK (status <> 'queued' OR (external_id IS NULL AND deadline_at IS NULL)), + CHECK (status <> 'processing' OR deadline_at IS NOT NULL), + CHECK (status <> 'succeeded' OR target_key IS NOT NULL), + CHECK (status <> 'failed' OR failure_code IS NOT NULL), + CHECK ( + target_key IS NULL + OR substr(target_key, 1, length('isolations/' || id || '/')) = 'isolations/' || id || '/' + ), + CHECK ( + residual_key IS NULL + OR substr(residual_key, 1, length('isolations/' || id || '/')) = 'isolations/' || id || '/' + ) +); + +CREATE INDEX IF NOT EXISTS idx_instrument_isolations_job + ON instrument_isolations (job_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_instrument_isolations_cache + ON instrument_isolations (cache_key, status); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_instrument_isolations_one_processing_per_job + ON instrument_isolations (job_id) WHERE status = 'processing'; + +-- A provider-start reservation is permanent budget evidence even when the +-- provider later fails. It is separate from output caching and core stems. +CREATE TABLE IF NOT EXISTS instrument_isolation_budget_reservations ( + isolation_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL CHECK (attempt_number BETWEEN 1 AND 5), + job_id TEXT NOT NULL, + cache_key TEXT NOT NULL + CHECK ( + length(cache_key) = 83 + AND substr(cache_key, 1, 19) = 'query-isolation/v1/' + AND substr(cache_key, 20) NOT GLOB '*[^0-9a-f]*' + ), + requested_by TEXT NOT NULL + CHECK ( + length(requested_by) BETWEEN 1 AND 64 + AND requested_by NOT GLOB '*[^A-Za-z0-9._-]*' + ), + course_id TEXT NOT NULL + CHECK ( + length(course_id) BETWEEN 2 AND 64 + AND substr(course_id, 1, 1) GLOB '[a-z0-9]' + AND course_id NOT GLOB '*[^a-z0-9._-]*' + ), + semester_id TEXT NOT NULL + CHECK ( + length(semester_id) BETWEEN 2 AND 64 + AND substr(semester_id, 1, 1) GLOB '[a-z0-9]' + AND semester_id NOT GLOB '*[^a-z0-9._-]*' + ), + policy_version TEXT NOT NULL + CHECK (policy_version = 'course-semester-provider-starts-v1'), + maximum_provider_starts INTEGER NOT NULL + CHECK (maximum_provider_starts BETWEEN 1 AND 1000), + reserved_at TEXT NOT NULL, + PRIMARY KEY (isolation_id, attempt_number) +); + +CREATE INDEX IF NOT EXISTS idx_instrument_isolation_budget_scope + ON instrument_isolation_budget_reservations (course_id, semester_id, reserved_at); + +CREATE TRIGGER IF NOT EXISTS instrument_isolation_budget_reservations_no_update +BEFORE UPDATE ON instrument_isolation_budget_reservations +FOR EACH ROW +BEGIN + SELECT RAISE(ABORT, 'instrument isolation budget reservations are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS instrument_isolation_budget_reservations_no_delete +BEFORE DELETE ON instrument_isolation_budget_reservations +FOR EACH ROW +BEGIN + SELECT RAISE(ABORT, 'instrument isolation budget reservations are immutable'); +END; + +-- Terminal provider observations are serialized independently from the +-- provider-start attempt. Expired leases can be reclaimed, but acquisition is +-- capped so a broken output cannot loop forever. +CREATE TABLE IF NOT EXISTS instrument_isolation_ingestion_leases ( + isolation_id TEXT PRIMARY KEY, + external_id TEXT NOT NULL + CHECK ( + length(external_id) BETWEEN 1 AND 128 + AND external_id NOT GLOB '*[^A-Za-z0-9_-]*' + ), + lease_id TEXT + CHECK ( + lease_id IS NULL OR ( + length(lease_id) BETWEEN 1 AND 128 + AND lease_id NOT GLOB '*[^A-Za-z0-9_-]*' + ) + ), + lease_expires_at TEXT, + attempts INTEGER NOT NULL CHECK (attempts BETWEEN 1 AND 3), + max_attempts INTEGER NOT NULL DEFAULT 3 CHECK (max_attempts = 3), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (isolation_id) REFERENCES instrument_isolations(id) ON DELETE CASCADE, + CHECK ( + (lease_id IS NULL AND lease_expires_at IS NULL) + OR (lease_id IS NOT NULL AND lease_expires_at IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_instrument_isolation_ingestion_deadline + ON instrument_isolation_ingestion_leases (lease_expires_at); + +-- Output identity is separate from both core stems and provider input. Rows +-- can disappear with their owning job, but cannot be rewritten in place. +CREATE TABLE IF NOT EXISTS instrument_isolation_outputs ( + isolation_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('target', 'residual')), + storage_key TEXT NOT NULL UNIQUE, + sha256 TEXT NOT NULL + CHECK (length(sha256) = 64 AND sha256 NOT GLOB '*[^0-9a-f]*'), + bytes INTEGER NOT NULL CHECK (bytes BETWEEN 44 AND 104857600), + content_type TEXT NOT NULL CHECK (content_type = 'audio/wav'), + retained_until TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (isolation_id, kind), + FOREIGN KEY (isolation_id) REFERENCES instrument_isolations(id) ON DELETE CASCADE, + CHECK (storage_key = 'isolations/' || isolation_id || '/' || kind || '.wav') +); + +CREATE INDEX IF NOT EXISTS idx_instrument_isolation_outputs_retention + ON instrument_isolation_outputs (retained_until, isolation_id); + +CREATE TRIGGER IF NOT EXISTS instrument_isolation_outputs_no_update +BEFORE UPDATE ON instrument_isolation_outputs +FOR EACH ROW +BEGIN + SELECT RAISE(ABORT, 'instrument isolation output identity is immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS instrument_isolation_outputs_no_replace +BEFORE INSERT ON instrument_isolation_outputs +WHEN EXISTS ( + SELECT 1 FROM instrument_isolation_outputs + WHERE (isolation_id = NEW.isolation_id AND kind = NEW.kind) + OR storage_key = NEW.storage_key +) +BEGIN + SELECT RAISE(ABORT, 'instrument isolation output identity is immutable'); +END; + +-- Structured teacher observations about a pinned candidate instrument +-- analysis. These revisions cannot route a split or become training data. +-- Deletion follows the retained job; curation must create a separate, +-- reviewed and de-identified artifact instead of changing these rows in place. +CREATE TABLE IF NOT EXISTS instrument_discovery_feedback ( + id TEXT PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT '1' CHECK (schema_version = '1'), + job_id TEXT NOT NULL, + reviewer TEXT NOT NULL + CHECK ( + length(reviewer) BETWEEN 1 AND 64 + AND reviewer NOT GLOB '*[^A-Za-z0-9._-]*' + ), + revision INTEGER NOT NULL CHECK (revision BETWEEN 1 AND 1000000), + analysis_sha256 TEXT NOT NULL + CHECK (length(analysis_sha256) = 64 AND analysis_sha256 NOT GLOB '*[^0-9a-f]*'), + source_sha256 TEXT NOT NULL + CHECK (length(source_sha256) = 64 AND source_sha256 NOT GLOB '*[^0-9a-f]*'), + classifier_version TEXT NOT NULL CHECK (length(classifier_version) BETWEEN 1 AND 200), + vocabulary_version TEXT NOT NULL CHECK (length(vocabulary_version) BETWEEN 1 AND 100), + vocabulary_sha256 TEXT NOT NULL + CHECK (length(vocabulary_sha256) = 64 AND vocabulary_sha256 NOT GLOB '*[^0-9a-f]*'), + review_ontology_version TEXT NOT NULL + CHECK (length(review_ontology_version) BETWEEN 1 AND 100), + genre_family TEXT NOT NULL CHECK (genre_family IN ( + 'unknown', 'rock', 'jazz', 'orchestral-chamber', 'electronic', + 'hip-hop', 'folk-traditional', 'sparse-acoustic', 'other' + )), + observations TEXT NOT NULL + CHECK ( + length(observations) BETWEEN 2 AND 8192 + AND json_valid(observations) + AND json_type(observations) = 'array' + ), + evidence_status TEXT NOT NULL DEFAULT 'unreviewed-candidate' + CHECK (evidence_status = 'unreviewed-candidate'), + deidentified INTEGER NOT NULL DEFAULT 0 CHECK (deidentified = 0), + training_eligible INTEGER NOT NULL DEFAULT 0 CHECK (training_eligible = 0), + created_at TEXT NOT NULL, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE, + UNIQUE (job_id, reviewer, analysis_sha256, revision) +); + +CREATE INDEX IF NOT EXISTS idx_instrument_discovery_feedback_job + ON instrument_discovery_feedback (job_id, analysis_sha256, created_at DESC, id DESC); + +CREATE TRIGGER IF NOT EXISTS instrument_discovery_feedback_no_update +BEFORE UPDATE ON instrument_discovery_feedback +BEGIN + SELECT RAISE(ABORT, 'instrument discovery feedback revisions are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS instrument_discovery_feedback_no_replace +BEFORE INSERT ON instrument_discovery_feedback +WHEN EXISTS ( + SELECT 1 FROM instrument_discovery_feedback + WHERE id = NEW.id OR ( + job_id = NEW.job_id + AND reviewer = NEW.reviewer + AND analysis_sha256 = NEW.analysis_sha256 + AND revision = NEW.revision + ) +) +BEGIN + SELECT RAISE(ABORT, 'instrument discovery feedback revisions are immutable'); +END; + +-- Shared time-anchored notes on a track, shown as seek-bar markers. +CREATE TABLE IF NOT EXISTS annotations ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + at_seconds REAL NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_annotations_job ON annotations (job_id); + +-- Cached AI listening guides (one per job, generated lazily, class-shared). +CREATE TABLE IF NOT EXISTS guides ( + job_id TEXT PRIMARY KEY, -- jobs.id + text TEXT NOT NULL, -- the generated guide prose + model TEXT NOT NULL, -- ASSISTANT_MODEL slug that produced it + created_at TEXT NOT NULL DEFAULT (datetime('now')), + prompt_version TEXT NOT NULL DEFAULT '', -- code-owned SYSTEM_PROMPT_VERSION + prompt_revision INTEGER NOT NULL DEFAULT -1, -- assistant_settings.revision + prompt_hash TEXT NOT NULL DEFAULT '' -- effective multi-variant policy SHA-256 + CHECK (prompt_hash = '' OR ( + length(prompt_hash) = 64 AND prompt_hash NOT GLOB '*[^0-9a-f]*' + )) +); +-- Teacher accounts, sessions, and the editable Listening Guy prompt amendment. + +-- Credentials are seeded from the TEACHER_SEED secret, never from this file: +-- password_hash is PBKDF2-HMAC-SHA256 over a per-user random salt. +CREATE TABLE IF NOT EXISTS teachers ( + username TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + salt TEXT NOT NULL, -- hex, 16 bytes + password_hash TEXT NOT NULL, -- hex, 32 bytes + iterations INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Only the SHA-256 of the session token is stored, so a database copy does not +-- hand over live sessions. +CREATE TABLE IF NOT EXISTS teacher_sessions ( + token_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_teacher_sessions_expires ON teacher_sessions (expires_at); + +-- Single-row settings table (id is pinned to 1). The amendment is appended to +-- the Listening Guy system prompt; the built-in guardrails still follow it. +CREATE TABLE IF NOT EXISTS assistant_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + amendment TEXT NOT NULL DEFAULT '', + updated_by TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + revision INTEGER NOT NULL DEFAULT 0 +); + +INSERT INTO assistant_settings (id, amendment) VALUES (1, '') ON CONFLICT(id) DO NOTHING; + +-- Append-only prompt amendment history. The base prompt is versioned in +-- src/assistant/prompt.ts; every revision stores that version and fingerprint +-- so runtime changes can be matched to the code/changelog that governed them. +CREATE TABLE IF NOT EXISTS assistant_prompt_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + settings_revision INTEGER NOT NULL UNIQUE, + amendment TEXT NOT NULL, + change_note TEXT NOT NULL, + base_prompt_version TEXT NOT NULL, + base_prompt_hash TEXT NOT NULL, + effective_prompt_hash TEXT NOT NULL, + updated_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_prompt_revisions_created + ON assistant_prompt_revisions (created_at DESC, id DESC); + +-- History is not merely API-append-only. Prevent direct mutation, deletion, +-- and INSERT OR REPLACE from rewriting an existing audit identity. +CREATE TRIGGER IF NOT EXISTS assistant_prompt_revisions_validate_insert +BEFORE INSERT ON assistant_prompt_revisions +WHEN typeof(NEW.settings_revision) <> 'integer' + OR NEW.settings_revision < 1 + OR length(NEW.amendment) > 2000 + OR length(trim(NEW.change_note)) < 1 + OR length(NEW.change_note) > 240 + OR length(NEW.base_prompt_version) < 1 + OR length(NEW.base_prompt_version) > 80 + OR length(NEW.base_prompt_hash) <> 64 + OR NEW.base_prompt_hash GLOB '*[^0-9a-f]*' + OR length(NEW.effective_prompt_hash) <> 64 + OR NEW.effective_prompt_hash GLOB '*[^0-9a-f]*' + OR length(NEW.updated_by) < 1 + OR length(NEW.updated_by) > 64 + OR NEW.updated_by GLOB '*[^A-Za-z0-9._-]*' +BEGIN + SELECT RAISE(ABORT, 'assistant prompt history row is invalid'); +END; + +CREATE TRIGGER IF NOT EXISTS assistant_prompt_revisions_no_update +BEFORE UPDATE ON assistant_prompt_revisions +BEGIN + SELECT RAISE(ABORT, 'assistant prompt history is append-only'); +END; + +CREATE TRIGGER IF NOT EXISTS assistant_prompt_revisions_no_delete +BEFORE DELETE ON assistant_prompt_revisions +BEGIN + SELECT RAISE(ABORT, 'assistant prompt history is append-only'); +END; + +CREATE TRIGGER IF NOT EXISTS assistant_prompt_revisions_no_replace +BEFORE INSERT ON assistant_prompt_revisions +WHEN EXISTS ( + SELECT 1 FROM assistant_prompt_revisions + WHERE id = NEW.id OR settings_revision = NEW.settings_revision +) +BEGIN + SELECT RAISE(ABORT, 'assistant prompt history is append-only'); +END; + +-- Instructor folders: named sets of finished splits kept for teaching. Items +-- snapshot filename/model so a folder still lists an entry after the 30-day +-- cleanup removes its job row; the UI shows those as expired. +CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 80), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS folder_items ( + folder_id TEXT NOT NULL, + job_id TEXT NOT NULL, + filename TEXT NOT NULL, + model TEXT NOT NULL, + added_by TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (folder_id, job_id) +); + +CREATE INDEX IF NOT EXISTS idx_folder_items_job ON folder_items (job_id); + +-- Application roles supplement verified, current CAIL Admission membership. +-- Administrators derive authority from Admission, never from this table. +CREATE TABLE IF NOT EXISTS app_users ( + subject TEXT PRIMARY KEY CHECK (length(subject) = 37 AND subject GLOB 'cail-*'), + role TEXT NOT NULL DEFAULT 'student' CHECK (role IN ('student', 'instructor')), + disabled INTEGER NOT NULL DEFAULT 0 CHECK (disabled IN (0, 1)), + role_expires_at TEXT, + revision INTEGER NOT NULL DEFAULT 0, + updated_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS app_user_events ( + id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + actor TEXT NOT NULL, + role TEXT NOT NULL, + disabled INTEGER NOT NULL, + role_expires_at TEXT, + revision INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(subject, revision) +); +CREATE TRIGGER IF NOT EXISTS app_user_events_no_update BEFORE UPDATE ON app_user_events +BEGIN SELECT RAISE(ABORT, 'access history is immutable'); END; +CREATE TRIGGER IF NOT EXISTS app_user_events_no_delete BEFORE DELETE ON app_user_events +BEGIN SELECT RAISE(ABORT, 'access history is immutable'); END; +CREATE TRIGGER IF NOT EXISTS app_user_events_no_replace BEFORE INSERT ON app_user_events +WHEN EXISTS (SELECT 1 FROM app_user_events WHERE id = NEW.id OR (subject = NEW.subject AND revision = NEW.revision)) +BEGIN SELECT RAISE(ABORT, 'access history is immutable'); END; +CREATE TRIGGER IF NOT EXISTS app_users_audit AFTER UPDATE ON app_users +BEGIN + INSERT INTO app_user_events (id, subject, actor, role, disabled, role_expires_at, revision) + VALUES (lower(hex(randomblob(16))), NEW.subject, NEW.updated_by, NEW.role, NEW.disabled, NEW.role_expires_at, NEW.revision); +END; +CREATE TABLE IF NOT EXISTS job_owners ( + job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE, + subject TEXT NOT NULL REFERENCES app_users(subject) +); +CREATE INDEX IF NOT EXISTS job_owners_subject ON job_owners(subject); +CREATE TABLE IF NOT EXISTS upload_owners ( + object_key TEXT PRIMARY KEY, + subject TEXT NOT NULL REFERENCES app_users(subject), + expires_at TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'issued' CHECK (state IN ('issued', 'uploading', 'ready')) +); +CREATE TABLE IF NOT EXISTS job_attributions ( + job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE, + attribution TEXT NOT NULL +); +-- Private, account-scoped Listening Guy transcript. Expiry is anchored to the +-- source split's creation time, not extended by later conversation activity. +CREATE TABLE IF NOT EXISTS listening_conversations ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + subject TEXT NOT NULL REFERENCES app_users(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) +); +CREATE INDEX IF NOT EXISTS idx_listening_conversations_expiry ON listening_conversations(expires_at); +CREATE TABLE IF NOT EXISTS app_request_reservations ( + id TEXT PRIMARY KEY, + subject TEXT NOT NULL REFERENCES app_users(subject), + scope TEXT NOT NULL CHECK (scope IN ('split', 'guide')), + day TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS app_request_reservations_scope ON app_request_reservations(scope, day, subject); diff --git a/cloudflare/fixtures/stem-course-v1.json b/cloudflare/fixtures/stem-course-v1.json new file mode 100644 index 0000000..bf30ff6 --- /dev/null +++ b/cloudflare/fixtures/stem-course-v1.json @@ -0,0 +1,41 @@ +{ + "accessInput": { + "subject": "cail-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "app": "stem-splitter", + "classId": "msh-245-the-american-musical-experience-fall-2026-01" + }, + "access": { + "ok": true, + "classId": "msh-245-the-american-musical-experience-fall-2026-01", + "className": "MSH 245 The American Musical Experience", + "term": "Fall 2026", + "section": "01", + "memberId": "stem-member-v1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "displayName": "Example Student", + "displayNameSource": "verified_profile", + "participant": true, + "owner": false, + "checkedAt": "2026-10-06T17:00:00.000Z", + "startsAt": "2026-08-28T04:00:00.000Z", + "expiresAt": "2026-12-24T05:00:00.000Z", + "revision": 12 + }, + "roster": { + "ok": true, + "classId": "msh-245-the-american-musical-experience-fall-2026-01", + "checkedAt": "2026-10-06T17:00:00.000Z", + "expiresAt": "2026-10-06T17:02:00.000Z", + "revision": 12, + "participants": [ + { + "memberId": "stem-member-v1-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "displayName": "Example Student", + "displayNameSource": "verified_profile", + "startsAt": "2026-08-28T04:00:00.000Z", + "expiresAt": "2026-12-24T05:00:00.000Z" + } + ], + "nextCursor": null + }, + "denied": { "ok": false, "code": "revoked", "retryable": false } +} diff --git a/cloudflare/gateway.spec.mjs b/cloudflare/gateway.spec.mjs index 582cb15..6d301cc 100644 --- a/cloudflare/gateway.spec.mjs +++ b/cloudflare/gateway.spec.mjs @@ -52,7 +52,12 @@ test('Listening Guide preserves the mixer and syncs private CUNY history across await expect(secondPage.locator('.coach-archive-toggle')).toContainText('EARLIER SESSION · 2'); await secondPage.locator('.coach-form input').fill('And now the drums?'); await secondPage.locator('.coach-form input').press('Enter'); - await expect(secondPage.locator('.coach-row.coach')).toContainText('Listen for the bass against the drums.'); + // The restored archive and the current response both contain coach rows. + // Wait for this session's reply rather than matching the earlier one. + const currentReply = secondPage.getByRole('log').locator('.coach-row.coach'); + await expect(currentReply).toHaveCount(1); + await expect(currentReply).toHaveText('Listen for the bass against the drums.'); + await expect(secondPage.locator('.coach-archive .coach-row.coach')).toHaveText('Listen for the bass against the drums.'); const captured = await server.fetch('/__fixture/gateway-messages', { headers: { 'x-fixture': 'local-only' } }).then(response => response.json()); expect(captured.messages.map(message => message.content)).toContain('What should I listen for next?'); expect(captured.messages.map(message => message.content)).toContain('And now the drums?'); diff --git a/cloudflare/identity.test.ts b/cloudflare/identity.test.ts index eb7a94d..63f4fa6 100644 --- a/cloudflare/identity.test.ts +++ b/cloudflare/identity.test.ts @@ -1,3 +1,4 @@ +import { courseFixture } from './course-fixture.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -15,7 +16,7 @@ function setup() { db.applySchema(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')); const membership = { ok: true, expiresAt: new Date(Date.now() + 60000).toISOString(), revision: 1, accessRole: 'member', budgetScope: 'person' }; const env = { AUTH_MODE: 'cail', PUBLIC_BASE_URL: 'https://split.test', CAIL_IDENTITY_JWKS: issuer.jwksJson, DB: db, verifyCailIdentity, - ADMISSION_RESOLVER: { resolveMembership: async () => membership } } as unknown as Env; + ADMISSION_RESOLVER: { resolveMembership: async () => membership, resolveCourseAccess: async ({subject,classId})=>courseFixture(subject,classId) } } as unknown as Env; return { db, env, membership }; } function req(path = '/api/account', token = alice, method = 'GET', origin = 'https://split.test') { @@ -29,7 +30,7 @@ test('valid CAIL identity becomes a student; display and entitlement claims neve const principals: AppPrincipal[] = []; assert.equal(await authorizeCailRequest(request, env, (principal) => principals.push(principal)), null); assert.equal(principals[0]?.role, 'student'); - assert.equal((await authorizeCailRequest(req('/api/teacher/prompt', token), env))?.status, 403); + assert.equal((await authorizeCailRequest(req('/api/teacher/folders', token), env))?.status, 403); }); test('wrong audience, array audience, expired token, wrong issuer, forged header fail authentication', async () => { const { env } = setup(); @@ -78,7 +79,7 @@ test('workspace disable and instructor expiry are enforced without a new login', await authorizeCailRequest(req(), env); await db.prepare('UPDATE app_users SET role = ?, role_expires_at = ?, updated_by = ?, revision = revision + 1 WHERE subject = ?') .bind('instructor', new Date(Date.now() + 60000).toISOString(), TEST_SUBJECTS.bob, TEST_SUBJECTS.alice).run(); - assert.equal(await authorizeCailRequest(req('/api/teacher/prompt'), env), null); + assert.equal(await authorizeCailRequest(req('/api/teacher/folders'), env), null); await db.prepare('UPDATE app_users SET disabled = 1, revision = revision + 1 WHERE subject = ?').bind(TEST_SUBJECTS.alice).run(); assert.equal((await authorizeCailRequest(req(), env))?.status, 403); const events = await db.prepare('SELECT * FROM app_user_events').all(); @@ -100,21 +101,21 @@ test('permanent instructor access remains app-local, revocable and subject to cu await db.prepare('UPDATE app_users SET role = ?, role_expires_at = NULL, updated_by = ?, revision = revision + 1 WHERE subject = ?') .bind('instructor', TEST_SUBJECTS.bob, TEST_SUBJECTS.alice).run(); const principals: AppPrincipal[] = []; - assert.equal(await authorizeCailRequest(req('/api/teacher/prompt'), env, principal => principals.push(principal)), null); + assert.equal(await authorizeCailRequest(req('/api/teacher/folders'), env, principal => principals.push(principal)), null); assert.equal(principals[0]?.role, 'instructor'); assert.equal((await authorizeCailRequest(req('/api/admin/users'), env))?.status, 403); - assert.equal((await authorizeCailRequest(req('/api/teacher/prompt', bob), env))?.status, 403); + assert.equal((await authorizeCailRequest(req('/api/teacher/folders', bob), env))?.status, 403); assert.equal((await authorizeCailRequest(req('/api/jobs/not-owned'), env))?.status, 404); membership.expiresAt = new Date(Date.now() - 1000).toISOString(); - assert.equal((await authorizeCailRequest(req('/api/teacher/prompt'), env))?.status, 403); + assert.equal((await authorizeCailRequest(req('/api/teacher/folders'), env))?.status, 403); membership.expiresAt = new Date(Date.now() + 60000).toISOString(); - assert.equal(await authorizeCailRequest(req('/api/teacher/prompt'), env), null); + assert.equal(await authorizeCailRequest(req('/api/teacher/folders'), env), null); env.ADMISSION_RESOLVER = { resolveMembership: async () => ({ ok: false, code: 'not_admitted' }) }; - assert.equal((await authorizeCailRequest(req('/api/teacher/prompt'), env))?.status, 403); + assert.equal((await authorizeCailRequest(req('/api/teacher/folders'), env))?.status, 403); env.ADMISSION_RESOLVER = { resolveMembership: async () => membership }; await db.prepare('UPDATE app_users SET disabled = 1, revision = revision + 1 WHERE subject = ?').bind(TEST_SUBJECTS.alice).run(); - assert.equal((await authorizeCailRequest(req('/api/teacher/prompt'), env))?.status, 403); + assert.equal((await authorizeCailRequest(req('/api/teacher/folders'), env))?.status, 403); }); test('a null instructor end date is distinct from expired, empty or malformed dates', async () => { @@ -126,6 +127,6 @@ test('a null instructor end date is distinct from expired, empty or malformed da const principals: AppPrincipal[] = []; assert.equal(await authorizeCailRequest(req(), env, principal => principals.push(principal)), null); assert.equal(principals[0]?.role, 'student'); - assert.equal((await authorizeCailRequest(req('/api/teacher/prompt'), env))?.status, 403); + assert.equal((await authorizeCailRequest(req('/api/teacher/folders'), env))?.status, 403); } }); diff --git a/cloudflare/playback.spec.mjs b/cloudflare/playback.spec.mjs index 9a061d9..ec8f77b 100644 --- a/cloudflare/playback.spec.mjs +++ b/cloudflare/playback.spec.mjs @@ -507,3 +507,133 @@ test('a frozen media clock parks every stem rather than leaving a half-playing m await page.locator('.play-btn').click(); } finally { await server.close(); } }); + +test('every waveform uses the shared seek commit for mouse, keyboard and playing stems', async ({ page }) => { + const errors = []; page.on('pageerror', error => errors.push(error.message)); + const names = ['vocals', 'drums', 'bass', 'guitar', 'piano', 'other']; + const server = await fixture(page, names); + try { + await page.evaluate(() => { + const m = mixers.get('remix-fixture'), original = m.seekTo.bind(m); + m.seekCalls = 0; m.seekTo = time => { m.seekCalls++; return original(time); }; + }); + for (let index = 0; index < names.length; index++) { + await page.setViewportSize({ width: index % 2 ? 320 : 1280, height: 1000 }); + const range = page.getByRole('slider', { name: `${names[index]} waveform position in Playback review`, exact: true }); + await expect(range).toBeEnabled(); + await expect(range).toHaveAccessibleName(`${names[index]} waveform position in Playback review`); + await range.scrollIntoViewIfNeeded(); const box = await range.boundingBox(); + const before = await page.evaluate(() => { const m = mixers.get('remix-fixture'); return { time: m.audios[0].currentTime, calls: m.seekCalls }; }); + await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down(); + await page.mouse.move(box.x + box.width * .6, box.y + box.height / 2, { steps: 8 }); + const preview = await page.evaluate(() => { const m = mixers.get('remix-fixture'); return { time: m.audios[0].currentTime, calls: m.seekCalls, scrubbing: m.scrubbing, values: m.seekControls.map(c => c.value) }; }); + expect(preview.time).toBeCloseTo(before.time, 2); expect(preview.calls).toBe(before.calls); + expect(preview.scrubbing).toBe(true); expect(new Set(preview.values).size).toBe(1); + await page.mouse.up(); + await expect.poll(() => page.evaluate(() => mixers.get('remix-fixture').seekCalls)).toBe(before.calls + 1); + await expect.poll(() => page.evaluate(() => mixers.get('remix-fixture').audios.every(a => Math.abs(a.currentTime - 42) < .5 && a.paused))).toBe(true); + await expect(range).toHaveAttribute('aria-valuetext', /0:4[12] of 1:10/); + await range.press('Home'); await expect(page.locator('.tc-now')).toHaveText('0:00'); + await range.press('ArrowRight'); + await expect.poll(() => page.evaluate(() => mixers.get('remix-fixture').audios.every(a => a.currentTime > 0 && a.currentTime < .2))).toBe(true); + } + const range = page.locator('.waveform-seek').last(); + await range.press('End'); await expect(page.locator('.tc-now')).toHaveText('1:10'); + await range.press('Home'); + await mkdir('/tmp/stem-classroom-screenshots', { recursive: true }); + await page.locator('.console').screenshot({ path: `/tmp/stem-classroom-screenshots/waveform-seek-focus-${process.env.STEM_BROWSER || 'chrome'}.png` }); + await page.locator('.rate-opt[data-rate="0.75"]').click(); await page.locator('.play-btn').click(); await clockAdvances(page); + const box = await range.boundingBox(); + await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down(); + await page.mouse.move(box.x + box.width * .5, box.y + box.height / 2, { steps: 6 }); + expect(await page.evaluate(() => mixers.get('remix-fixture').playing)).toBe(true); + await page.mouse.up(); await clockAdvances(page); + await expect.poll(() => page.evaluate(() => { const m = mixers.get('remix-fixture'); return m.audios.every(a => !a.paused && a.playbackRate === .75 && Math.abs(a.currentTime - m.audios[0].currentTime) < .2); })).toBe(true); + await page.locator('.play-btn').click(); + expect(await page.evaluate(() => mixers.get('remix-fixture').audios.every(a => a.paused))).toBe(true); + expect(errors).toEqual([]); + } finally { await server.close(); } +}); + +test('waveform seeking fences cancellation, stale track input, missing metadata and disposed songs', async ({ page }) => { + const errors = []; page.on('pageerror', error => errors.push(error.message)); + const server = await fixture(page, ['vocals', 'drums', 'bass', 'other']); + const preview = async (range, pointerId, value) => { + await range.dispatchEvent('pointerdown', { pointerId }); + await range.evaluate((control, value) => { control.value = String(value); control.dispatchEvent(new Event('input', { bubbles: true })); }, value); + }; + try { + const first = page.locator('.waveform-seek').first(), second = page.locator('.waveform-seek').nth(1); + await page.evaluate(() => mixers.get('remix-fixture').seekTo(14)); + for (const event of ['pointercancel', 'blur', 'Escape']) { + await preview(first, 41, 800); + if (event === 'blur') await page.evaluate(() => window.dispatchEvent(new Event('blur'))); + else if (event === 'Escape') await first.press('Escape'); + else await first.dispatchEvent(event, { pointerId: 41 }); + await expect.poll(() => page.evaluate(() => mixers.get('remix-fixture').scrubbing)).toBe(false); + await expect(page.locator('.tc-now')).toHaveText('0:14'); + } + await preview(first, 41, 300); + await first.dispatchEvent('lostpointercapture', { pointerId: 41 }); + await expect(page.locator('.tc-now')).toHaveText('0:21'); + await preview(first, 41, 400); + await preview(second, 42, 700); + // Late change from the former track cannot overwrite the active gesture. + await first.evaluate(control => { control.value = '100'; control.dispatchEvent(new Event('change', { bubbles: true })); }); + await page.locator('body').dispatchEvent('pointerup', { pointerId: 41 }); + expect(await page.evaluate(() => mixers.get('remix-fixture').scrubbing)).toBe(true); + await page.locator('body').dispatchEvent('pointerup', { pointerId: 42 }); + await expect(page.locator('.tc-now')).toHaveText('0:49'); + for (const duration of [0, NaN, Infinity]) { + await page.evaluate(duration => { const m = mixers.get('remix-fixture'); Object.defineProperty(m.audios[1], 'duration', { configurable: true, value: duration }); m.paint(); m.seekTo(5); }, duration); + await expect(first).toBeDisabled(); await expect(page.locator('.seek')).toBeDisabled(); + expect(await page.evaluate(() => mixers.get('remix-fixture').audios.every(a => Math.abs(a.currentTime - 49) < .1))).toBe(true); + await page.evaluate(() => { const m = mixers.get('remix-fixture'); delete m.audios[1].duration; m.paint(); }); + } + await expect(first).toBeEnabled(); + await page.evaluate(() => { + const old = mixers.get('remix-fixture'), next = new Mixer({ ...old.job, id: 'second-song', filename: 'Second song' }); + mixers.set('second-song', next); jobList.appendChild(next.el); + }); + const other = page.locator('.console').last().locator('.waveform-seek').first(); + await expect(other).toBeEnabled(); + await preview(first, 51, 200); + await preview(other, 52, 600); + await page.locator('body').dispatchEvent('pointerup', { pointerId: 51 }); + expect(await page.evaluate(() => mixers.get('second-song').scrubbing)).toBe(true); + await page.locator('body').dispatchEvent('pointerup', { pointerId: 52 }); + await expect.poll(() => page.evaluate(() => mixers.get('second-song').audios.every(a => Math.abs(a.currentTime - 42) < .1))).toBe(true); + expect(await page.evaluate(() => mixers.get('remix-fixture').audios.every(a => Math.abs(a.currentTime - 14) < .1))).toBe(true); + await preview(first, 61, 900); + await page.evaluate(() => { const m = mixers.get('remix-fixture'); m.disposeWaveforms(); m.el.remove(); }); + await page.locator('body').dispatchEvent('pointerup', { pointerId: 61 }); + expect(await page.evaluate(() => { const m = mixers.get('remix-fixture'); return !m.scrubbing && m.audios.every(a => Math.abs(a.currentTime - 14) < .1); })).toBe(true); + expect(errors).toEqual([]); + } finally { await server.close(); } +}); + +test('waveforms support native touch input without moving the narrow page or controls', async ({ browser, browserName }) => { + const context = await browser.newContext({ hasTouch: true, viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); + const server = await fixture(page, ['vocals', 'instrumental']); + try { + for (const range of await page.locator('.waveform-seek').all()) { + await range.scrollIntoViewIfNeeded(); const box = await range.boundingBox(); + await page.touchscreen.tap(box.x + box.width * .5, box.y + box.height / 2); + await expect.poll(() => page.evaluate(() => mixers.get('remix-fixture').audios.every(a => Math.abs(a.currentTime - 35) < .5 && a.paused))).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } + if (browserName === 'chromium') { + const range = page.locator('.waveform-seek').last(), box = await range.boundingBox(); + const client = await context.newCDPSession(page); + const touch = x => ({ x, y: box.y + box.height / 2, id: 1 }); + await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [touch(box.x + box.width * .5)] }); + for (const fraction of [.55, .6, .7, .8]) await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [touch(box.x + box.width * fraction)] }); + expect(await page.evaluate(() => mixers.get('remix-fixture').scrubbing)).toBe(true); + await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await expect.poll(() => page.evaluate(() => mixers.get('remix-fixture').audios.every(a => Math.abs(a.currentTime - 56) < .5))).toBe(true); + await client.detach(); + } + expect(errors).toEqual([]); + } finally { await server.close(); await context.close(); } +}); diff --git a/cloudflare/playwright.config.mjs b/cloudflare/playwright.config.mjs index 036aeb9..43fbf83 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'], workers: 1, timeout: 45000, +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, 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/retention.ts b/cloudflare/retention.ts index a174f08..ef1314e 100644 --- a/cloudflare/retention.ts +++ b/cloudflare/retention.ts @@ -1,4 +1,10 @@ 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 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'))"), + db.prepare("DELETE FROM course_guides WHERE NOT EXISTS(SELECT 1 FROM jobs WHERE id=course_guides.job_id AND created_at>datetime('now','-90 days'))"), + ]); return result.meta.changes ?? 0; } diff --git a/cloudflare/sharing.spec.mjs b/cloudflare/sharing.spec.mjs index c6b7ab2..ea8a605 100644 --- a/cloudflare/sharing.spec.mjs +++ b/cloudflare/sharing.spec.mjs @@ -85,6 +85,10 @@ test('shared split opens signed out, plays, and keeps private tools and data ina await page.locator('.ch-name').click(); await expect(page.locator('.ch-name-input')).toHaveCount(0); expect(await page.evaluate(async () => (await fetch('/api/jobs/remix-fixture/labels', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ labels: { vocals: 'Unauthorized' } }) })).status)).toBe(401); + const refreshRequests=[];page.on('request',request=>{if(new URL(request.url()).pathname.startsWith('/api/'))refreshRequests.push(new URL(request.url()).pathname);}); + await page.locator('.refresh-btn').click();await expect(page.locator('.refresh-status')).toContainText('Updated'); + expect(refreshRequests.filter(path=>path.includes('/jobs/')||path.includes('conversation'))).toEqual([]); + expect(refreshRequests).toContain('/api/shared-jobs/remix-fixture'); await page.locator('.play-btn').click(); await expect(page.locator('.tc-now')).not.toHaveText('0:00', { timeout: 8000 }); await page.locator('.play-btn').click(); diff --git a/cloudflare/sso.test.ts b/cloudflare/sso.test.ts index e3cfb16..f939f8e 100644 --- a/cloudflare/sso.test.ts +++ b/cloudflare/sso.test.ts @@ -27,6 +27,26 @@ function fixture() { } const pair = (response: Response, name: string) => response.headers.getSetCookie().find(c => c.startsWith(name + '='))!.split(';')[0]; +test('classroom sign-in preserves only scoped local destinations through the handoff', async () => { + const course = '/classroom.html?course=msh-245-the-american-musical-experience-fall-2026-01'; + const destinations = ['/classroom.html', course, `${course}&folder=${code}`, `${course}&job=${code}`]; + for (const next of destinations) { + assert.equal(safeNext(next), next); + const f = fixture(); + const start = await handleAuth(f.request('/auth/login?next=' + encodeURIComponent(next)), f.env); + const callback = await handleAuth(f.request(`/auth/callback?code=${code}&state=${f.state()}`, { + headers: { Cookie: pair(start, LOGIN_COOKIE) }, + }), f.env); + assert.equal(callback.status, 303); + assert.equal(callback.headers.get('location'), next); + } + for (const next of [`${course}&next=//attacker.test`, `${course}&folder=../../auth/logout`, + `${course}&folder=${code}&job=${code}`, `${course}#private`, '/classroom.html?course=search', + '//attacker.test/classroom.html', '/classroom.html?course=%2f%2fattacker.test', '/classroom.html?course=bad&course=good']) { + assert.equal(safeNext(next), '/'); + } +}); + test('browser sign-in failures offer safe recovery while JSON clients keep the auth contract', async () => { const f = fixture(); const path = '/auth/callback?code=private-code&state=private-state&next=https://attacker.test'; diff --git a/cloudflare/sso.ts b/cloudflare/sso.ts index 3292c0c..bff9574 100644 --- a/cloudflare/sso.ts +++ b/cloudflare/sso.ts @@ -2,6 +2,7 @@ import { uniqueCookie, parseLoginTransaction } from './session-contract/session. 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'; // Doorway owns CUNY OIDC, one-use PKCE grants, session revocation and Admission. // These RPC capabilities have deployment-pinned audiences and callback hosts. @@ -29,8 +30,12 @@ const random = () => base64url(crypto.getRandomValues(new Uint8Array(32))); export function safeNext(value: unknown): string { if (typeof value === 'string' && /^\/\?job=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) return value; + if (typeof value === 'string') { + const classroom = /^\/classroom\.html\?course=([a-z0-9-]+)(?:&(?:folder|job)=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$/.exec(value); + if (classroom && validClassId(classroom[1])) return value; + } // Fixed page destinations, never a caller-selected host, callback or API. - return typeof value === 'string' && ['/', '/teacher.html', '/account.html'].includes(value) ? value : '/'; + return typeof value === 'string' && ['/', '/teacher.html', '/account.html', '/classroom.html'].includes(value) ? value : '/'; } const readCookie = uniqueCookie; function identityClient(request: Request, env: SsoEnv): WorkerIdentity | undefined { @@ -80,7 +85,7 @@ function signInFailure(request: Request, status = 401): Response { 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex, nofollow', 'Content-Security-Policy': "default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" }); if (safe === 429) headers.set('Retry-After', '60'); - return new Response(`STEM Splitter · Sign in

${title}

${message}

${access}

Back to Splitter

`, { status: safe, headers }); + return new Response(`Stem Splitter · Sign in

${title}

${message}

${access}

Back to Splitter

`, { status: safe, headers }); } async function existingSessionStatus(request: Request, env: SsoEnv): Promise<200 | 401 | 403 | 503> { const token = readCookie(request, SESSION_COOKIE); @@ -127,7 +132,7 @@ export async function handleAuth(request: Request, env: SsoEnv): PromiseSTEM Splitter · Sign out

Signed out of this browser

We could not confirm that your server session ended. Close any other STEM Splitter tabs on this device.

Back to Splitter
', { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer' } }); + const response = new Response('Stem Splitter · Sign out

Signed out of this browser

We could not confirm that your server session ended. Close any other Stem Splitter tabs on this device.

Back to Splitter
', { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer' } }); for (const value of cleared) response.headers.append('Set-Cookie', value); return response; } diff --git a/cloudflare/test-worker.ts b/cloudflare/test-worker.ts index 4a280e5..2141b2e 100644 --- a/cloudflare/test-worker.ts +++ b/cloudflare/test-worker.ts @@ -1,8 +1,10 @@ +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. import preview, { type WorkerEnv } from './worker.ts'; import { purgeExpiredListeningConversations } from './retention.ts'; import { SESSION_COOKIE, type WorkerIdentity } from './sso.ts'; -type TestEnv = WorkerEnv & { TEST_JWKS: string; TEST_ADMIN: string; TEST_BROWSER?: string }; +type TestEnv = WorkerEnv & { TEST_JWKS: string; TEST_ADMIN: string; TEST_BROWSER?: string; TEST_ROSTER_SUBJECT?: string }; let gatewayCalls = 0; let gatewayMessages: Array<{ role: string; content: string }> = []; export default { @@ -26,6 +28,8 @@ export default { const jwt = request.headers.get('x-fixture-identity'); const gatewayJwt = request.headers.get('x-fixture-gateway-identity') || ''; const gatewayMode = request.headers.get('x-fixture-gateway-mode'); + const courseOwner=request.headers.get('x-fixture-course-role')==='owner'; + const courseRevoked=request.headers.get('x-fixture-course-state')==='revoked'; const fixtureToken = '00000000-0000-4000-8000-000000000001.' + 'a'.repeat(43); const identity: WorkerIdentity = { begin: async () => { throw new Error('No fixture login'); }, @@ -62,7 +66,11 @@ export default { 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, - ADMISSION_RESOLVER: { resolveMembership: async ({ subject }) => ({ + ADMISSION_RESOLVER: { + resolveCourseAccess: async ({subject,classId}) => courseRevoked?{ok:false,code:'revoked',retryable:false}:courseFixture(subject,classId,courseOwner), + listCourseAssignments: async ({subject}) => {const assignment=await courseFixture(subject,STEM_COURSE_ID,courseOwner);return {ok:true,checkedAt:assignment.checkedAt,expiresAt:assignment.expiresAt,revision:1,assignments:[(({ok,...row})=>row)(assignment)],nextCursor:null};}, + listCourseRoster: async ({subject,classId}) => {const assignment=await courseFixture(subject,classId,true);return {ok:true,classId,checkedAt:assignment.checkedAt,expiresAt:assignment.expiresAt,revision:1,participants:env.TEST_ROSTER_SUBJECT?await Promise.all([env.TEST_ROSTER_SUBJECT,'cail-00000000000000000000000000000000'].map(async member=>{const row=await courseFixture(member,classId);return {memberId:row.memberId,displayName:member===env.TEST_ROSTER_SUBJECT?'Fixture Student with a long preferred display name':'Zero split student',displayNameSource:row.displayNameSource,startsAt:row.startsAt,expiresAt:row.expiresAt};})):[],nextCursor:null};}, + resolveMembership: async ({ subject }) => ({ ok: true, expiresAt: new Date(Date.now() + 60000).toISOString(), revision: 1, accessRole: subject === env.TEST_ADMIN ? 'admin' : 'member', budgetScope: subject === env.TEST_ADMIN ? 'admin' : 'person', diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 66f4932..1e2606f 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -211,7 +211,7 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners const grantRole = { method: 'PUT', body: JSON.stringify({ role: 'instructor', expiresAt: new Date(Date.now() + 60000).toISOString(), disabled: false, revision: alice.revision }) }; assert.equal((await call(`/api/admin/users/${subjects[0]}`, 2, grantRole)).status, 200); assert.equal((await call(`/api/admin/users/${subjects[0]}`, 2, grantRole)).status, 409); - assert.equal((await call('/api/teacher/prompt')).status, 200); + assert.equal((await call('/api/teacher/folders')).status, 200); const permanentBody = { role: 'instructor', expiresAt: null, disabled: false, revision: alice.revision + 1 }; for (const expiresAt of [undefined, '', 'invalid', false, 0, new Date(Date.now() - 1000).toISOString()]) { assert.equal((await call(`/api/admin/users/${subjects[0]}`, 2, { method: 'PUT', body: JSON.stringify({ ...permanentBody, expiresAt }) })).status, 400); @@ -226,9 +226,9 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners assert.equal(updated.role_expires_at, null); assert.equal(updated.revision, alice.revision + 2); assert.equal((await (await call('/api/account')).json()).account.role, 'instructor'); - assert.equal((await call('/api/teacher/prompt')).status, 200); + assert.equal((await call('/api/teacher/folders')).status, 200); assert.equal((await call('/api/admin/users')).status, 403); - assert.equal((await call('/api/teacher/prompt', 1)).status, 403); + assert.equal((await call('/api/teacher/folders', 1)).status, 403); // The existing immutable trigger records the explicit permanent grant. const audit = await worker.fetch('/__fixture/schema', { method: 'POST', headers: { 'x-fixture': 'local-only' }, body: JSON.stringify([ 'CREATE TABLE audit_check (ok INTEGER CHECK (ok = 1))', diff --git a/docs/acceptance/classroom-2026-10-06.md b/docs/acceptance/classroom-2026-10-06.md new file mode 100644 index 0000000..fd14fb2 --- /dev/null +++ b/docs/acceptance/classroom-2026-10-06.md @@ -0,0 +1,99 @@ +# Classroom candidate checks — October 6, 2026 + +This is local candidate evidence for PR2, based on baseline +`e1918da3cb6fc37f9715d4cd143af5a5c6c880c6`. It is not a deployment or live-account +acceptance receipt. The [access and migration contract](../classroom-access.md) +defines the authority, prospective disclosure, retention, pause and release +policy. The independent reviewer records their own findings separately. + +## Automated checks + +| Check | Result | Local evidence | +| --- | --- | --- | +| Root `bun run test` | Typechecks; 321 Worker, 42 server, 5 separator, 30 discovery and 9 comparator tests pass | `/tmp/stem-pr2-root-final.log` | +| Cloudflare `bun run typecheck && bun run test` | 50 tests pass | `/tmp/stem-classroom-unit-portable.log` | +| Full Cloudflare Chrome browser suite | 36 tests pass | `/tmp/stem-pr2-chrome-final.log` | +| Firefox playback, sharing and classroom suite | 25 pass; native Chromium zoom intentionally skipped | `/tmp/stem-pr2-firefox-final.log` | +| WebKit playback, sharing and classroom suite | 25 pass; native Chromium zoom intentionally skipped | `/tmp/stem-pr2-webkit-final.log` | +| Native Chromium 200% follow-up | Waveform mouse and keyboard seek move both stems together; note/speed spacing passes | `/tmp/stem-pr2-native-zoom-final.log` | +| Fully mocked local-hosting browser suite | 19 tests pass | `/tmp/stem-local-hosting-final.log` | +| Local Wrangler D1 baseline → migration 0021 | Old private jobs, note text and conversation revision/text unchanged; zero course assignments manufactured; lease column present | `/tmp/stem-classroom-d1-reviewed.log` | +| Cloudflare Wrangler dry build | Pass; no deployment | `/tmp/stem-pr2-dryrun-final.log` | +| Production dependency audit, Cloudflare | No vulnerabilities in 6 audited packages | `/tmp/stem-classroom-cloudflare-audit.log` | +| Production dependency audit, legacy root | Four pre-existing moderate Hono advisories; frozen root lock unchanged | `/tmp/stem-classroom-root-audit.log` | + +The actual Cloudflare production and test bundles both alias Hono to +`hono-cloudflare` 4.13.11. The separate root Hono 4.12.34 audit reports +`GHSA-gqvv-2mrq-wpjv`, `GHSA-g6gw-c38x-mqfc`, `GHSA-crvj-82cr-hjcx`, and +`GHSA-hxh3-vqpv-xpqv`. This PR does not claim the frozen native-analyzer/root +dependency tree is clean or change the Railway lane. + +The strict wire tests mirror the byte-for-byte Portal fixture pinned at +`5f13f332c120c6712aead2a355b3cf5bcc6dae58`. They cover malformed UTC dates, +inverted validity windows, stale receipts and access markers on plain assignment +pages. Conversation tests cover concurrent duplicate input, late completion +after reset with the same client message ID, interrupted claims, and storage +failure before/after commit. Recovery preserves earlier messages and does not +repeat an uncertain model operation. The upgrade fixture has a pinned baseline +commit and digest; it no longer depends on Git history being present in CI. + +## Browser observations + +The Browser plugin was not available. Repository Playwright drove isolated +local Workers with synthetic identities, roster entries, stored messages and +audio. No paid model/separation calls or real account mutations were made. +Local URL ports are assigned per test; the exercised page routes and titles are: + +| Route | Title | Interaction and observation | +| --- | --- | --- | +| `/?job=course-song` | Stem Splitter | Course peer reads named notes, refreshes during real audio playback, keeps audio element/time/rate/loop/volume and drafts; access revocation clears saved private content and stops playback | +| `/teacher.html` | Stem Splitter · Instructor | Current course owner edits that course's prompt; stale revisions conflict; changing accounts clears drafts | +| `/classroom.html` | Stem Splitter · Courses | Owner sees a zero-split student, reads all 70 test messages through pagination, creates a private course folder and edits course grants | +| `/account.html` | Stem Splitter · Account | Account actions remain separate from verified course ownership; existing recovery/sign-out browser coverage passes | +| Existing public shared route | Stem Splitter | Refresh uses only the public audio projection and never requests private job or conversation routes | + +Refresh and classroom workflows collect uncaught page errors and finish with an +empty collection. Offline failures and revoked access are deliberately injected +and shown as recoverable status messages. Tests assert overflow and usable +controls at widths 320, 360, 390, 414, 540, 768, 1024 and 1440. Course and sharing +selectors have actual rendered heights of at least 44 pixels in all three +engines. Additional browser regressions hold account checks and course requests +open, return 503/network failures, switch courses twice, and finish stale prompt, +preview, history and folder requests. They verify hidden/inert private views, +paused audio, same-account draft recovery and writes bound to the originating +course. PR27's note-entry/speed-control spacing checks remain in the suite. + +CSS layout zoom and native browser zoom are different checks. The native +Chromium test calls the browser's `tabs.setZoom(2)` in a disposable test profile, +asserts `getZoom() === 2`, doubled device-pixel ratio and reduced CSS viewport, +then clicks a waveform, uses Home/ArrowRight and checks that both audio clocks +move together before checking the note/SAVE and speed controls. Its audio fixture +honors byte-range requests, matching the real media endpoint. The screenshot uses the browser's +visible-tab capture, not CSS zoom. Firefox and WebKit skip only this +Chromium-specific native-zoom test and retain their layout/interaction checks. + +Every stem waveform now contains a named native range input. Waveforms and the +main slider use the same preview, cancellation, keyboard and single multi-stem +commit path. Browser checks cover all six track rows, narrow layouts, paused and +playing clocks, playback rate, same-song track changes, two songs, missing/invalid +durations, detached sessions and stale pointer input. All three engines exercise +native mouse drags, keyboard input and touch taps; Chromium additionally uses +real touch-start/move/end events for a drag. Existing two-, four- and six-stem +playback, mix/focus, seek and waveform-peak checks remain passing. + +Screenshots are outside the repository under +`/tmp/stem-classroom-screenshots/`. Reviewable examples: + +- `classroom-refresh-mobile-chrome.png`: note draft and Refresh failure state. +- `classroom-roster-folders-desktop-chrome.png`: zero-split roster and course work. +- `classroom-folder-detail-chrome.png`: scoped folder and sharing controls. +- `classroom-roster-folders-mobile-webkit.png`: narrow course/folder layout. +- `classroom-prompt-desktop-chrome.png`: saved course prompt controls. +- `classroom-native-browser-200-chromium.png`: actual 200% browser zoom. +- `waveform-seek-focus-chrome.png`: keyboard focus on a narrow waveform control. +- `classroom-account-unavailable-chrome.png`: private view concealed during an outage. + +These checks do not prove a real CUNY MFA return, live enrollment/owner state, +production permissions, real provider output or remote migration. Those remain +separate authorized release acceptance steps. No deployment or live migration +was performed. diff --git a/docs/classroom-access.md b/docs/classroom-access.md new file mode 100644 index 0000000..a86ae81 --- /dev/null +++ b/docs/classroom-access.md @@ -0,0 +1,160 @@ +# Prospective classroom work + +This candidate uses the existing private `AdmissionResolver` binding. The wire +contract and byte-for-byte fixture are pinned to cail-tools-admission commit +`5f13f332c120c6712aead2a355b3cf5bcc6dae58`, +`packages/admission-contract/src/course.ts` and +`packages/admission-contract/fixtures/stem-course-v1.json`. No public registry +endpoint, roster import, new binding, or app-wide instructor promotion is added. + +## Authority and disclosure + +The production default accepts only +`msh-245-the-american-musical-experience-fall-2026-01`. The trusted +`CAIL_COURSE_IDS` configuration can explicitly allow additional courses; it does +not replace the exact target-class student entry requirement. A student enrolled +only in another configured course cannot enter the workspace. A current verified +course owner can use their independent teaching relationship. The existing +Admission/app suspension and separately granted app operational roles remain. + +Every protected request checks current identity, Admission, local disablement, +and exact course authority. Responses are private/no-store. An unavailable, +malformed, expired, scheduled, or stale authority receipt never grants access. +`X-Stem-Course` is a selector. A resource's stored `job_courses.course_id` takes +precedence over it. Course owners do not become app instructors or admins. +`X-Stem-Account` identifies only the requesting account so a restored or changed +browser session can discard the previous account's visible state. +An identity recheck immediately hides and disables the private view and pauses +its audio. An outage keeps the view concealed with a Retry action. Only a +verified same-account response restores its drafts; account changes clear them. +Course selections also have independent request generations: pending/failed +loads clear and disable prior course data, and late reads or writes cannot +replace the newly selected course's prompt, history, roster or folder state. + +New work defaults to Personal. Course work requires explicit selection and the +`course-work-v1` disclosure acknowledgment before creation. Job ownership and +course association are inserted together. Associations are immutable. Existing +jobs, annotations, and `listening_conversations` documents are never assigned, +backfilled, copied to the course store, or disclosed to an instructor. No endpoint +changes an existing personal split to course work. + +| Actor | Personal/unassigned work | Assigned course work | Conversation | Course settings/folders | +| --- | --- | --- | --- | --- | +| Current job owner | Existing operational access | Read, notes, own work actions | Own read/chat/reset | Current participant may read granted folders | +| Current course owner | No access to another owner's work | Read retained course work; comment as self | Separate read-only review; no generation or reset | Edit only their course prompt and folder grants | +| Current course participant | No access to another owner's work | Read only through a read/comment folder grant; comment only with comment grant | Never a classmate's conversation | Read granted folders only | +| App instructor only | Existing own-work and legacy instructor scope | No added course privilege | No added access | No course prompt/roster privilege | +| App admin only | Existing operational job/media scope retained | No course bypass | Personal transcript remains owner-only | No course-owner bypass | +| Anonymous/public link | Existing explicit audio-only projection | Same explicit audio-only projection | None | None | + +Course owners create folders; private folders are visible only to current course +owners. Read/comment grants admit current participants of that same course. +Every item is checked independently for the same course, retained audio, and +completed status. Revocation applies to subsequent folder, job, note, and media +requests. Course work cannot be placed into a legacy app folder. Expired items +remain labeled placeholders; folder references do not extend audio retention. +There are no anonymous folder shares. Existing owner-created public audio links +retain their prior policy and never expose notes, labels, names, or transcripts. + +## Messages, names, and prompts + +The roster comes from paginated Admission membership, including students with +zero splits. Course/app-scoped opaque member IDs join it to prospective work; +no student's raw subject, digest, or email is returned. Split lists, message +pages, and prompt history are bounded and scoped before reading. + +New course messages are append-only within the current conversation generation. +The server stores accepted student text and produces assistant/tool records. +Client-supplied assistant history is ignored. A message ID identifies a logical +turn; a separate random server claim fences concurrent attempts and late output. +A stale revision returns a recoverable 409. A pending claim has a 120-second +lease, longer than the model deadline. After a process/storage failure, a +fenced recovery records a visible server status and an interrupted receipt +without erasing earlier messages or rerunning uncertain inference. Reusing that +message ID returns 409; a new message can continue after reload. Expired claims +cannot publish late replies or note effects. Reset removes messages and replay +turns for all viewers, advances the revision, and prevents an old completion from +restoring content even if a client reuses its former message ID. Student and +instructor reads expire with the source split at 90 days; the scheduled purge +also deletes message/turn text and guide caches. The historical 60-entry document +is still private and is not represented as a recoverable complete archive. + +Notes record the trusted initiating subject and a bounded display-name snapshot. +The source mapping is Admission's verified profile name, with the optional +verified identity `name` as a fallback; there is no assumed `preferred_name` +claim. Missing usable names show “Student.” Historical unknown authors show +“Author unavailable.” Browser rendering escapes text, and exports retain names. +Assistant-created notes retain the initiating author and server provenance. +Only an author can edit/delete their notes; no implicit instructor moderation. + +Each course has an independent prompt revision and immutable audit history. +Optimistic revision checks prevent lost edits; a no-op does not create history. +Guide caches include course, base prompt version/hash, and amendment revision. +Publishing an older in-flight result fails its current-prompt check. Personal +work uses the shared base prompt, and the Railway singleton remains separate. + +## Migration and release rehearsal + +Fresh databases use `schema.sql`; existing Cloudflare databases through baseline +`e1918da3cb6fc37f9715d4cd143af5a5c6c880c6` use +`migrations/0021-classroom.sql` once. The additive migration preserves all old +rows and leaves new associations empty. It adds nullable author columns, +prospective course tables, scoped indexes, and immutable assignment/prompt/grant +history triggers. It contains no data backfill. The `ALTER TABLE` statements are +intentionally one-time: do not replay the raw file or apply it to a fresh schema. +Record its checksum and migration receipt in the deployment journal. + +Local tests apply the old schema with private jobs/notes/transcripts, migrate it, +and compare table/column contracts with a fresh database. They assert unchanged +private history and zero manufactured associations. Fresh schema reapplication +is supported; accidental raw migration replay fails at the duplicate column +without rewriting historical content. Workerd browser tests use fresh D1. +The old schema is a digest-checked fixture with its baseline commit recorded in +`cloudflare/fixtures/pre-classroom-schema.md`, so this test also works in shallow +CI checkouts without fetching repository history. + +For a separate authorized rehearsal, from `cloudflare/` use isolated local state: + +```sh +node_modules/.bin/wrangler d1 execute cail-stem-splitter-preview --local --persist-to /tmp/stem-classroom-d1 --file /tmp/stem-baseline.sql +node_modules/.bin/wrangler d1 execute cail-stem-splitter-preview --local --persist-to /tmp/stem-classroom-d1 --file ../migrations/0021-classroom.sql +node_modules/.bin/wrangler deploy --config wrangler.jsonc --dry-run --outdir /tmp/stem-classroom-build +``` + +These are local/dry-run commands. A production migration or service release is a +separate authorization gate. Before that gate, record the exact serving Worker +version, D1 identity, migration journal, backup/time-travel recovery point, +row-count checks, and a restore rehearsal in a separate database. Apply the +migration only to the Cloudflare candidate database; never the root legacy +Worker database or Railway. Release the private Admission methods first, then +the receiving Stem authorization, then the portal destination. Missing private +methods fail closed during that sequence. Run real approved-account acceptance +before claiming the classroom is live. + +`CAIL_CLASSROOM_ENABLED=false` is a narrow maintenance pause, tested locally. +It stops new course creation/writes/model starts, queued course starts through +`authorizeStoredCourseWork`, instructor review, and folder sharing. Assigned +work remains readable only through its exact owner's protected paths; existing +explicit public audio links retain their existing audio-only policy. It does +not erase associations, retention deadlines, or audit history. Re-enabling +restores current-authority checks. No live setting is changed by this PR. + +Do not roll back to pre-classroom code after course work exists: older admin and +folder readers do not understand course boundaries. Use the tested pause on a +schema-aware build while correcting forward. Before any later code rollback, +drain or explicitly settle queued/in-flight work, preserve the migration and +operation journals, and verify the rollback build applies the same private +read policy. PR3's success ledger/queue has an additional migration and drain +procedure; old attempt accounting must never reinterpret it. Already-started +provider work may still finish; neither pause nor rollback promises to cancel +external work or refund it. Retention continues during a pause. + +## Verification scope + +All committed fixtures use synthetic identities, rosters, model output, and +local D1/audio. Browser coverage includes widths 320–1440, PR27 annotation +spacing, preserved audio/control/draft state through Refresh, separate public +Refresh requests, account changes, instructor review and folder controls. +Screenshots are review evidence outside the repository. Native Chromium zoom +and CSS layout zoom are recorded separately. These checks do not establish a +live CUNY MFA callback, actual classroom roster, or paid provider result. diff --git a/migrations/0021-classroom.sql b/migrations/0021-classroom.sql new file mode 100644 index 0000000..2035730 --- /dev/null +++ b/migrations/0021-classroom.sql @@ -0,0 +1,150 @@ +-- Prospective classroom policy. No existing split or transcript is assigned. +CREATE TABLE IF NOT EXISTS job_courses ( + job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE, + course_id TEXT NOT NULL, + member_id TEXT NOT NULL, + assigned_by TEXT NOT NULL, + policy_version TEXT NOT NULL CHECK(policy_version = 'course-work-v1'), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS job_courses_member ON job_courses(course_id, member_id, job_id); +CREATE TRIGGER IF NOT EXISTS job_courses_no_update BEFORE UPDATE ON job_courses +BEGIN + SELECT RAISE(ABORT, 'course assignment is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS job_courses_no_replace BEFORE INSERT ON job_courses +WHEN EXISTS(SELECT 1 FROM job_courses WHERE job_id=NEW.job_id) +BEGIN + SELECT RAISE(ABORT, 'course assignment is immutable'); +END; + +ALTER TABLE annotations ADD COLUMN author_subject TEXT; +ALTER TABLE annotations ADD COLUMN author_name TEXT; +ALTER TABLE annotations ADD COLUMN provenance TEXT; + +CREATE TABLE IF NOT EXISTS course_settings ( + course_id TEXT PRIMARY KEY, + amendment TEXT NOT NULL DEFAULT '' CHECK(length(amendment)<=2000), + revision INTEGER NOT NULL DEFAULT 0 CHECK(revision>=0), + updated_by TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS course_prompt_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK(revision>0), + amendment TEXT NOT NULL CHECK(length(amendment)<=2000), + change_note TEXT NOT NULL CHECK(length(trim(change_note)) BETWEEN 1 AND 240), + base_version TEXT NOT NULL, + base_hash TEXT NOT NULL CHECK(length(base_hash)=64), + effective_hash TEXT NOT NULL CHECK(length(effective_hash)=64), + actor TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(course_id,revision) +); +CREATE TRIGGER IF NOT EXISTS course_prompt_revisions_no_update BEFORE UPDATE ON course_prompt_revisions +BEGIN + SELECT RAISE(ABORT,'course prompt history is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS course_prompt_revisions_no_delete BEFORE DELETE ON course_prompt_revisions +BEGIN + SELECT RAISE(ABORT,'course prompt history is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS course_prompt_revisions_no_replace BEFORE INSERT ON course_prompt_revisions +WHEN EXISTS(SELECT 1 FROM course_prompt_revisions WHERE id=NEW.id OR (course_id=NEW.course_id AND revision=NEW.revision)) +BEGIN + SELECT RAISE(ABORT,'course prompt history is immutable'); +END; +CREATE TABLE IF NOT EXISTS course_guides ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + course_id TEXT NOT NULL, + text TEXT NOT NULL, + model TEXT NOT NULL, + created_at TEXT NOT NULL, + base_version TEXT NOT NULL, + revision INTEGER NOT NULL, + fingerprint TEXT NOT NULL, + PRIMARY KEY(job_id,course_id) +); + +CREATE TABLE IF NOT EXISTS course_conversations ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0 CHECK(revision>=0), + pending_turn TEXT, + pending_expires_at TEXT, + expires_at TEXT NOT NULL, + PRIMARY KEY(job_id,subject) +); +CREATE TABLE IF NOT EXISTS course_turns ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + turn_id TEXT NOT NULL, + input TEXT NOT NULL CHECK(length(input) BETWEEN 1 AND 2000), + state TEXT NOT NULL CHECK(state IN ('pending','complete','failed')), + reply TEXT, + tools TEXT, + finish_reason TEXT, + revision INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(job_id,subject,turn_id) +); +CREATE TABLE IF NOT EXISTS course_messages ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + turn_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('you','coach','action','status')), + provenance TEXT NOT NULL CHECK(provenance IN ('student','server-assistant','server-tool','server-status')), + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS course_messages_page ON course_messages(job_id,subject,seq); + +CREATE TABLE IF NOT EXISTS course_folders ( + id TEXT PRIMARY KEY, + course_id TEXT NOT NULL, + name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 80), + created_by TEXT NOT NULL, + permission TEXT NOT NULL DEFAULT 'private' CHECK(permission IN ('private','read','comment')), + revision INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS course_folders_course ON course_folders(course_id,id); +CREATE TABLE IF NOT EXISTS course_folder_items ( + folder_id TEXT NOT NULL REFERENCES course_folders(id) ON DELETE CASCADE, + job_id TEXT NOT NULL, + filename TEXT NOT NULL, + model TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(folder_id,job_id) +); +CREATE TABLE IF NOT EXISTS course_folder_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id TEXT NOT NULL, + course_id TEXT NOT NULL, + actor TEXT NOT NULL, + permission TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(folder_id,revision) +); +CREATE TRIGGER IF NOT EXISTS course_folder_events_no_update BEFORE UPDATE ON course_folder_events +BEGIN + SELECT RAISE(ABORT,'folder history is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS course_folder_events_no_delete BEFORE DELETE ON course_folder_events +BEGIN + SELECT RAISE(ABORT,'folder history is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS course_folder_events_no_replace BEFORE INSERT ON course_folder_events +WHEN EXISTS(SELECT 1 FROM course_folder_events WHERE id=NEW.id OR (folder_id=NEW.folder_id AND revision=NEW.revision)) +BEGIN + SELECT RAISE(ABORT,'folder history is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS course_folders_audit AFTER UPDATE OF permission ON course_folders +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; diff --git a/public/account.html b/public/account.html index 410f0c7..4f49543 100644 --- a/public/account.html +++ b/public/account.html @@ -3,9 +3,10 @@ -STEM Splitter · Account - +Stem Splitter · Account +
+

My account

@@ -17,23 +18,23 @@

My account

Daily split allowance unavailable.

Model usage unavailable.

-
- + + diff --git a/public/account.js b/public/account.js index 9bb78c1..c429909 100644 --- a/public/account.js +++ b/public/account.js @@ -31,6 +31,7 @@ window.addEventListener('focus', async () => { }); async function request(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', ...options }); + window.StemSessionGuard?.observe(response); const body = await response.json(); if (!response.ok) throw new Error(body.error?.message || body.error || 'Please try again.'); return body; @@ -125,6 +126,7 @@ el('access-form').addEventListener('submit', async (event) => { try { const { account, splitAllowance } = await request('/api/account'); subject = account.subject; + window.StemSessionGuard?.start(subject,()=>{users=[];subject='';}); accountRole = account.role; el('account-status').textContent = ''; el('account-role').textContent = `${{ admin: 'Administrator', instructor: 'Instructor', student: 'Student' }[account.role] || 'Student'} access`; @@ -132,7 +134,7 @@ el('access-form').addEventListener('submit', async (event) => { el('account-details').hidden = false; el('account-footer').hidden = false; el('account-reference').hidden = false; - el('account-guidance').hidden = !['admin', 'instructor'].includes(account.role); + el('account-guidance').hidden = !(['admin', 'instructor'].includes(account.role)||account.course?.owner); el('account-admin').hidden = account.role !== 'admin'; if (splitAllowance && Number.isInteger(splitAllowance.limit) && Number.isInteger(splitAllowance.remaining) && splitAllowance.limit > 0 && splitAllowance.remaining >= 0 && splitAllowance.remaining <= splitAllowance.limit && diff --git a/public/app.js b/public/app.js index 2c48872..658e29a 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,7 @@ let accountSubject = null; let accountJobs = []; let rackCursor = null; let rackLoading = false; +let selectedCourse = 'personal'; // Solo has two stages on purpose, and this is the quiet one: the rest of the // band drops back instead of disappearing, so you hear a part in its place @@ -204,16 +205,22 @@ async function ensureClassCode() { } async function api(path, options = {}) { + if (runtime.authMode === 'cail' && selectedCourse !== 'personal' && options.method === 'POST' && ['/api/uploads','/api/jobs'].includes(path)) { + if (!document.getElementById('course-consent').checked) throw new Error('Confirm the course visibility notice before creating course work.'); + if (path === '/api/jobs') options = { ...options, body: JSON.stringify({ ...JSON.parse(options.body || '{}'), coursePolicy:'course-work-v1' }) }; + } const res = await fetch(path, { ...options, headers: { 'Content-Type': 'application/json', 'x-class-code': getClassCode(), + ...(runtime.authMode==='cail'?{'x-stem-course':selectedCourse}:{}), ...(options.headers || {}), }, }); + window.StemSessionGuard?.observe(res); if (res.status === 401) { - if (runtime.authMode === 'cail') throw new Error('Sign in with CUNY Login to continue.'); + if (runtime.authMode === 'cail') throw Object.assign(new Error('Sign in with CUNY Login to continue.'),{status:401}); localStorage.removeItem('classCode'); void ensureClassCode(); throw new Error('Invalid class code — enter it and retry.'); @@ -223,7 +230,7 @@ async function api(path, options = {}) { const reset = new Date(body.resetsAt).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }); throw new Error(`You've used today's runs. You can split again ${reset}.`); } - if (!res.ok) throw new Error(body.error?.message || body.error || `Request failed (${res.status})`); + if (!res.ok) throw Object.assign(new Error(body.error?.message || body.error || `Request failed (${res.status})`),{status:res.status}); return body; } @@ -237,8 +244,9 @@ async function streamApi(path, body, onEvent, signal) { body: JSON.stringify(body), signal, }); + window.StemSessionGuard?.observe(res); if (res.status === 401) { - if (runtime.authMode === 'cail') throw new Error('Sign in with CUNY Login to continue.'); + if (runtime.authMode === 'cail') throw Object.assign(new Error('Sign in with CUNY Login to continue.'),{status:401}); localStorage.removeItem('classCode'); void ensureClassCode(); throw new Error('Invalid class code — enter it and retry.'); @@ -1277,7 +1285,8 @@ async function diagnoseUploadFailure(file) { } try { const res = await fetch('/api/auth-check', { headers: { 'x-class-code': getClassCode() } }); - if (res.status === 401) { + window.StemSessionGuard?.observe(res); + if (res.status === 401) { if (runtime.authMode === 'cail') return 'Upload failed — sign in with CUNY Login and try again.'; localStorage.removeItem('classCode'); void ensureClassCode(); @@ -1377,6 +1386,7 @@ class Mixer {
+
@@ -1471,6 +1481,7 @@ class Mixer { } catch { showUploadMessage('Could not stop sharing. Please try again.', true); } }); this.folderBtn = li.querySelector('.folder-btn'); + if (this.job.courseId && this.job.permissions?.instructor) this.folderBtn.style.display='inline-block'; this.folderBtn.addEventListener('click', () => { const actionsWereOpen = this.el.classList.contains('actions-open'); toggleFolderMenu(this); @@ -1488,7 +1499,7 @@ class Mixer { this.splitMetaEl = li.querySelector('.split-meta'); this.toRemixBtn = li.querySelector('.to-remix-btn'); this.toRemixBtn.addEventListener('click', () => sendJobToRemixer(this.job)); - this.shareBtn = li.querySelector('.share-btn:not(.to-remix-btn)'); + this.shareBtn = li.querySelector('.share-btn:not(.to-remix-btn):not(.refresh-btn)'); this.shareBtn.addEventListener('click', () => this.copyLink()); this.rateGroup = li.querySelector('.rate'); this.loopRegion = li.querySelector('.loop-region'); @@ -1576,7 +1587,9 @@ class Mixer { row.style.setProperty('--ch', `var(--c-${cssName(stem.name)}, var(--ink-dim))`); row.innerHTML = ` ${esc(this.label(stem.name))} - + + + @@ -1595,6 +1608,7 @@ class Mixer { bars: [...row.querySelectorAll('.meter i')], levels: new Float32Array(METER_BANDS.length), waveform: row.querySelector('.waveform'), + seek: row.querySelector('.waveform-seek'), waveformStatus: 'idle', waveformPeaks: null, mixGain: 1, @@ -1605,7 +1619,9 @@ class Mixer { ); soloBtn.addEventListener('click', () => this.cycleSolo(stem.name)); audio.addEventListener('error', () => this.markAudioUnavailable(stem.name)); - audio.addEventListener('loadedmetadata', () => this.loadWaveforms()); + audio.addEventListener('loadedmetadata', () => { this.loadWaveforms(); this.paint(); }); + audio.addEventListener('durationchange', () => this.paint()); + audio.addEventListener('emptied', () => { this.endScrub(false); this.paint(); }); const nameEl = row.querySelector('.ch-name'); nameEl.addEventListener('click', () => this.editLabel(stem.name, nameEl)); @@ -1634,57 +1650,25 @@ class Mixer { this.playBtn.addEventListener('click', () => (this.playing || this.starting ? this.pause() : this.play())); - // Preview while dragging; seek every stem only when the gesture finishes. - // Native range event ordering differs across browsers. A late input or a - // missing change must never leave the display permanently in preview mode. + // Every waveform and the main slider share the same native-range lifecycle. this.scrubbing = false; - this.seek.addEventListener('pointerdown', (event) => { - this.endScrub(false); - this.scrubbing = true; - this.scrubPointer = event.pointerId; - // Let the native range own pointer capture. Observe releases outside the - // element too, without taking capture away from its browser-native thumb. - this.scrubEvents = new AbortController(); - const options = { capture: true, signal: this.scrubEvents.signal }; - window.addEventListener('pointerup', (e) => { - if (e.pointerId === this.scrubPointer) this.queueScrubEnd(); - }, options); - window.addEventListener('pointercancel', (e) => { - if (e.pointerId === this.scrubPointer) this.endScrub(false); - }, options); - window.addEventListener('blur', (event) => { - if (event.target === window) this.endScrub(false); - }, options); - }); - this.seek.addEventListener('pointerup', () => this.queueScrubEnd()); - this.seek.addEventListener('pointercancel', () => this.endScrub(false)); - this.seek.addEventListener('lostpointercapture', () => this.queueScrubEnd()); - this.seek.addEventListener('blur', () => this.endScrub(true)); - // Recover a lost release before the next mixer interaction. - li.addEventListener('pointerdown', (event) => { - if (event.target !== this.seek && this.scrubbing) this.endScrub(true); - }, true); - this.seek.addEventListener('input', () => { - this.scrubbing = true; - const t = (this.seek.value / 1000) * (master.duration || 0); - this.scrubTarget = t; - this.seek.style.setProperty('--fill', `${this.seek.value / 10}%`); - this.tcNow.textContent = fmt(t); - for (const channel of this.channelsByName.values()) this.paintWaveform(channel); - // Keyboard/assistive input has no pointer lifecycle. Give native change - // its turn, then commit even if that event is absent. - if (this.scrubPointer == null) this.queueScrubEnd(); - }); - this.seek.addEventListener('change', () => { - this.scrubTarget = (this.seek.value / 1000) * (master.duration || 0); - this.endScrub(true); - }); + this.seekControls = [this.seek, ...[...this.channelsByName.values()].map(channel => channel.seek)]; + for (const control of this.seekControls) this.bindSeekControl(control); + this.paint(); + this.refreshBtn=li.querySelector('.refresh-btn'); + this.refreshStatus=li.querySelector('.refresh-status'); + this.refreshBtn.addEventListener('click',()=>void this.refresh()); this.bindKeys(li); this.renderSplitMeta(); this.applyMix(); this.renderNotes(); this.renderGuide(); + if (this.job.permissions && !this.job.permissions.owner) { + for(const node of li.querySelectorAll('.coach,.folder-btn,.export-btn,.unshare-btn,.share-btn:not(.refresh-btn),.to-remix-btn')) node.hidden=true; + li.querySelector('.note-btn').hidden=!this.job.permissions.comment; + this.folderBtn.hidden=!this.job.permissions.instructor; + } if (this.job.readOnlyShared) { li.classList.add('shared'); const notice = document.createElement('p'); @@ -1816,6 +1800,7 @@ class Mixer { // invisible stale menu instead of opening a fresh one. if (collapsed) this.el.querySelector('.folder-menu')?.remove(); if (this.el.classList.contains('collapsed') === collapsed) return; + if (collapsed) this.endScrub(false); if (collapsed && (this.playing || this.starting)) this.pause(); this.setActionsOpen(false); this.el.classList.toggle('collapsed', collapsed); @@ -1841,13 +1826,14 @@ class Mixer { } if (this.annotations.length) { lines.push('## Notes', ''); - for (const note of this.annotations) lines.push(`- ${fmt(note.atSeconds)} — ${note.text}`); + for (const note of this.annotations) lines.push(`- ${fmt(note.atSeconds)} — ${note.text} (${note.authorName || 'Author unavailable'})`); lines.push(''); } if (runtime.authMode === 'cail' && this.conversationEntries.length) { lines.push('## Listening Guy conversation', ''); for (const entry of this.conversationEntries) { - if (entry.kind === 'action') lines.push(`- Mixer action: ${entry.text}`, ''); + if (entry.kind === 'status') lines.push(`- Conversation status: ${entry.text}`, ''); + else if (entry.kind === 'action') lines.push(`- Mixer action: ${entry.text}`, ''); else lines.push(`**${entry.kind === 'you' ? 'You' : 'Listening Guy'}:** ${entry.text}`, ''); } } else if (this.chatHistory.length) { @@ -1865,7 +1851,16 @@ class Mixer { this.exportBtn.textContent = 'PACKING…'; let saved = false; try { - if (runtime.authMode === 'cail') await this.loadConversation(); + if (runtime.authMode === 'cail') { + await this.loadConversation(); + if(this.durableConversation){ + const all=[];let cursor=null;let revision; + do{const page=await api(`/api/jobs/${this.job.id}/listening-conversation`+(cursor?`?cursor=${encodeURIComponent(cursor)}&revision=${revision}`:'')); + all.push(...page.entries);revision=page.revision;cursor=page.nextCursor; + }while(cursor); + this.conversationEntries=all;this.conversationRevision=revision; + } + } const entries = []; const used = new Set(); for (const stem of this.job.stems) { @@ -2130,6 +2125,8 @@ class Mixer { } disposeWaveforms() { + this.disposed = true; + this.endScrub(false); this.waveformAbort.abort(); this.waveformObserver.disconnect(); this.waveformResize.disconnect(); @@ -2203,10 +2200,7 @@ class Mixer { paint() { if (this.scrubbing) return; // don't fight the user's drag const master = this.audios[0]; - const dur = Number.isFinite(master.duration) ? master.duration : 0; - const pct = dur ? (master.currentTime / dur) * 1000 : 0; - this.seek.value = pct; - this.seek.style.setProperty('--fill', `${pct / 10}%`); + this.paintSeekControls(master.currentTime); this.tcNow.textContent = fmt(master.currentTime); for (const channel of this.channelsByName.values()) this.paintWaveform(channel); } @@ -2216,7 +2210,7 @@ class Mixer { } editLabel(stemName, nameEl) { - if (this.job.readOnlyShared || this.labelSaving || !nameEl.isConnected) return; + if (this.job.readOnlyShared || this.job.permissions?.owner===false || this.labelSaving || !nameEl.isConnected) return; const input = document.createElement('input'); input.className = 'ch-name-input'; input.maxLength = 40; @@ -2262,6 +2256,78 @@ class Mixer { input.addEventListener('blur', () => done(true)); } + seekDuration() { + // A displayed/decoded waveform is not proof that each media clock is ready. + // Never write NaN/Infinity or start a partial multi-stem seek during loading. + if (this.disposed || !this.audios.every(audio => Number.isFinite(audio.duration) && audio.duration > 0 && !audio.error)) return 0; + return this.audios[0].duration; + } + + paintSeekControls(time) { + const duration = this.seekDuration(); + const bounded = duration && Number.isFinite(time) ? Math.max(0, Math.min(time, duration)) : 0; + const value = duration ? bounded / duration * 1000 : 0; + for (const control of this.seekControls || []) { + control.disabled = !duration; + control.value = value; + control.setAttribute('aria-valuetext', duration ? `${fmt(bounded)} of ${fmt(duration)}` : 'Audio is loading'); + } + this.seek.style.setProperty('--fill', `${value / 10}%`); + } + + bindSeekControl(control) { + const active = () => this.scrubControl === control; + control.addEventListener('pointerdown', event => { + if (control.disabled || event.button !== 0 || (event.pointerType && !event.isPrimary)) return; + this.endScrub(false); + this.scrubbing = true; + this.scrubControl = control; + this.scrubPointer = event.pointerId; + // The native range retains pointer capture for mouse, pen and touch. + // These shared listeners recover outside releases and interrupted gestures. + this.scrubEvents = new AbortController(); + const options = { capture: true, signal: this.scrubEvents.signal }; + window.addEventListener('pointerup', event => { + if (event.pointerId === this.scrubPointer) this.queueScrubEnd(); + }, options); + window.addEventListener('pointercancel', event => { + if (event.pointerId === this.scrubPointer) this.endScrub(false); + }, options); + window.addEventListener('pointerdown', event => { + if (event.target !== control) this.endScrub(true); + }, options); + window.addEventListener('blur', event => { + if (event.target === window) this.endScrub(false); + }, options); + }); + control.addEventListener('pointerup', () => { if (active()) this.queueScrubEnd(); }); + control.addEventListener('pointercancel', () => { if (active()) this.endScrub(false); }); + control.addEventListener('lostpointercapture', () => { if (active()) this.queueScrubEnd(); }); + control.addEventListener('blur', () => { if (active()) this.endScrub(true); }); + control.addEventListener('keydown', event => { + if (event.key === 'Escape' && active()) { event.preventDefault(); this.endScrub(false); } + }); + const preview = () => { + if (control.disabled || (this.scrubControl && !active())) return false; + const duration = this.seekDuration(); + if (!duration) { this.endScrub(false); return false; } + this.scrubControl = control; + this.scrubbing = true; + this.scrubTarget = Number(control.value) / 1000 * duration; + this.paintSeekControls(this.scrubTarget); + this.tcNow.textContent = fmt(this.scrubTarget); + for (const channel of this.channelsByName.values()) this.paintWaveform(channel); + return true; + }; + control.addEventListener('input', () => { + if (!preview()) return; + // Keyboard/assistive input has no pointer lifecycle. Native change gets + // its turn before the fallback commit, just as on the main slider. + if (this.scrubPointer == null) this.queueScrubEnd(); + }); + control.addEventListener('change', () => { if (preview()) this.endScrub(true); }); + } + queueScrubEnd() { clearTimeout(this.scrubTimer); // Wait until native range release/change processing has completed before @@ -2275,6 +2341,7 @@ class Mixer { this.scrubEvents?.abort(); this.scrubEvents = null; this.scrubPointer = null; + this.scrubControl = null; this.scrubTarget = null; this.scrubbing = false; if (commit && Number.isFinite(target)) this.seekTo(target); @@ -2284,15 +2351,16 @@ class Mixer { seekTo(t) { if (!Number.isFinite(t)) return; this.endScrub(false); + const duration = this.seekDuration(); + if (!duration) return; this.clockProgress = null; - const duration = this.audios[0].duration; - t = Math.max(0, Number.isFinite(duration) ? Math.min(t, duration) : t); + t = Math.max(0, Math.min(t, duration)); for (const a of this.audios) { // Seeks can land while paused (Listening Guide tool calls do this a lot) — start // buffering the target region now so play() finds data ready instead of // six stems stalling at once. if (a.preload !== 'auto') a.preload = 'auto'; - a.currentTime = t; + a.currentTime = Math.min(t, a.duration); } this.paint(); } @@ -2306,7 +2374,7 @@ class Mixer { m.className = 'marker'; m.style.left = `${Math.min(100, (note.atSeconds / dur) * 100)}%`; m.setAttribute('aria-label', `Note at ${fmt(note.atSeconds)}: ${note.text}`); - m.title = `${fmt(note.atSeconds)} — ${note.text}`; + m.title = `${fmt(note.atSeconds)} — ${note.text} (${note.authorName || 'Author unavailable'})`; m.addEventListener('click', () => { this.seekTo(note.atSeconds); this.flashNote(note.id); @@ -2390,6 +2458,7 @@ class Mixer { // Always-visible list of every annotation under the channels — read them // all without touching playback; the timecode is the deliberate "jump" act. renderNotes() { + const edits=new Map([...this.notes.querySelectorAll('.note-edit-form')].map(form=>[form.parentElement.dataset.id,{form,focused:form.contains(document.activeElement)}])); this.notes.innerHTML = ''; this.notes.hidden = this.annotations.length === 0; for (const note of this.annotations) { @@ -2398,18 +2467,35 @@ class Mixer { row.dataset.id = note.id; row.innerHTML = ` - ${esc(note.text)} + ${esc(note.text)}${esc(note.authorName || 'Author unavailable')} + `; row.querySelector('.note-time').addEventListener('click', () => this.seekTo(note.atSeconds)); row.querySelector('.note-loop').addEventListener('click', () => this.loopFromNote(note)); + row.querySelector('.note-edit').hidden=runtime.authMode!=='cail'||note.canDelete===false; + row.querySelector('.note-edit').addEventListener('click',()=>this.editNote(note,row)); + row.querySelector('.note-del').hidden=note.canDelete===false; row.querySelector('.note-del').addEventListener('click', () => this.deleteNote(note)); this.notes.appendChild(row); + const edit=edits.get(note.id); + if(edit&¬e.canDelete!==false){row.append(edit.form);if(edit.focused)edit.form.querySelector('input').focus({preventScroll:true});} } this.renderLoop(); } + editNote(note,row) { + if(note.canDelete===false||row.querySelector('form'))return; + const form=document.createElement('form');form.className='note-edit-form'; + const input=document.createElement('input');input.maxLength=200;input.value=note.text;input.setAttribute('aria-label','Edit note text'); + const save=document.createElement('button');save.type='submit';save.textContent='Save'; + const cancel=document.createElement('button');cancel.type='button';cancel.textContent='Cancel';cancel.addEventListener('click',()=>form.remove()); + form.append(input,save,cancel);row.append(form);input.focus(); + input.addEventListener('keydown',event=>{if(event.key==='Escape')form.remove();}); + form.addEventListener('submit',async event=>{event.preventDefault();save.disabled=true;try{const result=await api(`/api/jobs/${this.job.id}/annotations/${note.id}`,{method:'PUT',body:JSON.stringify({text:input.value})});const current=this.annotations.find(item=>item.id===note.id);if(current)current.text=result.text;form.remove();this.renderNotes();}catch(error){showUploadMessage(error.message,true);save.disabled=false;}}); + } + async deleteNote(note) { this.annotations = this.annotations.filter((n) => n.id !== note.id); this.renderMarkers(); @@ -2435,7 +2521,7 @@ class Mixer { } addNote() { - if (this.job.readOnlyShared) return; + if (this.job.readOnlyShared || this.job.permissions?.comment===false) return; if (this.el.querySelector('.note-form')) return; const t = this.audios[0].currentTime; const form = document.createElement('form'); @@ -2483,19 +2569,33 @@ class Mixer { // Labels, notes, and the guide are class-wide; this pulls in whatever other // students have added since the page loaded. Read-only, so no class code. async refresh() { - if (this.job.readOnlyShared) return; + if(this.refreshBusy)return; + const sequence=(this.refreshSequence||0)+1;this.refreshSequence=sequence;this.refreshBusy=true; + this.refreshBtn.disabled=true;this.refreshStatus.textContent='Refreshing…'; + const focus=document.activeElement; try { - const res = await fetch(`/api/jobs/${this.job.id}`); - if (!res.ok) return; - const state = await res.json(); - jobStates.set(this.job.id, state); - this.merge(state); - } catch { - // Offline or a blip — the panel keeps whatever it already had. + const path=this.job.readOnlyShared?`/api/shared-jobs/${this.job.id}`:`/api/jobs/${this.job.id}`; + const state=await api(path,{signal:AbortSignal.timeout(15000),cache:'no-store'}); + if(sequence!==this.refreshSequence||!this.el.isConnected)return; + jobStates.set(this.job.id,state);this.merge(state); + this.refreshStatus.textContent=`Updated ${new Date().toLocaleTimeString([],{hour:'numeric',minute:'2-digit'})}`; + } catch(error) { + if(sequence!==this.refreshSequence)return; + this.refreshStatus.textContent=[401,403,404].includes(error.status)?'Access ended. Sign in or ask your instructor.':'Could not refresh. Your draft and mixer settings are safe.'; + if([401,403,404].includes(error.status)) { + this.pause();this.annotations=[];this.renderNotes();this.renderMarkers(); + this.job.permissions={owner:false,comment:false}; + for(const node of this.el.querySelectorAll('.note-btn,.coach,.export-btn,.folder-btn'))node.hidden=true; + } + } finally { + if(sequence===this.refreshSequence){this.refreshBusy=false;this.refreshBtn.disabled=false;} + if(focus?.isConnected&&focus!==this.refreshBtn&&document.activeElement===document.body)focus.focus({preventScroll:true}); } } merge(state) { + if(state.permissions){this.job.permissions=state.permissions;this.el.querySelector('.note-btn').hidden=!state.permissions.comment;} + if(state.courseId)this.job.courseId=state.courseId; if (state.labels) { this.job.labels = state.labels; this.renderChannelNames(); @@ -2517,6 +2617,7 @@ class Mixer { if (!nameEl) continue; // a rename is open in this strip — leave it alone nameEl.textContent = this.label(name); channel.muteBtn.setAttribute('aria-label', `Mute ${this.label(name)}`); + channel.seek.setAttribute('aria-label', `${this.label(name)} waveform position in ${this.job.filename}`); } } @@ -2620,7 +2721,10 @@ class Mixer { this.conversationLoading = (async () => { try { const result = await api(`/api/jobs/${this.job.id}/listening-conversation`, { signal: AbortSignal.timeout(15000) }); + this.durableConversation=result.mode==='durable'; this.conversationEntries = Array.isArray(result.entries) ? result.entries : []; + this.conversationNextCursor=result.nextCursor; + if(this.durableConversation&&result.nextCursor)this.addConversationMore(); this.conversationRevision = Number(result.revision) || 0; this.conversationLoaded = true; this.chatHistory = this.conversationEntries @@ -2644,9 +2748,23 @@ class Mixer { return this.conversationLoading; } + addConversationMore() { + if(this.conversationMore)return; + const button=document.createElement('button');button.type='button';button.className='account-button';button.textContent='Load more conversation'; + this.coachArchive.after(button);this.conversationMore=button; + button.addEventListener('click',async()=>{ + button.disabled=true; + try{const page=await api(`/api/jobs/${this.job.id}/listening-conversation?cursor=${encodeURIComponent(this.conversationNextCursor)}&revision=${this.conversationRevision}`); + this.conversationEntries.push(...page.entries);this.conversationNextCursor=page.nextCursor;this.renderArchive();button.hidden=!page.nextCursor; + }catch(error){this.conversationSaveStatus.hidden=false;this.conversationSaveStatus.textContent=error.message;} + finally{button.disabled=false;} + }); + } + // Account mode writes the transcript to the signed-in CUNY subject. Legacy // class-code mode retains its browser-only archive behavior. logChatEntry(kind, text) { + if(this.durableConversation)return; if (runtime.authMode === 'cail') { this.conversationEntries = [...this.conversationEntries, { kind, text }].slice(-60); this.queueConversationSave(); @@ -2692,10 +2810,11 @@ class Mixer { this.coachArchive.hidden = false; this.archiveToggle.innerHTML = `▾EARLIER SESSION · ${entries.length}`; for (const entry of entries) { - const kind = entry.kind === 'you' || entry.kind === 'action' ? entry.kind : 'coach'; + const kind = ['you','action','status'].includes(entry.kind) ? entry.kind : 'coach'; const row = document.createElement('div'); row.className = `coach-row ${kind}`; - if (kind === 'action') row.innerHTML = `${esc(String(entry.text))}`; + if (kind === 'status') row.textContent = `Conversation status: ${entry.text}`; + else if (kind === 'action') row.innerHTML = `${esc(String(entry.text))}`; else if (kind === 'you') row.textContent = String(entry.text); else row.innerHTML = coachHtml(String(entry.text)); this.archiveLog.appendChild(row); @@ -2713,7 +2832,7 @@ class Mixer { try { await this.conversationLoading?.catch(() => {}); await this.conversationSaveQueue; - const reset = await api(`/api/jobs/${this.job.id}/listening-conversation`, { method: 'DELETE', signal: AbortSignal.timeout(15000) }); + const reset = await api(`/api/jobs/${this.job.id}/listening-conversation`, { method: 'DELETE', signal: AbortSignal.timeout(15000), ...(this.durableConversation?{body:JSON.stringify({revision:this.conversationRevision})}:{}) }); this.conversationEntries = []; this.conversationRevision = reset.revision; this.conversationLoaded = true; @@ -2736,6 +2855,7 @@ class Mixer { // Storage blocked — the in-memory reset above still holds for this page. } this.coachArchive.hidden = true; + if(this.conversationMore){this.conversationMore.remove();this.conversationMore=null;} this.archiveLog.hidden = true; this.archiveLog.innerHTML = ''; this.archiveToggle.setAttribute('aria-expanded', 'false'); @@ -2781,7 +2901,7 @@ class Mixer { try { await streamApi( `/api/jobs/${this.job.id}/chat`, - { messages: this.chatHistory, durationSec: this.duration() }, + { messages: this.chatHistory, durationSec: this.duration(), ...(this.durableConversation?{messageId:crypto.randomUUID(),revision:this.conversationRevision}:{}) }, (ev) => { if (ev.type === 'delta') { if (!row) { @@ -2796,6 +2916,8 @@ class Mixer { } else if (ev.type === 'done') { finalText = ev.text || acc; finishReason = ev.finishReason || 'stop'; + if(Number.isSafeInteger(ev.revision))this.conversationRevision=ev.revision; + if(ev.notesChanged)void this.refresh(); } }, this.coachAbort.signal ); @@ -2820,6 +2942,7 @@ class Mixer { typing.remove(); if (row && !acc) row.remove(); if (row) row.classList.remove('streaming'); + if(this.durableConversation)this.conversationLoaded=false; this.addChatRow('error', esc(err.name === 'AbortError' ? 'Request cancelled. The mixer still works.' : err.message)); if (runtime.authMode === 'cail') await this.conversationSaveQueue; } @@ -3146,6 +3269,7 @@ async function loadWholeFolder(folder, button) { // The "+ FOLDER" popover on a finished console: pick an existing folder or // name a new one; either way the split is saved server-side for the teacher. function toggleFolderMenu(mixer) { + if(runtime.authMode==='cail'&&mixer.job.courseId){location.assign(`/classroom.html?course=${encodeURIComponent(mixer.job.courseId)}&job=${encodeURIComponent(mixer.job.id)}`);return;} const existing = mixer.el.querySelector('.folder-menu'); if (existing) { existing.remove(); @@ -3261,6 +3385,7 @@ async function loadAccountJobs(more = false) { emptyState.hidden = true; try { const response = await fetch(`/api/jobs${more && rackCursor ? `?cursor=${encodeURIComponent(rackCursor)}` : ''}`); + window.StemSessionGuard?.observe(response); if (response.status === 401 || response.status === 403) { location.assign('/account.html'); return; } if (!response.ok) throw new Error('load'); const result = await response.json(); @@ -3522,8 +3647,8 @@ function pollSoon() { const STATIONS = { splitter: { - word: 'SPLITTER', - tagline: 'Split a song apart. Listen one layer at a time. Annotate as you go.', + word: 'Splitter', + tagline: 'Split apart a song, listen one layer at a time, annotate as you go.', }, remixer: { word: 'REMIXER', @@ -4260,7 +4385,7 @@ async function addTake(chunks, manifest) { const ext = type.includes('mp4') ? 'm4a' : 'webm'; const name = `${fileSafe(manifest.title) || 'remix'}-take-${String(takeNumber).padStart(2, '0')}.${ext}`; const url = URL.createObjectURL(blob); - const credits = `STEM Splitter remix\n\nLicense: ${manifest.licenseUrl}\n${manifest.nonCommercial ? 'Noncommercial use only.\n' : ''}\n${manifest.sources.map((s) => `${s.title}\n${s.creator}\n${s.sourceUrl}\n${s.licenseUrl}\nFile: ${s.fileName}\n`).join('\n')}\n${manifest.changes}\n`; + const credits = `Stem Splitter remix\n\nLicense: ${manifest.licenseUrl}\n${manifest.nonCommercial ? 'Noncommercial use only.\n' : ''}\n${manifest.sources.map((s) => `${s.title}\n${s.creator}\n${s.sourceUrl}\n${s.licenseUrl}\nFile: ${s.fileName}\n`).join('\n')}\n${manifest.changes}\n`; const bundle = makeZip([ { name, data: new Uint8Array(await blob.arrayBuffer()) }, { name: 'ATTRIBUTION.txt', data: new TextEncoder().encode(credits) }, @@ -4419,6 +4544,18 @@ function fmt(sec) { // --- init ------------------------------------------------------------- window.addEventListener('pageshow', event => { if (event.persisted && runtime.authMode === 'cail') location.reload(); }); +async function loadCourseOptions() { + const wrapper=document.getElementById('course-context'),select=document.getElementById('course-select'); + try { + let cursor=null; + do{const page=await api('/api/classroom/courses'+(cursor?`?cursor=${encodeURIComponent(cursor)}`:'')); + for(const course of page.courses)select.add(new Option(`${course.className} · ${course.term} · ${course.section}`,course.classId));cursor=page.nextCursor; + }while(cursor); + wrapper.hidden=false; + select.addEventListener('change',()=>{selectedCourse=select.value;document.getElementById('course-disclosure').hidden=selectedCourse==='personal';document.getElementById('course-consent').checked=false;}); + }catch{wrapper.hidden=true;} +} + async function initialize() { try { const response = await fetch('/api/runtime'); @@ -4451,11 +4588,13 @@ async function initialize() { if (principal && /^cail-[0-9a-f]{32}$/.test(principal.subject)) { jobsStorageKey = `jobs:${principal.subject}`; accountSubject = principal.subject; + window.StemSessionGuard?.start(accountSubject,()=>{for(const mixer of mixers.values()){mixer.pause();mixer.disposeWaveforms();}mixers.clear();jobStates.clear();accountJobs=[];accountSubject=null;selectedCourse='personal';},()=>{for(const mixer of mixers.values()){mixer.pause();mixer.coachAbort?.abort();}}); const link = document.createElement('a'); link.className = 'account-button'; link.href = '/account.html'; link.textContent = 'My account'; account.append(link); + void loadCourseOptions(); } else if (runtime.loginUrl) { const link = document.createElement('a'); link.className = 'account-button'; @@ -4476,11 +4615,4 @@ async function initialize() { else if (runtime.authMode !== 'cail') void pollActiveJobs().then(adoptSharedJob); else void adoptSharedJob(); } -window.addEventListener('focus', async () => { - if (!accountSubject) return; - try { - const response = await fetch('/api/account'); - if (response.status === 401 || response.status === 403 || (response.ok && (await response.json()).account?.subject !== accountSubject)) location.reload(); - } catch { /* An outage is not a new identity. */ } -}); void initialize(); diff --git a/public/classroom.html b/public/classroom.html new file mode 100644 index 0000000..36a8cc4 --- /dev/null +++ b/public/classroom.html @@ -0,0 +1,19 @@ + + +Stem Splitter · Courses +
+ +

Course work

+

Loading course access…

+ + +
+ diff --git a/public/classroom.js b/public/classroom.js new file mode 100644 index 0000000..4baac6c --- /dev/null +++ b/public/classroom.js @@ -0,0 +1,178 @@ +const el = id => document.getElementById(id); +let courses = [], course = null, ready = false, generation = 0; +let rosterCursor = null, jobsCursor = null, student = null, conversation = null, folder = null; +let jobsRequest = 0, conversationRequest = 0, folderRequest = 0; +const status = message => { el('classroom-status').textContent = message; }; +const context = () => ({ courseId: course?.classId, generation }); +const current = scope => scope && scope.generation === generation && scope.courseId === course?.classId && scope.courseId === el('classroom-course').value; +async function request(path, options = {}) { + const response = await fetch(path, { ...options, cache: 'no-store', headers: { 'Content-Type': 'application/json', ...options.headers } }); + window.StemSessionGuard?.observe(response); + const result = await response.json(); + if (!response.ok) throw Object.assign(new Error(result.error?.message || result.error || 'Please try again.'), { status: response.status }); + return result; +} +const courseRequest = (scope, suffix, options = {}) => request(`/api/classroom/courses/${encodeURIComponent(scope.courseId)}${suffix}`, { ...options, headers: { 'x-stem-course': scope.courseId, ...options.headers } }); +function item(list, text) { const row = document.createElement('li'), span = document.createElement('span'); span.textContent = text; row.append(span); list.append(row); return row; } +function button(row, label, action, scope = context()) { + const control = document.createElement('button'); control.className = 'account-button'; control.textContent = label; control.type = 'button'; + control.addEventListener('click', () => run(control, action, scope)); row.append(control); return control; +} +function link(row, label, href) { const a = document.createElement('a'); a.textContent = label; a.href = href; a.className = 'account-button'; row.append(a); } +async function run(control, action, scope = context()) { + if (!control || control.disabled || !ready || !current(scope)) return; + control.disabled = true; + try { await action(scope); } + catch (error) { + if (!current(scope)) return; + status(error.message); + if ([401, 403].includes(error.status)) { generation++; clearCourseView(); ready = false; el('classroom-refresh').disabled = false; } + } finally { if (current(scope)) control.disabled = !ready || (control.id === 'folder-add-button' && !el('folder-job').value); } +} +function chooseSplit(job, scope = context()) { + if (!ready || !current(scope)) return; + el('folder-job').value = job.id; el('folder-selected').textContent = job.filename; el('folder-add-button').disabled = false; +} +function clearCourseView() { + jobsRequest++; conversationRequest++; folderRequest++; + student = null; conversation = null; folder = null; rosterCursor = null; jobsCursor = null; + for (const id of ['roster-list', 'student-jobs', 'conversation-messages', 'course-folders', 'folder-items']) el(id).replaceChildren(); + for (const id of ['classroom-roster', 'classroom-jobs', 'classroom-conversation', 'folder-detail', 'folder-create', 'classroom-prompt', 'roster-more', 'jobs-more', 'conversation-more']) el(id).hidden = true; + el('course-folders').hidden = true; el('classroom-prompt').removeAttribute('href'); + el('classroom-student').textContent = ''; el('folder-title').textContent = ''; el('folder-status').textContent = ''; + el('folder-name').value = ''; el('folder-job').value = ''; el('folder-selected').textContent = 'Choose a split from course work above.'; el('folder-add-button').disabled = true; +} +async function roster(more = false, scope = context()) { + if (!current(scope)) return; + const page = await courseRequest(scope, '/roster' + (more && rosterCursor ? `?cursor=${encodeURIComponent(rosterCursor)}` : '')); + if (!current(scope)) return; + if (!more) el('roster-list').replaceChildren(); + for (const member of page.participants) { + const row = item(el('roster-list'), `${member.displayName || 'Student'} · ${member.splitCount} split${member.splitCount === 1 ? '' : 's'} · ${member.retainedCount} retained`); + button(row, 'View course work', scope => loadJobs(member, false, scope), scope); + } + if (!page.participants.length && !more) item(el('roster-list'), 'No currently enrolled students.'); + rosterCursor = page.nextCursor; el('roster-more').hidden = !rosterCursor; +} +async function loadJobs(member, more = false, scope = context()) { + if (!current(scope) || !member) return; + const sequence = ++jobsRequest; + if (!more) { el('classroom-jobs').hidden = true; el('classroom-conversation').hidden = true; conversationRequest++; conversation = null; } + const page = await courseRequest(scope, `/participants/${encodeURIComponent(member.memberId)}/jobs` + (more && jobsCursor ? `?cursor=${encodeURIComponent(jobsCursor)}` : '')); + if (!current(scope) || sequence !== jobsRequest) return; + student = member; el('classroom-student').textContent = `${member.displayName || 'Student'} · course work`; el('classroom-jobs').hidden = false; + if (!more) el('student-jobs').replaceChildren(); + for (const job of page.jobs) { + const row = item(el('student-jobs'), `${job.filename} · ${job.retained ? job.status : 'Expired'} · ${job.createdAt}`); + if (job.retained) { link(row, 'Open split', `/?job=${encodeURIComponent(job.id)}`); button(row, 'Read conversation', scope => loadConversation(job, false, scope), scope); } + if (job.retained && job.status === 'done') button(row, 'Use in folder', scope => { chooseSplit(job, scope); el('course-folders').scrollIntoView({ block: 'nearest' }); }, scope); + } + if (!page.jobs.length && !more) item(el('student-jobs'), 'No course splits yet.'); + jobsCursor = page.nextCursor; el('jobs-more').hidden = !jobsCursor; +} +async function loadConversation(job, more = false, scope = context()) { + if (!current(scope) || !job) return; + const sequence = ++conversationRequest, previous = conversation; + if (!more) el('classroom-conversation').hidden = true; + const page = await courseRequest(scope, `/jobs/${encodeURIComponent(job.id)}/conversation` + (more && previous?.cursor ? `?cursor=${encodeURIComponent(previous.cursor)}&revision=${previous.revision}` : '')); + if (!current(scope) || sequence !== conversationRequest) return; + if (!more) el('conversation-messages').replaceChildren(); + for (const message of page.entries) item(el('conversation-messages'), `${message.kind === 'you' ? 'Student' : message.kind === 'coach' ? 'Listening Guy' : message.kind === 'status' ? 'System' : 'Mixer action'} · ${message.text} (${message.provenance})`); + if (!page.entries.length && !more) item(el('conversation-messages'), 'No retained conversation. Only new server-saved course conversations appear here.'); + conversation = { job, cursor: page.nextCursor, revision: page.revision }; el('classroom-conversation').hidden = false; el('conversation-more').hidden = !page.nextCursor; +} +async function folders(scope = context()) { + if (!current(scope)) return; + const page = await courseRequest(scope, '/folders'); if (!current(scope)) return; + el('course-folders').replaceChildren(); + for (const entry of page.folders) { + const row = item(el('course-folders'), `${entry.name} · ${entry.permission === 'private' ? 'Private' : entry.permission === 'comment' ? 'Course comments' : 'Course listening'}`); + button(row, 'Open folder', scope => openFolder(entry.id, scope), scope); + } + if (!page.folders.length) item(el('course-folders'), 'No course folders yet.'); +} +async function openFolder(id, scope = context()) { + if (!current(scope)) return; + const sequence = ++folderRequest; el('folder-detail').hidden = true; folder = null; + const page = await courseRequest(scope, `/folders/${encodeURIComponent(id)}`); + if (!current(scope) || sequence !== folderRequest) return; + folder = page.folder; el('folder-detail').hidden = false; el('folder-title').textContent = folder.name; el('folder-permission').value = folder.permission; + el('folder-controls').hidden = !course.owner; el('folder-add').hidden = !course.owner; el('folder-items').replaceChildren(); + for (const job of page.items) { + const row = item(el('folder-items'), `${job.filename}${job.available ? '' : ' · Expired or unavailable'}`); + if (job.available) link(row, 'Open split', `/?job=${encodeURIComponent(job.jobId)}`); + if (course.owner) button(row, 'Remove from folder', async scope => { + await courseRequest(scope, `/folders/${encodeURIComponent(id)}/items/${encodeURIComponent(job.jobId)}`, { method: 'DELETE' }); + if (current(scope) && folder?.id === id) await openFolder(id, scope); + }, scope); + } + if (!page.items.length) item(el('folder-items'), 'This folder is empty.'); +} +async function selectCourse() { + generation++; ready = false; course = courses.find(entry => entry.classId === el('classroom-course').value); clearCourseView(); + if (!course) return null; + const scope = context(); el('classroom-refresh').disabled = true; status('Loading…'); + try { + await Promise.all([folders(scope), course.owner ? roster(false, scope) : Promise.resolve()]); + if (!current(scope)) return null; + ready = true; el('classroom-roster').hidden = !course.owner; el('folder-create').hidden = !course.owner; el('classroom-prompt').hidden = !course.owner; + el('course-folders').hidden = false; el('classroom-prompt').href = `/teacher.html?course=${encodeURIComponent(scope.courseId)}`; + status('Course access checked.'); return scope; + } catch (error) { + if (current(scope)) { generation++; clearCourseView(); status(error.message); el('classroom-refresh').disabled = false; } + return null; + } finally { if (current(scope)) el('classroom-refresh').disabled = false; } +} +el('classroom-course').addEventListener('change', () => void selectCourse()); +el('classroom-refresh').addEventListener('click', () => void selectCourse()); +el('roster-more').addEventListener('click', () => run(el('roster-more'), scope => roster(true, scope))); +el('jobs-more').addEventListener('click', () => run(el('jobs-more'), scope => loadJobs(student, true, scope))); +el('conversation-more').addEventListener('click', () => run(el('conversation-more'), scope => loadConversation(conversation?.job, true, scope))); +el('folder-create').addEventListener('submit', event => { + event.preventDefault(); run(event.submitter, async scope => { + const result = await courseRequest(scope, '/folders', { method: 'POST', body: JSON.stringify({ name: el('folder-name').value }) }); + if (!current(scope)) return; + el('folder-name').value = ''; await folders(scope); await openFolder(result.folder.id, scope); + }); +}); +el('folder-add').addEventListener('submit', event => { + event.preventDefault(); const target = folder; if (!target) return; + run(event.submitter, async scope => { + await courseRequest(scope, `/folders/${encodeURIComponent(target.id)}/items`, { method: 'POST', body: JSON.stringify({ jobId: el('folder-job').value.trim() }) }); + if (!current(scope) || folder?.id !== target.id) return; + el('folder-job').value = ''; el('folder-selected').textContent = 'Choose a split from course work above.'; el('folder-add-button').disabled = true; await openFolder(target.id, scope); + }); +}); +el('folder-grant').addEventListener('click', () => { + const target = folder; if (!target) return; + run(el('folder-grant'), async scope => { + await courseRequest(scope, `/folders/${encodeURIComponent(target.id)}`, { method: 'PUT', body: JSON.stringify({ permission: el('folder-permission').value, revision: target.revision }) }); + if (!current(scope) || folder?.id !== target.id) return; + el('folder-status').textContent = 'Sharing updated. Only current course members can open a course link.'; await openFolder(target.id, scope); await folders(scope); + }); +}); +el('folder-link').addEventListener('click', () => { + const target = folder; if (!target) return; + run(el('folder-link'), async scope => { + await navigator.clipboard.writeText(`${location.origin}/classroom.html?course=${encodeURIComponent(scope.courseId)}&folder=${encodeURIComponent(target.id)}`); + if (current(scope) && folder?.id === target.id) el('folder-status').textContent = 'Course link copied. Sign-in and current enrollment are required.'; + }); +}); +(async () => { + try { + const { account } = await request('/api/account'); + window.StemSessionGuard?.start(account.subject, () => { generation++; ready = false; courses = []; course = null; clearCourseView(); }); + let cursor = null; + do { const page = await request('/api/classroom/courses' + (cursor ? `?cursor=${encodeURIComponent(cursor)}` : '')); courses.push(...page.courses); cursor = page.nextCursor; } while (cursor); + if (!courses.length) { status('No current courses are available for Stem Splitter.'); return; } + for (const entry of courses) el('classroom-course').add(new Option(`${entry.className} · ${entry.term} · ${entry.section}`, entry.classId)); + const query = new URLSearchParams(location.search); + if (courses.some(entry => entry.classId === query.get('course'))) el('classroom-course').value = query.get('course'); + el('classroom-content').hidden = false; const scope = await selectCourse(); if (!scope) return; + if (query.get('folder')) await openFolder(query.get('folder'), scope); + if (query.get('job')) { const job = await request(`/api/jobs/${encodeURIComponent(query.get('job'))}`); if (job.courseId === scope.courseId) chooseSplit(job, scope); } + } catch (error) { + status(error.message); + if (error.status === 401) link(el('classroom-status'), 'CUNY Login', '/auth/login?next=' + encodeURIComponent(location.pathname + location.search)); + } +})(); diff --git a/public/index.html b/public/index.html index 21cb9c4..4babc30 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,7 @@ - + @@ -19,16 +19,17 @@ -
+

// a closer listen

-

STEM
SPLITTER

-

Split a song apart. Listen one layer at a time. Annotate as you go.

+

Stem Splitter

+

Split apart a song, listen one layer at a time, annotate as you go.

+
@@ -40,12 +41,12 @@

STEM
SPLITTER

- Or paste a YouTube link + Paste a link to fetch audio
-
+

Split into

@@ -167,7 +168,8 @@

REMIX DECK02 · INSTRUCTOR - + +
@@ -185,6 +187,7 @@

ENTER CLASS CODE

- + + diff --git a/public/session-guard.js b/public/session-guard.js new file mode 100644 index 0000000..d7f1684 --- /dev/null +++ b/public/session-guard.js @@ -0,0 +1,80 @@ +/* Private views belong to one currently verified account. A recheck conceals + * the view immediately; only a same-account success can restore its drafts. */ +(() => { + let subject = null, clearing = false, checking = null, suspended = false; + let cleanup = () => {}, pause = () => {}, previousHidden = false, previousInert = false; + let panel, message, retry; + function suspend() { + if (suspended) return; + suspended = true; + const main = document.querySelector('main'); + if (main) { + previousHidden = main.hidden; previousInert = main.inert; + main.inert = true; main.hidden = true; + } + try { pause(); } catch { /* The concealed view must still offer recovery. */ } + for (const audio of document.querySelectorAll('audio')) audio.pause(); + if (!panel) { + panel = document.createElement('section'); panel.id = 'session-check'; panel.className = 'session-check'; + message = document.createElement('p'); message.setAttribute('role', 'status'); + retry = document.createElement('button'); retry.type = 'button'; retry.className = 'account-button'; retry.textContent = 'Retry'; + retry.addEventListener('click', () => void check()); + panel.append(message, retry); document.body.append(panel); + } + panel.hidden = false; + } + function restore() { + if (!suspended || clearing) return; + suspended = false; + const main = document.querySelector('main'); + if (main) { main.inert = previousInert; main.hidden = previousHidden; } + if (panel) panel.hidden = true; + // Playback stays paused. Returning to a page is not a request to play audio. + } + function clear() { + if (clearing) return; + clearing = true; suspend(); cleanup(); + const main = document.querySelector('main'); + if (main) main.replaceChildren(); + message.textContent = 'Your account changed. Reloading your workspace…'; retry.hidden = true; + location.reload(); + } + function observe(response) { + const actor = response.headers.get('X-Stem-Account'); + if (subject && actor && actor !== subject) { + clear(); throw Object.assign(new Error('Your account changed. Reload the page.'), { status: 401 }); + } + return response; + } + async function check() { + if (!subject || clearing) return; + if (checking) return checking; + suspend(); message.textContent = 'Checking your account…'; retry.hidden = true; + checking = (async () => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const response = observe(await fetch('/api/account', { cache: 'no-store', credentials: 'same-origin', signal: controller.signal })); + if ([401, 403].includes(response.status)) { clear(); return; } + if (!response.ok) throw new Error('Account unavailable'); + const account = (await response.json()).account; + if (!account || typeof account.subject !== 'string') throw new Error('Invalid account'); + if (account.subject !== subject) { clear(); return; } + restore(); + } catch { + if (!clearing) { + message.textContent = 'Could not verify your account. Your workspace is hidden until access is checked.'; + retry.hidden = false; + } + } finally { clearTimeout(timer); checking = null; } + })(); + return checking; + } + window.StemSessionGuard = { + start(value, onClear, onPause) { subject = value; cleanup = onClear || cleanup; pause = onPause || pause; }, + observe, check, clear, + }; + window.addEventListener('focus', () => void check()); + window.addEventListener('pageshow', event => { if (event.persisted) void check(); }); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void check(); }); +})(); diff --git a/public/styles.css b/public/styles.css index 1b1409f..3e15c8e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1584,6 +1584,16 @@ footer a:hover { color: var(--hot); } opacity: .55; } .waveform { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } +.waveform-seek { + position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; + appearance: none; -webkit-appearance: none; opacity: 0; cursor: ew-resize; + touch-action: pan-y; user-select: none; +} +.waveform-seek::-webkit-slider-thumb { -webkit-appearance: none; width: 1px; height: 100%; } +.waveform-seek::-moz-range-thumb { width: 1px; height: 100%; border: 0; } +.waveform-seek:disabled { cursor: wait; } +.meter:focus-within { outline: 2px solid var(--hot); outline-offset: 2px; } +@media (forced-colors: active) { .meter:focus-within { outline-color: Highlight; } } .meter.waveform-ready::before { display: none; } .ch-actions { grid-column: 2; grid-row: 1; display: flex; justify-content: flex-end; align-items: center; gap: .35rem; } .channel .solo-btn, @@ -2745,3 +2755,68 @@ body.instructor .folder-btn { display: inline-block; } /* Feature visibility is authoritative even where component rules set display. */ [hidden] { display: none !important; } body:not(.remixer-enabled) .to-remix-btn { display: none; } + +/* Shared classroom controls. Keep the PR27 note form's positive flow spacing. */ +:root { --control-height: 44px; --control-radius: 7px; --control-pad: .55rem .85rem; } +main { padding-top: 1.4rem; } +.masthead { margin-bottom: 1.35rem; } +.masthead h1 { font-size: clamp(2rem, 6vw, 3.2rem); line-height: 1.05; text-transform: none; } +.compact-masthead { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: .65rem 1.1rem; } +.compact-masthead h1 { grid-column: 1; grid-row: 1; } +.compact-masthead .lab-brand { grid-column: 2; grid-row: 1; margin: 0; justify-self: end; } +.compact-masthead .lab-logo { height: auto; width: 150px; max-width: 100%; } +.compact-masthead .tagline { grid-column: 1; grid-row: 2; margin: 0; max-width: none; font-size: .96rem; } +.compact-masthead .account-nav { grid-column: 2; grid-row: 2; margin: 0; justify-self: end; } +.compact-masthead #split-summary { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } +.compact-masthead h1 span { color: var(--ink); -webkit-text-stroke: 0; } +.account-button, .yt-go, .crate-page-btn, .teacher-submit, .course-context button, .top-return a, .teacher-actions button, .bench-tab { + min-height: var(--control-height); box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; + padding: var(--control-pad); border: 1px solid var(--panel-edge); border-radius: var(--control-radius); + font-family: var(--font-body); font-size: .9rem; font-weight: 600; line-height: 1.3; text-transform: none; letter-spacing: 0; +} +button:focus-visible, a:focus-visible, select:focus-visible { outline: 2px solid var(--hot); outline-offset: 3px; } +button:disabled { opacity: .55; cursor: wait; } +.top-return { margin-bottom: 1rem; } +.bottom-help { margin-top: 1.7rem; text-align: center; } +.bottom-help hr { border: 0; border-top: 1px solid var(--panel-edge); margin: .65rem 0; } +.bottom-help a { display: inline-flex; min-height: 44px; align-items: center; color: var(--ink); } +.course-context { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin: .85rem 0; } +.course-context label { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; min-width: 0; } +.course-context select { appearance: none; -webkit-appearance: none; height: 44px; padding-right: 2rem; background-image: linear-gradient(45deg,transparent 50%,var(--ink-dim) 50%),linear-gradient(135deg,var(--ink-dim) 50%,transparent 50%); background-position: calc(100% - 14px) 50%,calc(100% - 9px) 50%; background-size: 5px 5px; background-repeat: no-repeat; overflow: hidden; text-overflow: ellipsis; min-height: 44px; max-width: 100%; min-width: 0; border: 1px solid var(--panel-edge); border-radius: var(--control-radius); padding: .4rem 2rem .4rem .4rem; background-color: var(--bg-raise); color: var(--ink); font: inherit; } +.course-context > label:first-child { flex: 1 1 240px; } +.course-context > label:first-child select { width: 100%; } +.course-context .course-disclosure { width: 100%; flex-wrap: nowrap; align-items: flex-start; font-size: .88rem; color: var(--ink-dim); } +.course-disclosure input { flex: 0 0 22px; min-height: 24px; } +.classroom-list { list-style: none; padding: 0; display: grid; gap: .65rem; } +.classroom-list li { display: flex; flex-wrap: wrap; align-items: center; gap: .55rem; padding: .65rem; border: 1px solid var(--panel-edge); border-radius: 7px; overflow-wrap: anywhere; } +.classroom-list li > span { flex: 1 1 200px; min-width: 0; white-space: pre-wrap; } +.classroom-main section { margin-top: 1rem; } +.classroom-main h2 { margin: .9rem 0 .55rem; font-size: 1.15rem; } +.classroom-main .yt-form input { min-width: 0; } +.note-author { display: block; font-size: .75rem; color: var(--ink-dim); overflow-wrap: anywhere; } +.refresh-status { font-size: .75rem; color: var(--ink-dim); text-transform: none; letter-spacing: 0; } +.refresh-btn { min-height: 28px; text-transform: none; } +.session-check { max-width: 42rem; margin: 3rem auto; padding: 1.5rem; } +.head-btn, .share-btn, .note-time, .note-loop, .note-del { min-height: 28px; } +.console-sub { flex-wrap: wrap; } +@media (max-width: 540px) { + .compact-masthead { gap: .65rem .7rem; } + .compact-masthead .lab-logo { width: 100px; } + .compact-masthead h1 { font-size: clamp(1.7rem, 6.5vw, 2.3rem); } + .compact-masthead .tagline { grid-column: 1 / -1; grid-row: 2; } + .compact-masthead .account-nav { grid-column: 1 / -1; grid-row: 3; justify-self: start; } + .course-context > * { max-width: 100%; } + .classroom-main .yt-form { flex-wrap: wrap; } + .classroom-main .yt-form input { flex-basis: 100%; } +} +@media (prefers-reduced-motion: reduce) { .masthead *, .classroom-main * { animation: none; scroll-behavior: auto; } } +@media (forced-colors: active) { button, .account-button, select { border: 1px solid ButtonText; } .note-author,.refresh-status { color: CanvasText; } } +.note-edit { min-height: 28px; font: inherit; font-size: .75rem; background: transparent; border: 1px solid var(--panel-edge); color: var(--ink); border-radius: 4px; } +.note-edit-form { display: flex; gap: .4rem; flex-basis: 100%; min-width: 0; } +.note-edit-form input { min-width: 0; flex: 1; } +.note-edit-form button { min-height: 32px; } +.note-row { flex-wrap: wrap; } +.refresh-btn { cursor: pointer; background: transparent; font-family: inherit; border: 1px solid var(--panel-edge); border-radius: 5px; padding: .3rem .6rem; font-size: .8rem; letter-spacing: 0; color: var(--ink); } +.classroom-main .yt-form input:not([type="hidden"]) { min-height: 44px; box-sizing: border-box; width: 100%; padding: .55rem .75rem; border: 1px solid var(--panel-edge); border-radius: var(--control-radius); background: var(--bg-raise); color: var(--ink); font: inherit; } +#folder-add { align-items: center; flex-wrap: wrap; } +#folder-selected { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; } diff --git a/public/teacher.html b/public/teacher.html index 17add44..96bb631 100644 --- a/public/teacher.html +++ b/public/teacher.html @@ -2,11 +2,12 @@ Stem Splitter · Instructor - +
+
@@ -30,6 +31,7 @@

SIGN IN

+