From f8a4992a9035040b0db599c0ba451f77f001f1db Mon Sep 17 00:00:00 2001 From: zmuhls Date: Tue, 6 Oct 2026 14:39:17 -0400 Subject: [PATCH 1/7] Add prospective course authority and classroom data boundaries --- cloudflare/classroom.test.ts | 145 +++++++++++++++++++++ cloudflare/course-fixture.ts | 9 ++ cloudflare/fixtures/stem-course-v1.json | 41 ++++++ cloudflare/identity.test.ts | 21 +-- cloudflare/retention.ts | 6 + cloudflare/test-worker.ts | 10 +- cloudflare/worker.test.ts | 6 +- migrations/0021-classroom.sql | 149 +++++++++++++++++++++ schema.sql | 165 +++++++++++++++++++++++- src/assistant/index.ts | 20 +++ src/classroom/access.ts | 67 ++++++++++ src/classroom/contract.ts | 57 ++++++++ src/classroom/conversations.ts | 78 +++++++++++ src/classroom/prompts.ts | 43 ++++++ src/classroom/routes.ts | 114 ++++++++++++++++ src/env.ts | 5 +- src/identity.ts | 64 +++++++-- src/index.ts | 91 +++++++++++-- 18 files changed, 1046 insertions(+), 45 deletions(-) create mode 100644 cloudflare/classroom.test.ts create mode 100644 cloudflare/course-fixture.ts create mode 100644 cloudflare/fixtures/stem-course-v1.json create mode 100644 migrations/0021-classroom.sql create mode 100644 src/classroom/access.ts create mode 100644 src/classroom/contract.ts create mode 100644 src/classroom/conversations.ts create mode 100644 src/classroom/prompts.ts create mode 100644 src/classroom/routes.ts diff --git a/cloudflare/classroom.test.ts b/cloudflare/classroom.test.ts new file mode 100644 index 0000000..9131dd9 --- /dev/null +++ b/cloudflare/classroom.test.ts @@ -0,0 +1,145 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +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, validAssignment, validPage, validFailure } from '../src/classroom/contract.ts'; +import { courseFixture } from './course-fixture.ts'; +import { beginCourseTurn, finishCourseTurn, conversationPage, resetCourseConversation } from '../src/classroom/conversations.ts'; +import { cacheGuideIfPromptCurrent, getGuide } from '../src/assistant/index.ts'; +import { hashSystemPromptFingerprint } from '../src/assistant/prompt.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),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/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','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=execFileSync('git',['show','e1918da3cb6fc37f9715d4cd143af5a5c6c880c6:schema.sql'],{encoding:'utf8'}); + 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(); + db.applySchema(readFileSync(new URL('../migrations/0021-classroom.sql',import.meta.url),'utf8')); + const clean=new SqliteD1(':memory:');clean.applySchema(fresh); + 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));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(validAssignment(fixture.access,now),true);assert.equal(validPage(fixture.roster,now),true);assert.equal(validFailure(fixture.denied),true); + assert.equal(validAssignment({...fixture.access,subject:ids.alice},now),false);assert.equal(validAssignment(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);} +}); 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/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/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/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/test-worker.ts b/cloudflare/test-worker.ts index 4a280e5..541edba 100644 --- a/cloudflare/test-worker.ts +++ b/cloudflare/test-worker.ts @@ -1,3 +1,5 @@ +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'; @@ -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:[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:[],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/migrations/0021-classroom.sql b/migrations/0021-classroom.sql new file mode 100644 index 0000000..c964f29 --- /dev/null +++ b/migrations/0021-classroom.sql @@ -0,0 +1,149 @@ +-- 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, + 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')), + provenance TEXT NOT NULL CHECK(provenance IN ('student','server-assistant','server-tool')), + 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/schema.sql b/schema.sql index f40b58b..0993109 100644 --- a/schema.sql +++ b/schema.sql @@ -312,6 +312,9 @@ CREATE TABLE IF NOT EXISTS annotations ( job_id TEXT NOT NULL, at_seconds REAL NOT NULL, text TEXT NOT NULL, + author_subject TEXT, + author_name TEXT, + provenance TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -473,12 +476,18 @@ CREATE TABLE IF NOT EXISTS app_user_events ( 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; +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; +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; +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) @@ -519,3 +528,153 @@ CREATE TABLE IF NOT EXISTS app_request_reservations ( day TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS app_request_reservations_scope ON app_request_reservations(scope, day, subject); + +-- 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; + + + + + +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, + 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')), + provenance TEXT NOT NULL CHECK(provenance IN ('student','server-assistant','server-tool')), + 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/src/assistant/index.ts b/src/assistant/index.ts index 0407e23..069f937 100644 --- a/src/assistant/index.ts +++ b/src/assistant/index.ts @@ -1,3 +1,5 @@ +import { coursePrompt } from '../classroom/prompts.ts'; +import { jobCourse } from '../classroom/access.ts'; // Listening Guy orchestrators: cached guide generation + chat with validated // mixer tool calls, both streaming prose through an onDelta sink while the // caller owns the transport (SSE). Routes stay thin; everything @@ -74,6 +76,7 @@ export function contextFromJob( * that has not run migration 0004) degrades to no amendment. */ async function loadAmendmentState(env: Env): Promise { + if (env.AUTH_MODE === 'cail') return coursePrompt(env, env.ASSISTANT_COURSE_ID ?? null); try { const row = await env.DB.prepare( 'SELECT amendment, revision FROM assistant_settings WHERE id = 1' @@ -100,6 +103,13 @@ async function loadAmendment(env: Env): Promise { } export async function getGuide(env: Env, jobId: string): Promise { + if (env.AUTH_MODE === 'cail') { + const scope = (await jobCourse(env, jobId))?.course_id ?? ''; + const state = await coursePrompt(env, scope || null); + const row = await env.DB.prepare('SELECT text,model,created_at AS createdAt,fingerprint FROM course_guides WHERE job_id=? AND course_id=? AND base_version=? AND revision=?') + .bind(jobId, scope, SYSTEM_PROMPT_VERSION, state.revision).first(); + return row && row.fingerprint === await hashSystemPromptFingerprint(state.amendment) ? { text: row.text, model: row.model, createdAt: row.createdAt } : null; + } const row = await env.DB.prepare( `SELECT guides.text, guides.model, guides.created_at, guides.prompt_hash, assistant_settings.amendment @@ -138,6 +148,14 @@ export async function cacheGuideIfPromptCurrent( expectedPromptHash: string ): Promise { if (expectedRevision === null || !/^[a-f0-9]{64}$/.test(expectedPromptHash)) return false; + if (env.AUTH_MODE === 'cail') { + const scope = env.ASSISTANT_COURSE_ID ?? ''; + const result = await env.DB.prepare(`INSERT INTO course_guides(job_id,course_id,text,model,created_at,base_version,revision,fingerprint) + SELECT ?,?,?,?,?,?,?,? WHERE (?='' OR COALESCE((SELECT revision FROM course_settings WHERE course_id=?),0)=?) + ON CONFLICT(job_id,course_id) DO UPDATE SET text=excluded.text,model=excluded.model,created_at=excluded.created_at,base_version=excluded.base_version,revision=excluded.revision,fingerprint=excluded.fingerprint`) + .bind(guide.jobId,scope,guide.text,guide.model,guide.createdAt,SYSTEM_PROMPT_VERSION,expectedRevision,expectedPromptHash,scope,scope,expectedRevision).run(); + return (result.meta.changes ?? 0)>0; + } const result = await env.DB.prepare( `INSERT INTO guides (job_id, text, model, created_at, prompt_version, prompt_revision, prompt_hash) @@ -178,6 +196,7 @@ export async function streamGuide( durationSec: number | undefined, onDelta: (text: string) => void | Promise ): Promise<{ guide: GuideRecord; cached: boolean }> { + if (env.AUTH_MODE === 'cail') env = { ...env, ASSISTANT_COURSE_ID: (await jobCourse(env, row.id))?.course_id ?? null }; const existing = await getGuide(env, row.id); if (existing) return { guide: existing, cached: true }; @@ -247,6 +266,7 @@ export async function streamChat( durationSec: number | undefined, onDelta: (text: string) => void | Promise ): Promise { + if (env.AUTH_MODE === 'cail') env = { ...env, ASSISTANT_COURSE_ID: (await jobCourse(env, row.id))?.course_id ?? null }; const ctx = contextFromJob(row, annotations, durationSec, 'chat', await loadAmendment(env)); const stemNames = ctx.stems.map((s) => s.name); const messages: WireMessage[] = [{ role: 'system', content: buildSystemPrompt(ctx) }, ...turns]; diff --git a/src/classroom/access.ts b/src/classroom/access.ts new file mode 100644 index 0000000..77ff619 --- /dev/null +++ b/src/classroom/access.ts @@ -0,0 +1,67 @@ +import type { Env } from '../env.ts'; +import type { AppPrincipal } from '../identity.ts'; +import { COURSE_APP, STEM_COURSE_ID, validAssignment, validClassId, validFailure, type CourseAssignment } from './contract.ts'; + +export class CourseError extends Error { + constructor(public status: 400 | 403 | 404 | 409 | 503, public code: string) { super(code); } +} +export const courseIds = (env: Env): string[] => { + const ids = env.CAIL_COURSE_IDS?.split(',').map(s => s.trim()) ?? [STEM_COURSE_ID]; + if (!ids.length || ids.length > 20 || ids.some(id => !validClassId(id))) throw new CourseError(503, 'course_configuration_unavailable'); + return [...new Set(ids)]; +}; +export async function boundedRpc(run: () => Promise): Promise { + let timer: ReturnType | undefined; + try { return await Promise.race([run(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 5000); })]); } + catch { throw new CourseError(503, 'course_authority_unavailable'); } + finally { if (timer) clearTimeout(timer); } +} +export async function resolveCourse(env: Env, subject: string, classId: string): Promise { + if (!courseIds(env).includes(classId)) throw new CourseError(403, 'course_not_available'); + if (!env.ADMISSION_RESOLVER?.resolveCourseAccess) throw new CourseError(503, 'course_authority_unavailable'); + const value = await boundedRpc(() => env.ADMISSION_RESOLVER!.resolveCourseAccess!({ subject, app: COURSE_APP, classId })); + if (validFailure(value)) throw new CourseError(403, value.code); + if (!value || typeof value !== 'object' || (value as { ok?: unknown }).ok !== true || !validAssignment(value) || value.classId !== classId) throw new CourseError(503, 'course_authority_unavailable'); + return value; +} +/** Used again by delayed operation workers; a remembered launch is not permission. */ +export async function authorizeStoredCourseWork(env: Env, subject: string, courseId: string | null): Promise { + const user = await env.DB.prepare('SELECT disabled FROM app_users WHERE subject=?').bind(subject).first<{ disabled: number }>(); + if (!user || user.disabled) throw new CourseError(403, 'workspace_disabled'); + const access=await resolveCourse(env,subject,courseId??STEM_COURSE_ID); + if(!access.owner&&access.classId!==STEM_COURSE_ID){const entry=await resolveCourse(env,subject,STEM_COURSE_ID);if(!entry.participant)throw new CourseError(403,'target_course_enrollment_required');} + return access; +} +export interface JobCourse { course_id: string; member_id: string; policy_version: string } +export async function jobCourse(env: Env, jobId: string): Promise { + if (env.AUTH_MODE !== 'cail') return null; + return env.DB.prepare('SELECT course_id, member_id, policy_version FROM job_courses WHERE job_id=?').bind(jobId).first(); +} +export function courseAssignmentStatement(env: Env, id: string, principal: AppPrincipal): D1PreparedStatement | null { + return principal.courseId && principal.course ? env.DB.prepare( + `INSERT INTO job_courses(job_id,course_id,member_id,assigned_by,policy_version) SELECT ?,?,?,?,'course-work-v1' WHERE EXISTS(SELECT 1 FROM jobs WHERE id=?)` + ).bind(id, principal.courseId, principal.course.memberId, principal.subject,id) : null; +} +export function cleanDisplayName(value: unknown): string { + return typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '').trim().slice(0, 160) || 'Student' : 'Student'; +} +export interface JobPermission { owner: boolean; read: boolean; comment: boolean; courseId: string | null; instructor: boolean; administration?: boolean } +export async function jobPermission(env: Env, principal: AppPrincipal, id: string): Promise { + const row = await env.DB.prepare(`SELECT o.subject,j.created_at,c.course_id FROM jobs j JOIN job_owners o ON o.job_id=j.id + LEFT JOIN job_courses c ON c.job_id=j.id WHERE j.id=? AND j.created_at>datetime('now','-90 days')`).bind(id) + .first<{ subject: string; course_id: string | null }>(); + if (!row) return null; + if (!row.course_id) return row.subject === principal.subject || principal.role === 'admin' ? { owner:row.subject===principal.subject,read:true,comment:true,courseId:null,instructor:false,administration:principal.role==='admin' } : null; + const course = principal.course?.classId === row.course_id ? principal.course : await resolveCourse(env, principal.subject, row.course_id); + const owner = row.subject === principal.subject; + if (owner || course.owner) return { owner, read: true, comment: true, courseId: row.course_id, instructor: course.owner }; + const grant = await env.DB.prepare(`SELECT MAX(f.permission='comment') AS comment FROM course_folders f + JOIN course_folder_items i ON i.folder_id=f.id WHERE i.job_id=? AND f.course_id=? AND f.permission IN ('read','comment') + HAVING COUNT(*)>0`).bind(id, row.course_id).first<{ comment: number }>(); + return grant && course.participant ? { owner: false, read: true, comment: !!grant.comment, courseId: row.course_id, instructor: false } : null; +} +export function courseErrorResponse(error: CourseError): Response { + const message = error.status === 503 ? 'Course access is temporarily unavailable. Try again shortly.' + : error.status === 409 ? 'This course changed. Refresh and try again.' : 'This course work is not available to your account.'; + return Response.json({ error: { code: error.code, message } }, { status: error.status, headers: { 'Cache-Control': 'private, no-store' } }); +} diff --git a/src/classroom/contract.ts b/src/classroom/contract.ts new file mode 100644 index 0000000..1cb0c54 --- /dev/null +++ b/src/classroom/contract.ts @@ -0,0 +1,57 @@ +/** Private Admission course wire v1, mirrored from admission-contract/src/course.ts. + * Keep runtime validation here dependency-free; never derive authority from a summary. */ +export const COURSE_APP = 'stem-splitter' as const; +export const STEM_COURSE_ID = 'msh-245-the-american-musical-experience-fall-2026-01'; +export interface CourseAssignment { + classId: string; className: string; term: string; section: string; + memberId: string; displayName: string | null; displayNameSource: 'verified_profile' | 'unavailable'; + participant: boolean; owner: boolean; startsAt: string; checkedAt: string; expiresAt: string; revision: number; +} +export interface CourseFailure { ok: false; code: 'invalid' | 'not_admitted' | 'not_enrolled' | 'scheduled' | 'expired' | 'revoked' | 'not_owner' | 'cursor_invalid' | 'stale_cursor'; retryable: false } +export type CourseAccessResult = (CourseAssignment & { ok: true }) | CourseFailure; +export interface CoursePage { ok: true; checkedAt: string; expiresAt: string; revision: number; nextCursor: string | null } +export interface CourseMember { memberId: string; displayName: string | null; displayNameSource: 'verified_profile' | 'unavailable'; startsAt: string; expiresAt: string } +export interface AdmissionCourseResolver { + resolveCourseAccess?(input: { subject: string; app: typeof COURSE_APP; classId: string }): Promise; + listCourseAssignments?(input: { subject: string; app: typeof COURSE_APP; limit?: number; cursor?: string }): Promise; + listCourseRoster?(input: { subject: string; app: typeof COURSE_APP; classId: string; limit?: number; cursor?: string }): Promise; +} +const classIdPattern = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; +export const validClassId = (value: unknown): value is string => typeof value === 'string' && classIdPattern.test(value) && value !== 'search'; +export const validMemberId = (value: unknown): value is string => typeof value === 'string' && /^stem-member-v1-[0-9a-f]{64}$/.test(value); +const date = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v) && Number.isFinite(Date.parse(v)); +const bounded = (v: unknown, n: number) => typeof v === 'string' && v.trim().length > 0 && v.length <= n && !/[\u0000-\u001f\u007f]/.test(v); +export function validClock(v: Record, now = Date.now()): boolean { + return date(v.checkedAt) && date(v.expiresAt) && Date.parse(v.checkedAt) <= now + 5000 && + now - Date.parse(v.checkedAt) < 30000 && Date.parse(v.expiresAt) > now && + Number.isSafeInteger(v.revision) && Number(v.revision) >= 0; +} +function memberFields(v: unknown): v is CourseMember { + if (!v || typeof v !== 'object') return false; + const r = v as Record; + return validMemberId(r.memberId) && date(r.startsAt) && date(r.expiresAt) && + ((r.displayName === null && r.displayNameSource === 'unavailable') || + (bounded(r.displayName, 160) && r.displayNameSource === 'verified_profile')); +} +export function validMember(v: unknown): v is CourseMember { + return memberFields(v) && Object.keys(v).every(k=>['memberId','displayName','displayNameSource','startsAt','expiresAt'].includes(k)); +} +export function validAssignment(v: unknown, now = Date.now()): v is CourseAssignment { + if (!memberFields(v)) return false; + const r = v as unknown as Record; + return validClassId(r.classId) && bounded(r.className, 200) && bounded(r.term, 120) && bounded(r.section, 120) && + typeof r.owner === 'boolean' && typeof r.participant === 'boolean' && (r.owner || r.participant) && + Object.keys(r).every(k=>['ok','classId','className','term','section','memberId','displayName','displayNameSource','participant','owner','startsAt','checkedAt','expiresAt','revision'].includes(k)) && + validClock(r,now) && Date.parse(r.startsAt as string) <= Date.parse(r.checkedAt as string); +} +export function validPage(v: unknown,now = Date.now()): v is CoursePage { + if (!v || typeof v !== 'object') return false; + const r = v as Record; + return r.ok === true && Object.keys(r).every(k=>['ok','checkedAt','expiresAt','revision','nextCursor',...('participants'in r?['classId','participants']:['assignments'])].includes(k)) && validClock(r,now) && (r.nextCursor === null || + (typeof r.nextCursor === 'string' && /^[A-Za-z0-9_-]{1,2048}$/.test(r.nextCursor))); +} +export function validFailure(v: unknown): v is CourseFailure { + if (!v || typeof v !== 'object') return false; + const r = v as Record; + return Object.keys(r).length===3 && r.ok === false && r.retryable === false && ['invalid', 'not_admitted', 'not_enrolled', 'scheduled', 'expired', 'revoked', 'not_owner', 'cursor_invalid', 'stale_cursor'].includes(String(r.code)); +} diff --git a/src/classroom/conversations.ts b/src/classroom/conversations.ts new file mode 100644 index 0000000..754351b --- /dev/null +++ b/src/classroom/conversations.ts @@ -0,0 +1,78 @@ +import type { Env } from '../env.ts'; +import type { AppPrincipal } from '../identity.ts'; +import type { ChatResult } from '../assistant/index.ts'; +import type { ChatTurn } from '../assistant/types.ts'; +import { CourseError } from './access.ts'; + +type Turn = { input: string; state: string; reply: string | null; tools: string | null; finish_reason: string | null; revision: number }; +export async function conversationPage(env: Env, jobId: string, subject: string, after = 0, expectedRevision?: number) { + const state = await env.DB.prepare(`SELECT revision,expires_at AS expiresAt,pending_turn AS pendingTurn FROM course_conversations + WHERE job_id=? AND subject=? AND expires_at>datetime('now')`).bind(jobId,subject).first<{ revision: number; expiresAt: string; pendingTurn: string | null }>(); + if (expectedRevision !== undefined && expectedRevision !== (state?.revision ?? 0)) throw new CourseError(409,'conversation_changed'); + const { results } = await env.DB.prepare(`SELECT seq,id,kind,text,provenance,created_at AS createdAt FROM course_messages + WHERE job_id=? AND subject=? AND seq>? AND EXISTS(SELECT 1 FROM course_conversations c JOIN jobs j ON j.id=c.job_id WHERE c.job_id=course_messages.job_id AND c.subject=course_messages.subject AND c.expires_at>datetime('now') AND j.created_at>datetime('now','-90 days')) ORDER BY seq LIMIT 41`).bind(jobId,subject,after).all<{ seq: number; kind: string; text: string }>(); + const entries = results.slice(0,40); + return { mode: 'durable', entries, revision: state?.revision ?? 0, expiresAt: state?.expiresAt ?? null, + pending: Boolean(state?.pendingTurn), nextCursor: results.length>40 ? String(entries.at(-1)!.seq) : null }; +} +/** User input is accepted once; assistant/tool messages can only be produced by this server. */ +export async function beginCourseTurn(env: Env, jobId: string, principal: AppPrincipal, input: unknown, turnId: unknown, revision: unknown) { + if (typeof input !== 'string' || !input.trim() || input.length>2000 || typeof turnId !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(turnId) || !Number.isSafeInteger(revision) || Number(revision)<0) throw new CourseError(400,'invalid_conversation_turn'); + const subject = principal.subject; + const existing = await env.DB.prepare('SELECT input,state,reply,tools,finish_reason,revision FROM course_turns WHERE job_id=? AND subject=? AND turn_id=?').bind(jobId,subject,turnId).first(); + if (existing) { + if (existing.input !== input || existing.state === 'pending') throw new CourseError(409,'conversation_changed'); + return { turnId, claimId: '', replay: existing, revision: existing.revision, turns: [] as ChatTurn[] }; + } + const claimId = crypto.randomUUID(); + const result = await env.DB.batch([ + env.DB.prepare(`INSERT OR IGNORE INTO course_conversations(job_id,subject,expires_at) + SELECT j.id,?,datetime(j.created_at,'+90 days') FROM jobs j JOIN job_courses c ON c.job_id=j.id + JOIN job_owners o ON o.job_id=j.id WHERE j.id=? AND o.subject=? AND j.created_at>datetime('now','-90 days')`).bind(subject,jobId,subject), + env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=? WHERE job_id=? AND subject=? AND revision=? AND pending_turn IS NULL AND expires_at>datetime('now')`).bind(claimId,jobId,subject,revision), + env.DB.prepare(`INSERT INTO course_turns(job_id,subject,turn_id,input,state,revision) + SELECT job_id,subject,?,?, 'pending',revision FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?`).bind(turnId,input,jobId,subject,claimId), + env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) + SELECT ?,job_id,subject,?,'you','student',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?`).bind(crypto.randomUUID(),turnId,input,jobId,subject,claimId), + ]); + if (!result[1].meta.changes) throw new CourseError(409,'conversation_changed'); + const { results } = await env.DB.prepare(`SELECT kind,text FROM course_messages WHERE job_id=? AND subject=? AND kind IN ('you','coach') ORDER BY seq DESC LIMIT 12`).bind(jobId,subject).all<{ kind: string; text: string }>(); + const turns = results.reverse().map(row => ({ role: row.kind === 'you' ? 'user' as const : 'assistant' as const, content: row.text.slice(0,2000) })); + return { turnId, claimId, replay: null, revision: Number(revision)+1, turns }; +} +export async function finishCourseTurn(env: Env, jobId: string, subject: string, turnId: string, claimId: string, result: ChatResult, failed = false, displayName = 'Student'): Promise { + const statements = []; + for (const call of result.toolCalls) if (!failed && call.name === 'add_note') { + statements.push(env.DB.prepare(`INSERT INTO annotations(id,job_id,at_seconds,text,author_subject,author_name,provenance) + SELECT ?,job_id,?,?,subject,?,'server-assistant' FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now')`) + .bind(crypto.randomUUID(),Number(call.args.seconds),String(call.args.text).slice(0,200),displayName,jobId,subject,claimId)); + } + + if (result.reply) statements.push(env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) + SELECT ?,job_id,subject,?,'coach','server-assistant',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,result.reply,jobId,subject,claimId)); + if (result.toolCalls.length) statements.push(env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) + SELECT ?,job_id,subject,?,'action','server-tool',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,JSON.stringify(result.toolCalls),jobId,subject,claimId)); + statements.push(env.DB.prepare(`UPDATE course_turns SET state=?,reply=?,tools=?,finish_reason=?,revision=revision+1 + WHERE job_id=? AND subject=? AND turn_id=? AND state='pending' AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now'))`) + .bind(failed?'failed':'complete',result.reply,JSON.stringify(result.toolCalls),result.finishReason,jobId,subject,turnId,jobId,subject,claimId)); + statements.push(env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=NULL WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now') RETURNING revision`).bind(jobId,subject,claimId)); + const saved = await env.DB.batch<{ revision: number }>(statements); + if(!saved.at(-1)?.meta.changes)return null; + const completed=await env.DB.prepare('SELECT revision FROM course_turns WHERE job_id=? AND subject=? AND turn_id=?').bind(jobId,subject,turnId).first<{revision:number}>(); + return completed?.revision??null; +} +export async function resetCourseConversation(env: Env, jobId: string, subject: string, revision: unknown) { + if (!Number.isSafeInteger(revision) || Number(revision)<0) throw new CourseError(400,'invalid_revision'); + const token = `reset-${crypto.randomUUID()}`; + const result = await env.DB.batch([ + env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=? WHERE job_id=? AND subject=? AND revision=? AND expires_at>datetime('now') RETURNING revision`).bind(token,jobId,subject,revision), + env.DB.prepare(`DELETE FROM course_messages WHERE job_id=? AND subject=? AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?)`).bind(jobId,subject,jobId,subject,token), + env.DB.prepare(`DELETE FROM course_turns WHERE job_id=? AND subject=? AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?)`).bind(jobId,subject,jobId,subject,token), + env.DB.prepare('UPDATE course_conversations SET pending_turn=NULL WHERE job_id=? AND subject=? AND pending_turn=?').bind(jobId,subject,token), + ]); + if (!result[0].meta.changes) { + if (revision===0 && !(await env.DB.prepare('SELECT 1 FROM course_conversations WHERE job_id=? AND subject=?').bind(jobId,subject).first())) return { ok:true,revision:0 }; + throw new CourseError(409,'conversation_changed'); + } + return { ok:true, revision:Number(revision)+1 }; +} diff --git a/src/classroom/prompts.ts b/src/classroom/prompts.ts new file mode 100644 index 0000000..9e6fd46 --- /dev/null +++ b/src/classroom/prompts.ts @@ -0,0 +1,43 @@ +import type { Env } from '../env.ts'; +import { buildSystemPromptPreview, hashSystemPromptFingerprint, SYSTEM_PROMPT_VERSION } from '../assistant/prompt.ts'; + +export async function coursePrompt(env: Env, courseId: string | null) { + if (!courseId) return { amendment: '', revision: 0, updatedAt: null, updatedBy: null }; + const row = await env.DB.prepare('SELECT amendment,revision,updated_at AS updatedAt,updated_by AS updatedBy FROM course_settings WHERE course_id=?') + .bind(courseId).first<{ amendment: string; revision: number; updatedAt: string; updatedBy: string }>(); + return row ?? { amendment: '', revision: 0, updatedAt: null, updatedBy: null }; +} +export async function promptView(env: Env, courseId: string, before = Number.MAX_SAFE_INTEGER) { + const state = await coursePrompt(env, courseId); + const { results } = await env.DB.prepare(`SELECT id,revision,amendment,change_note AS changeNote,base_version AS basePromptVersion, + base_hash AS basePromptHash,effective_hash AS effectivePromptHash,created_at AS createdAt + FROM course_prompt_revisions WHERE course_id=? AND id(); + return { ...state, basePrompt: buildSystemPromptPreview(), basePromptVersion: SYSTEM_PROMPT_VERSION, + effectivePromptHash: await hashSystemPromptFingerprint(state.amendment), basePromptHash: await hashSystemPromptFingerprint(), + maxChars: 2000, maxChangeNoteChars: 240, history: results.slice(0, 40), historyHasMore: results.length > 40, + historyNextBeforeId: results.length > 40 ? results[39].id : null }; +} +export async function saveCoursePrompt(env: Env, courseId: string, actor: string, body: Record) { + if (typeof body.amendment !== 'string' || body.amendment.length > 2000 || !Number.isSafeInteger(body.expectedRevision) || Number(body.expectedRevision)<0) return { status: 400 as const, error: 'Enter instructions up to 2000 characters and a current revision.' }; + const amendment = body.amendment.trim(); + const current = await coursePrompt(env, courseId); + if (body.expectedRevision !== current.revision) return { status: 409 as const, error: 'These instructions changed. Refresh before saving.' }; + if (amendment === current.amendment) return { status: 200 as const, changed: false, ...await promptView(env, courseId) }; + const note = typeof body.changeNote === 'string' ? body.changeNote.trim() : ''; + if (!note || note.length>240) return { status: 400 as const, error: 'Describe this change in 1–240 characters.' }; + const base = await hashSystemPromptFingerprint(); + const effective = await hashSystemPromptFingerprint(amendment); + const token = crypto.randomUUID(); + // The writer token fences all statements in the atomic D1 batch, including first insert races. + const statements = [ + env.DB.prepare('INSERT OR IGNORE INTO course_settings(course_id) VALUES(?)').bind(courseId), + env.DB.prepare(`UPDATE course_settings SET amendment=?,revision=revision+1,updated_by=?,updated_at=? WHERE course_id=? AND revision=?`).bind(amendment, actor, token, courseId, current.revision), + env.DB.prepare(`INSERT INTO course_prompt_revisions(course_id,revision,amendment,change_note,base_version,base_hash,effective_hash,actor) + SELECT course_id,revision,amendment,?,?,?,?,? FROM course_settings WHERE course_id=? AND updated_at=?`).bind(note,SYSTEM_PROMPT_VERSION,base,effective,actor,courseId,token), + env.DB.prepare(`DELETE FROM course_guides WHERE course_id=? AND EXISTS(SELECT 1 FROM course_settings WHERE course_id=? AND updated_at=?)`).bind(courseId,courseId,token), + env.DB.prepare("UPDATE course_settings SET updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE course_id=? AND updated_at=?").bind(courseId,token), + ]; + const result = await env.DB.batch(statements); + if (!result[1].meta.changes) return { status: 409 as const, error: 'These instructions changed. Refresh before saving.' }; + return { status: 200 as const, changed: true, ...await promptView(env, courseId) }; +} diff --git a/src/classroom/routes.ts b/src/classroom/routes.ts new file mode 100644 index 0000000..4d7a535 --- /dev/null +++ b/src/classroom/routes.ts @@ -0,0 +1,114 @@ +import { Hono } from 'hono'; +import type { Env } from '../env.ts'; +import type { AppPrincipal } from '../identity.ts'; +import { readBoundedJsonRequest } from '../http/bounded-request.ts'; +import { buildSystemPromptPreview } from '../assistant/prompt.ts'; +import { boundedRpc, courseIds, CourseError, courseErrorResponse } from './access.ts'; +import { COURSE_APP, validAssignment, validFailure, validMember, validMemberId, validPage, type CourseAssignment, type CourseMember } from './contract.ts'; +import { conversationPage } from './conversations.ts'; +import { coursePrompt, promptView, saveCoursePrompt } from './prompts.ts'; + +type State = { Bindings: Env; Variables: { principal?: AppPrincipal } }; +const routes = new Hono(); +routes.use('*', async (c,next) => { + if (c.env.AUTH_MODE !== 'cail' || !c.get('principal')) return c.json({ error:'Not found' },404); + try { await next(); } catch(error) { if (error instanceof CourseError) return courseErrorResponse(error); throw error; } +}); +function owner(p: AppPrincipal) { if (!p.course?.owner) throw new CourseError(403,'not_owner'); } +function cursor(value: string | undefined): string | undefined { + if (value!==undefined && !/^[A-Za-z0-9_-]{1,2048}$/.test(value)) throw new CourseError(400,'cursor_invalid'); + return value; +} +async function body(request: Request): Promise> { + try { const value = await readBoundedJsonRequest(request,16*1024); if (!value || typeof value!=='object' || Array.isArray(value)) throw new Error(); return value as Record; } + catch { throw new CourseError(400,'invalid_request'); } +} +routes.get('/courses', async c => { + const p = c.get('principal')!; + if (!c.env.ADMISSION_RESOLVER?.listCourseAssignments) throw new CourseError(503,'course_authority_unavailable'); + const value = await boundedRpc(() => c.env.ADMISSION_RESOLVER!.listCourseAssignments!({ subject:p.subject,app:COURSE_APP,limit:100,cursor:cursor(c.req.query('cursor')) })); + if (validFailure(value)) throw new CourseError(value.code.includes('cursor')?409:403,value.code); + const result = value as { assignments?: unknown }; + if (!validPage(value) || !Array.isArray(result.assignments) || result.assignments.length>100 || !result.assignments.every(row=>validAssignment(row))) throw new CourseError(503,'course_authority_unavailable'); + const allowed = courseIds(c.env); + return c.json({ courses:(result.assignments as CourseAssignment[]).filter(row=>allowed.includes(row.classId)), nextCursor:value.nextCursor }); +}); +routes.get('/courses/:courseId/roster',async c=>{ + const p=c.get('principal')!; owner(p); + if (!c.env.ADMISSION_RESOLVER?.listCourseRoster) throw new CourseError(503,'course_authority_unavailable'); + const courseId=c.req.param('courseId'); + const value=await boundedRpc(()=>c.env.ADMISSION_RESOLVER!.listCourseRoster!({subject:p.subject,app:COURSE_APP,classId:courseId,limit:40,cursor:cursor(c.req.query('cursor'))})); + if (validFailure(value)) throw new CourseError(value.code.includes('cursor')?409:403,value.code); + const result=value as {classId?:unknown;participants?:unknown}; + if (!validPage(value)||result.classId!==courseId||!Array.isArray(result.participants)||result.participants.length>100||!result.participants.every(validMember)) throw new CourseError(503,'course_authority_unavailable'); + const participants=result.participants as CourseMember[]; + // One aggregate query for the whole authority page; students with no work remain present. + const counts=participants.length ? (await c.env.DB.prepare(`SELECT c.member_id,COUNT(*) AS total,SUM(j.created_at>datetime('now','-90 days')) AS retained + FROM job_courses c JOIN jobs j ON j.id=c.job_id WHERE c.course_id=? AND c.member_id IN (${participants.map(()=>'?').join(',')}) GROUP BY c.member_id`) + .bind(courseId,...participants.map(p=>p.memberId)).all<{member_id:string;total:number;retained:number}>()).results : []; + const byMember=new Map(counts.map(row=>[row.member_id,row])); + return c.json({course:p.course,participants:participants.map(row=>({...row,splitCount:byMember.get(row.memberId)?.total??0,retainedCount:byMember.get(row.memberId)?.retained??0})),nextCursor:value.nextCursor}); +}); +routes.get('/courses/:courseId/participants/:memberId/jobs',async c=>{ + owner(c.get('principal')!); + const courseId=c.req.param('courseId'),memberId=c.req.param('memberId'); + if (!validMemberId(memberId)) throw new CourseError(404,'not_found'); + const after=c.req.query('cursor'); let before=''; + if (after) { try { const v=JSON.parse(atob(after)); if(v.courseId!==courseId||v.memberId!==memberId||typeof v.id!=='string'||!/^[\w-]{1,128}$/.test(v.id)) throw new Error(); before=v.id; } catch {throw new CourseError(400,'cursor_invalid');} } + const {results}=await c.env.DB.prepare(`SELECT j.id,j.filename,j.model,j.status,j.created_at AS createdAt,(j.created_at>datetime('now','-90 days')) AS retained + FROM job_courses c JOIN jobs j ON j.id=c.job_id WHERE c.course_id=? AND c.member_id=? AND (?='' OR j.id(); + return c.json({jobs:results.slice(0,40),nextCursor:results.length>40?btoa(JSON.stringify({courseId,memberId,id:results[39].id})):null}); +}); +routes.get('/courses/:courseId/jobs/:jobId/conversation',async c=>{ + owner(c.get('principal')!); + const job=await c.env.DB.prepare(`SELECT o.subject FROM job_courses jc JOIN jobs j ON j.id=jc.job_id JOIN job_owners o ON o.job_id=j.id + WHERE jc.course_id=? AND j.id=? AND j.created_at>datetime('now','-90 days')`).bind(c.req.param('courseId'),c.req.param('jobId')).first<{subject:string}>(); + if(!job) throw new CourseError(404,'not_found'); + const after=Number(c.req.query('cursor')??0),revision=c.req.query('revision'); + if(!Number.isSafeInteger(after)||after<0||(revision!==undefined&&!/^\d+$/.test(revision))) throw new CourseError(400,'cursor_invalid'); + return c.json({...await conversationPage(c.env,c.req.param('jobId'),job.subject,after,revision===undefined?undefined:Number(revision)),readOnly:true}); +}); +routes.get('/courses/:courseId/prompt',async c=>{owner(c.get('principal')!);return c.json(await promptView(c.env,c.req.param('courseId')));}); +routes.get('/courses/:courseId/prompt/history',async c=>{owner(c.get('principal')!);const before=Number(c.req.query('before'));if(!Number.isSafeInteger(before)||before<1)throw new CourseError(400,'cursor_invalid');return c.json(await promptView(c.env,c.req.param('courseId'),before));}); +routes.get('/courses/:courseId/prompt/preview',async c=>{owner(c.get('principal')!);return c.json({prompt:buildSystemPromptPreview((await coursePrompt(c.env,c.req.param('courseId'))).amendment)});}); +routes.put('/courses/:courseId/prompt',async c=>{const p=c.get('principal')!;owner(p);const result=await saveCoursePrompt(c.env,c.req.param('courseId'),p.subject,await body(c.req.raw));return c.json(result,result.status);}); + +type Folder={id:string;course_id:string;name:string;created_by:string;permission:string;revision:number}; +async function folder(env:Env,p:AppPrincipal,id:string,courseId:string):Promise{ + const row=await env.DB.prepare('SELECT * FROM course_folders WHERE id=? AND course_id=?').bind(id,courseId).first(); + if(!row||(!p.course?.owner&&(row.permission==='private'||!p.course?.participant)))throw new CourseError(404,'not_found');return row; +} +routes.get('/courses/:courseId/folders',async c=>{ + const p=c.get('principal')!;const {results}=await c.env.DB.prepare(`SELECT id,name,permission,revision FROM course_folders WHERE course_id=? AND (?=1 OR permission<>'private') ORDER BY created_at,id LIMIT 100`).bind(c.req.param('courseId'),p.course?.owner?1:0).all();return c.json({folders:results}); +}); +routes.post('/courses/:courseId/folders',async c=>{ + const p=c.get('principal')!;owner(p);const b=await body(c.req.raw);const name=typeof b.name==='string'?b.name.trim():''; + if(!name||name.length>80)throw new CourseError(400,'invalid_folder');const id=crypto.randomUUID(); + await c.env.DB.prepare('INSERT INTO course_folders(id,course_id,name,created_by) VALUES(?,?,?,?)').bind(id,c.req.param('courseId'),name,p.subject).run();return c.json({folder:{id,name,permission:'private',revision:0}}); +}); +routes.get('/courses/:courseId/folders/:id',async c=>{ + const f=await folder(c.env,c.get('principal')!,c.req.param('id'),c.req.param('courseId')); + const {results}=await c.env.DB.prepare(`SELECT i.job_id AS jobId,i.filename,i.model,(j.id IS NOT NULL AND j.created_at>datetime('now','-90 days') AND j.status='done') AS available + FROM course_folder_items i LEFT JOIN jobs j ON j.id=i.job_id LEFT JOIN job_courses jc ON jc.job_id=j.id + WHERE i.folder_id=? AND (jc.course_id=? OR j.id IS NULL) ORDER BY i.added_at,i.job_id`).bind(f.id,f.course_id).all(); + return c.json({folder:{id:f.id,name:f.name,permission:f.permission,revision:f.revision},items:results}); +}); +routes.put('/courses/:courseId/folders/:id',async c=>{ + const p=c.get('principal')!;owner(p);const f=await folder(c.env,p,c.req.param('id'),c.req.param('courseId'));const b=await body(c.req.raw); + if(!['private','read','comment'].includes(String(b.permission))||!Number.isSafeInteger(b.revision))throw new CourseError(400,'invalid_grant'); + const result=await c.env.DB.prepare('UPDATE course_folders SET permission=?,revision=revision+1,created_by=? WHERE id=? AND course_id=? AND revision=?').bind(b.permission,p.subject,f.id,f.course_id,b.revision).run(); + if(!result.meta.changes)throw new CourseError(409,'folder_changed');return c.json({ok:true,revision:Number(b.revision)+1,url:`/classroom.html?course=${encodeURIComponent(f.course_id)}&folder=${f.id}`}); +}); +routes.post('/courses/:courseId/folders/:id/items',async c=>{ + const p=c.get('principal')!;owner(p);const f=await folder(c.env,p,c.req.param('id'),c.req.param('courseId'));const b=await body(c.req.raw); + if(typeof b.jobId!=='string')throw new CourseError(400,'invalid_job'); + const result=await c.env.DB.prepare(`INSERT INTO course_folder_items(folder_id,job_id,filename,model) + SELECT ?,j.id,j.filename,COALESCE(j.model,'htdemucs_ft') FROM jobs j JOIN job_courses jc ON jc.job_id=j.id + WHERE j.id=? AND jc.course_id=? AND j.status='done' AND j.created_at>datetime('now','-90 days') + ON CONFLICT(folder_id,job_id) DO UPDATE SET job_id=excluded.job_id`).bind(f.id,b.jobId,f.course_id).run(); + if(!result.meta.changes)throw new CourseError(404,'not_found');return c.json({ok:true}); +}); +routes.delete('/courses/:courseId/folders/:id/items/:jobId',async c=>{ + const p=c.get('principal')!;owner(p);const f=await folder(c.env,p,c.req.param('id'),c.req.param('courseId'));await c.env.DB.prepare('DELETE FROM course_folder_items WHERE folder_id=? AND job_id=?').bind(f.id,c.req.param('jobId')).run();return c.json({ok:true}); +}); +export default routes; diff --git a/src/env.ts b/src/env.ts index 7b870f5..e747d66 100644 --- a/src/env.ts +++ b/src/env.ts @@ -6,12 +6,15 @@ export type Env = { AUTH_MODE?: string; CAIL_IDENTITY_JWKS?: string; /** Set by the Worker adapter, never by browser input or environment JSON. */ - verifyCailIdentity?: (token: string, jwks: string | undefined) => Promise<{ subject: string } | null | 'unavailable'>; + verifyCailIdentity?: (token: string, jwks: string | undefined) => Promise<{ subject: string; name?: string } | null | 'unavailable'>; /** Private adapter dependencies; never accepted from a request body. */ assistantTransport?: typeof import('./assistant/openrouter.ts').openRouterChatStream; assistantQuota?: () => Promise; ASSISTANT_ABORT_SIGNAL?: AbortSignal; CAIL_BROWSER_ORIGIN?: string; + CAIL_COURSE_IDS?: string; + /** Trusted request scope set only after stored-job authorization. */ + ASSISTANT_COURSE_ID?: string | null; CAIL_LOGIN_URL?: string; ADMISSION_RESOLVER?: import('./identity.ts').AdmissionResolver; REMIXER_ENABLED?: string; diff --git a/src/identity.ts b/src/identity.ts index 68605b6..168c1b6 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -1,9 +1,12 @@ +import { courseIds, resolveCourse, jobCourse, jobPermission, cleanDisplayName, CourseError, courseErrorResponse } from './classroom/access.ts'; +import { STEM_COURSE_ID } from './classroom/contract.ts'; +import type { AdmissionCourseResolver, CourseAssignment } from './classroom/contract.ts'; import type { Env } from './env.ts'; export const STEM_AUDIENCE = 'cail:stem-splitter'; export type AppRole = 'student' | 'instructor' | 'admin'; -export interface AppPrincipal { subject: string; role: AppRole; } -export interface AdmissionResolver { +export interface AppPrincipal { subject: string; role: AppRole; displayName: string; course: CourseAssignment | null; courseId: string | null; } +export interface AdmissionResolver extends AdmissionCourseResolver { resolveMembership(input: { subject: string }): Promise; } @@ -78,23 +81,58 @@ export async function authorizeCailRequest(request: Request, env: Env, authentic const role: AppRole = membership.accessRole === 'admin' ? 'admin' : user.role === 'instructor' && (user.role_expires_at === null || Date.parse(user.role_expires_at) > Date.now()) ? 'instructor' : 'student'; - const principal = { subject: identity.subject, role }; - if (path === '/api/teacher/login' || path === '/api/teacher/logout') return authFailure('admission_required', 403); - if (path.startsWith('/api/admin/') && role !== 'admin') return authFailure('admission_required', 403); - if (path.startsWith('/api/teacher/') && path !== '/api/teacher/me' && path !== '/api/teacher/logout' && role === 'student') { - return authFailure('admission_required', 403); - } const jobId = /^\/api\/(?:teacher\/)?jobs\/([^/]+)/.exec(path)?.[1] ?? /^\/api\/files\/stems\/([^/]+)/.exec(path)?.[1]; - if (jobId) { - const owner = await env.DB.prepare('SELECT subject FROM job_owners WHERE job_id = ?').bind(jobId).first<{ subject: string }>(); - // Existing unclaimed jobs are deliberately unavailable until a reviewed data import assigns ownership. - if (!owner || (role !== 'admin' && owner.subject !== identity.subject)) return Response.json({ error: 'Job not found' }, { status: 404 }); + try { + const storedCourse = jobId ? await jobCourse(env, jobId) : null; + const routeCourse = /^\/api\/classroom\/courses\/([^/]+)/.exec(path)?.[1]; + const selected = storedCourse?.course_id ?? routeCourse ?? request.headers.get('x-stem-course') ?? courseIds(env)[0]; + const personal = selected === 'personal'; + // Admin/app roles remain separate. They never create a course-owner grant. + let course: CourseAssignment | null = null; + try { course = await resolveCourse(env, identity.subject, personal ? courseIds(env)[0] : selected); } + catch (error) { + if (!(error instanceof CourseError) || error.status === 503) throw error; + if (!request.headers.has('x-stem-course') && !storedCourse && !routeCourse && ['/api/account','/api/teacher/me','/api/classroom/courses'].includes(path)) { + // Course owners may discover their independently authorized teaching context. + for (const id of courseIds(env).filter(id=>id!==selected)) { + try { const candidate=await resolveCourse(env,identity.subject,id); if(candidate.owner){course=candidate;break;} } + catch(lookup) { if(!(lookup instanceof CourseError)||lookup.status===503)throw lookup; } + } + } + if (!course && (role === 'student' || storedCourse || routeCourse || (!personal && selected !== courseIds(env)[0]))) throw error; + } + // The entry policy is the exact MSH245 participant source, independently of + // which other configured course is selected. Owners use their separate relationship. + if (course && !course.owner && course.classId !== STEM_COURSE_ID) { + const entry = await resolveCourse(env,identity.subject,STEM_COURSE_ID); + if (!entry.participant) throw new CourseError(403,'target_course_enrollment_required'); + } + const principal: AppPrincipal = { subject: identity.subject, role, + displayName: cleanDisplayName(identity.name), course, + courseId: !personal && request.headers.has('x-stem-course') && course ? course.classId : null }; + if (course?.displayNameSource === 'verified_profile') principal.displayName = cleanDisplayName(course.displayName); + if (path === '/api/teacher/login' || path === '/api/teacher/logout') return authFailure('admission_required', 403); + if (path.startsWith('/api/admin/') && role !== 'admin') return authFailure('admission_required', 403); + if (path.startsWith('/api/teacher/') && path !== '/api/teacher/me' && role === 'student' && !course?.owner) return authFailure('admission_required', 403); + // Cloudflare amendments belong to a course; the legacy singleton remains Railway-only. + if (path.startsWith('/api/teacher/prompt')) return Response.json({ error: 'Choose a course in the instructor page.' }, { status: 409 }); + if (jobId) { + const permission = await jobPermission(env, principal, jobId); + if (!permission) return Response.json({ error: 'Job not found' }, { status: 404 }); + const annotationWrite = /\/annotations(?:\/[^/]+)?$/.test(path); + const read = ['GET', 'HEAD'].includes(request.method); + if (!permission.owner && !permission.administration && ((!read && !(annotationWrite && permission.comment)) || path.includes('/listening-conversation') || path.startsWith('/api/teacher/'))) return Response.json({ error: 'Job not found' }, { status: 404 }); + principal.courseId = permission.courseId; + } + authenticated(principal); + } catch (error) { + if (error instanceof CourseError) return courseErrorResponse(error); + throw error; } const folderId = /^\/api\/teacher\/folders\/([^/]+)/.exec(path)?.[1]; if (folderId) { const folder = await env.DB.prepare('SELECT created_by FROM folders WHERE id = ?').bind(folderId).first<{ created_by: string }>(); if (!folder || (role !== 'admin' && folder.created_by !== identity.subject)) return Response.json({ error: 'Folder not found' }, { status: 404 }); } - authenticated(principal); return null; } diff --git a/src/index.ts b/src/index.ts index 1889111..b5fabba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +import classroomRoutes from './classroom/routes.ts'; +import { courseAssignmentStatement, jobCourse, jobPermission, CourseError, courseErrorResponse } from './classroom/access.ts'; +import { beginCourseTurn, finishCourseTurn, conversationPage, resetCourseConversation } from './classroom/conversations.ts'; import { Hono } from 'hono'; import type { Context } from 'hono'; import { createMiddleware } from 'hono/factory'; @@ -156,6 +159,9 @@ interface AnnotationRow { at_seconds: number; text: string; created_at: string; + author_subject?: string | null; + author_name?: string | null; + provenance?: string | null; } async function sha256Audio(data: ArrayBuffer): Promise { @@ -196,6 +202,8 @@ app.use('/api/*', async (c, next) => { await next(); }); +app.route('/api/classroom', classroomRoutes); + app.get('/api/runtime', (c) => c.json({ authMode: c.env.AUTH_MODE === 'cail' ? 'cail' : 'class-code', loginUrl: c.env.CAIL_LOGIN_URL || null, @@ -454,7 +462,7 @@ function ensureTeachersSeeded(c: Context): Promise { async function currentTeacher(c: Context) { if (c.env.AUTH_MODE === 'cail') { const principal = c.get('principal'); - return principal && principal.role !== 'student' ? { username: principal.subject, displayName: 'Instructor' } : null; + return principal && (principal.role !== 'student' || principal.course?.owner) ? { username: principal.subject, displayName: principal.displayName } : null; } await ensureTeachersSeeded(c); return resolveSession(c.env, readSessionCookie(c.req.header('Cookie'))); @@ -902,6 +910,7 @@ app.post('/api/jobs', requireClassCode, async (c) => { if ('response' in parsed) return parsed.response; const body = parsed.value as | { + coursePolicy?: string; key?: string; filename?: string; youtubeUrl?: string; @@ -913,6 +922,7 @@ app.post('/api/jobs', requireClassCode, async (c) => { } | null; + if (c.get('principal')?.courseId && body?.coursePolicy !== 'course-work-v1') return c.json({ error: 'Confirm that your instructor can review new course work and Listening Guy conversations.' }, 400); const options = getSeparationOptions(c.env.SEPARATION_BACKEND); const submittedModel = body?.model ?? options.defaultModel; const autoCapability = serverAutoCapability(c.env); @@ -1138,7 +1148,11 @@ app.post('/api/jobs', requireClassCode, async (c) => { ); const statements = [insertJob]; const principal = c.get('principal'); - if (principal) statements.push(c.env.DB.prepare('INSERT INTO job_owners (job_id, subject) VALUES (?, ?)').bind(id, principal.subject)); + if (principal) { + statements.push(c.env.DB.prepare('INSERT INTO job_owners (job_id, subject) VALUES (?, ?)').bind(id, principal.subject)); + const assignment = courseAssignmentStatement(c.env, id, principal); + if (assignment) statements.push(assignment); + } if (attribution) statements.push(c.env.DB.prepare('INSERT INTO job_attributions (job_id, attribution) VALUES (?, ?)').bind(id, JSON.stringify(attribution))); await c.env.DB.batch(statements); } catch (error) { @@ -1257,7 +1271,11 @@ app.get('/api/jobs/:id', async (c) => { const guide = row.status === 'done' ? await getGuide(c.env, id) : null; const attribution = await c.env.DB.prepare('SELECT attribution FROM job_attributions WHERE job_id = ?').bind(id).first<{ attribution: string }>(); const publicSharing = c.env.AUTH_MODE === 'cail' && Boolean(await c.env.DB.prepare('SELECT 1 FROM public_split_links WHERE job_id=?').bind(id).first()); - return c.json({ ...jobResponse(row, results ?? [], guide), publicSharing, attribution: attribution ? JSON.parse(attribution.attribution) : null }); + const principal = c.get('principal'); + const permission = principal ? await jobPermission(c.env, principal, id) : null; + return c.json({ ...jobResponse(row, results ?? [], guide), annotations: (results ?? []).map(a => ({ id:a.id,atSeconds:a.at_seconds,text:a.text, + authorName:a.author_subject ? (a.author_name || 'Student') : 'Author unavailable',canDelete:!principal || a.author_subject===principal.subject,provenance:a.provenance??null })), + courseId:permission?.courseId??null, permissions:permission, publicSharing, attribution: attribution ? JSON.parse(attribution.attribution) : null }); }); app.get('/api/teacher/jobs/:id/analysis', requireTeacher, async (c) => { @@ -1669,13 +1687,28 @@ app.post('/api/jobs/:id/annotations', requireClassCode, async (c) => { } const annotationId = crypto.randomUUID(); - await c.env.DB.prepare('INSERT INTO annotations (id, job_id, at_seconds, text) VALUES (?, ?, ?, ?)') - .bind(annotationId, id, atSeconds, text) - .run(); - return c.json({ id: annotationId, atSeconds, text }); + const principal = c.get('principal'); + if (principal) { + await c.env.DB.prepare('INSERT INTO annotations (id,job_id,at_seconds,text,author_subject,author_name,provenance) VALUES(?,?,?,?,?,?,?)') + .bind(annotationId,id,atSeconds,text,principal.subject,principal.displayName,'student').run(); + } else await c.env.DB.prepare('INSERT INTO annotations (id,job_id,at_seconds,text) VALUES(?,?,?,?)').bind(annotationId,id,atSeconds,text).run(); + return c.json({ id:annotationId,atSeconds,text,authorName:principal?.displayName??'Author unavailable',canDelete:true,provenance:principal?'student':null }); +}); + +app.put('/api/jobs/:id/annotations/:annotationId', requireClassCode, async c => { + const principal=c.get('principal'); + if(!principal)return c.json({error:'Sign in to edit a note.'},401); + const parsed=await boundedJson(c,MAX_SMALL_JSON_BYTES);if(parsed.response)return parsed.response; + const text=(parsed.value as {text?:unknown})?.text; + if(typeof text!=='string'||!text.trim()||text.trim().length>200)return c.json({error:'Enter a note up to 200 characters.'},400); + const result=await c.env.DB.prepare('UPDATE annotations SET text=? WHERE id=? AND job_id=? AND author_subject=?') + .bind(text.trim(),c.req.param('annotationId'),c.req.param('id'),principal.subject).run(); + return result.meta.changes?c.json({ok:true,text:text.trim()}):c.json({error:'Note not found'},404); }); app.delete('/api/jobs/:id/annotations/:annotationId', requireClassCode, async (c) => { + const principal = c.get('principal'); + if (principal && !(await c.env.DB.prepare('SELECT 1 FROM annotations WHERE id=? AND job_id=? AND author_subject=?').bind(c.req.param('annotationId'),c.req.param('id'),principal.subject).first())) return c.json({error:'Note not found'},404); await c.env.DB.prepare('DELETE FROM annotations WHERE id = ? AND job_id = ?') .bind(c.req.param('annotationId'), c.req.param('id')) .run(); @@ -1727,6 +1760,12 @@ app.get('/api/jobs/:id/listening-conversation', async (c) => { const principal = c.get('principal'); if (c.env.AUTH_MODE !== 'cail' || !principal) return c.json({ error: 'Sign in with CUNY Login to save this conversation.' }, 401); const id = c.req.param('id'); + if (await jobCourse(c.env,id)) { + const cursor=Number(c.req.query('cursor')??0),revision=c.req.query('revision'); + if(!Number.isSafeInteger(cursor)||cursor<0||(revision!==undefined&&!/^\d+$/.test(revision))) return c.json({error:'Invalid conversation page.'},400); + try { return c.json(await conversationPage(c.env,id,principal.subject,cursor,revision===undefined?undefined:Number(revision))); } + catch(error) { if(error instanceof CourseError)return courseErrorResponse(error);throw error; } + } const row = await c.env.DB.prepare(` SELECT conv.entries, conv.revision, conv.expires_at AS expiresAt FROM listening_conversations conv @@ -1745,6 +1784,7 @@ app.get('/api/jobs/:id/listening-conversation', async (c) => { }); app.put('/api/jobs/:id/listening-conversation', async (c) => { + if (await jobCourse(c.env,c.req.param('id'))) return c.json({ error:'Course conversations are saved by Listening Guy. Reload before continuing.' },409); const principal = c.get('principal'); if (c.env.AUTH_MODE !== 'cail' || !principal) return c.json({ error: 'Sign in with CUNY Login to save this conversation.' }, 401); const parsed = await boundedJson(c, MAX_CONVERSATION_JSON_BYTES); @@ -1787,6 +1827,11 @@ app.delete('/api/jobs/:id/listening-conversation', async (c) => { const principal = c.get('principal'); if (c.env.AUTH_MODE !== 'cail' || !principal) return c.json({ error: 'Sign in with CUNY Login to reset this conversation.' }, 401); const id = c.req.param('id'); + if (await jobCourse(c.env,id)) { + const parsed=await boundedJson(c,MAX_SMALL_JSON_BYTES);if(parsed.response)return parsed.response; + try { return c.json(await resetCourseConversation(c.env,id,principal.subject,(parsed.value as {revision?:unknown})?.revision)); } + catch(error) { if(error instanceof CourseError)return courseErrorResponse(error);throw error; } + } const result = await c.env.DB.prepare(` INSERT INTO listening_conversations (job_id, subject, entries, revision, expires_at) SELECT j.id, ?, '[]', 1, datetime(j.created_at, '+90 days') FROM jobs j @@ -1819,7 +1864,7 @@ app.post('/api/jobs/:id/chat', requireClassCode, async (c) => { const parsed = await boundedJson(c, MAX_JOB_JSON_BYTES); if ('response' in parsed) return parsed.response; const body = parsed.value as - | { messages?: unknown; durationSec?: unknown; mode?: unknown; deck?: unknown } + | { messages?: unknown; durationSec?: unknown; mode?: unknown; deck?: unknown; messageId?: unknown; revision?: unknown } | null; const turns = validateTurns(body?.messages); if (!turns) { @@ -1834,13 +1879,30 @@ app.post('/api/jobs/:id/chat', requireClassCode, async (c) => { .bind(id) .all(); + const course = await jobCourse(c.env,id); + let durable: Awaited> | null = null; + if (course) { + try { durable = await beginCourseTurn(c.env,id,c.get('principal')!,turns.at(-1)!.content,body?.messageId,body?.revision); } + catch(error) { if(error instanceof CourseError)return courseErrorResponse(error);throw error; } + } return sseResponse(c, async (emit, signal) => { - const result = await streamChat( - { ...c.env, ASSISTANT_ABORT_SIGNAL: signal }, row, results ?? [], turns, parseDuration(body?.durationSec), - (text) => emit({ type: 'delta', text }) - ); - if (result.toolCalls.length) await emit({ type: 'tool_calls', calls: result.toolCalls }); - await emit({ type: 'done', text: result.reply, finishReason: result.finishReason }); + if (durable?.replay) { + await emit({type:'done',text:durable.replay.reply??'',finishReason:durable.replay.finish_reason??'stop',revision:durable.replay.revision,replayed:true});return; + } + let streamed = ''; + let result; + try { result = await streamChat( + { ...c.env, ASSISTANT_ABORT_SIGNAL: signal }, row, results ?? [], durable?.turns ?? turns, parseDuration(body?.durationSec), + (text) => { streamed += text; return emit({ type: 'delta', text }); } + ); } catch(error) { + if(durable) await finishCourseTurn(c.env,id,c.get('principal')!.subject,durable.turnId,durable.claimId,{reply:streamed,toolCalls:[],finishReason:'error'},true); + throw error; + } + const revision = durable ? await finishCourseTurn(c.env,id,c.get('principal')!.subject,durable.turnId,durable.claimId,result,false,c.get('principal')!.displayName) : undefined; + if(durable && revision===null) throw new AssistantError(503,'This conversation changed. Refresh before continuing.'); + const calls = durable ? result.toolCalls.filter(call => call.name !== 'add_note') : result.toolCalls; + if (calls.length) await emit({ type: 'tool_calls', calls }); + await emit({ type: 'done', text: result.reply, finishReason: result.finishReason, ...(revision===undefined?{}:{revision,notesChanged:result.toolCalls.some(call=>call.name==='add_note')}) }); }); }); @@ -1907,6 +1969,7 @@ app.get('/api/files/*', async (c) => { app.notFound((c) => c.json({ error: 'Not found' }, 404)); app.onError((_error, c) => { + if(_error instanceof CourseError)return courseErrorResponse(_error); console.error(JSON.stringify({ event: 'request_failed', method: c.req.method })); return c.json({ error: 'The service is temporarily unavailable. Please try again.' }, 503); }); From 0005ec7e10dbc931dff48feeb8552bf36f52e3e1 Mon Sep 17 00:00:00 2001 From: zmuhls Date: Tue, 6 Oct 2026 15:06:33 -0400 Subject: [PATCH 2/7] Fence orphaned course turns and tighten classroom authority --- cloudflare/classroom.test.ts | 90 +++++++++++++++++++++++++++++++--- cloudflare/test-worker.ts | 6 +-- migrations/0021-classroom.sql | 5 +- schema.sql | 5 +- src/classroom/access.ts | 11 +++-- src/classroom/contract.ts | 15 +++--- src/classroom/conversations.ts | 36 +++++++++++--- src/classroom/prompts.ts | 4 +- src/env.ts | 2 + src/identity.ts | 3 +- src/index.ts | 4 +- 11 files changed, 146 insertions(+), 35 deletions(-) diff --git a/cloudflare/classroom.test.ts b/cloudflare/classroom.test.ts index 9131dd9..efab8a4 100644 --- a/cloudflare/classroom.test.ts +++ b/cloudflare/classroom.test.ts @@ -6,11 +6,12 @@ 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, validAssignment, validPage, validFailure } from '../src/classroom/contract.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 } from '../src/classroom/conversations.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'; @@ -29,7 +30,7 @@ async function setup(){ 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),nextCursor:null};}, + 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; @@ -55,6 +56,8 @@ test('current target enrollment and independent course ownership protect direct 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}`); @@ -115,6 +118,8 @@ test('course folders enforce same-course items, grants and author-only notes on 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); @@ -126,20 +131,89 @@ test('fresh and additive migration preserve private history, no backfill; retain const fresh=readFileSync(new URL('../schema.sql',import.meta.url),'utf8'); const legacy=execFileSync('git',['show','e1918da3cb6fc37f9715d4cd143af5a5c6c880c6:schema.sql'],{encoding:'utf8'}); 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(); - db.applySchema(readFileSync(new URL('../migrations/0021-classroom.sql',import.meta.url),'utf8')); - const clean=new SqliteD1(':memory:');clean.applySchema(fresh); + 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));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); + 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(validAssignment(fixture.access,now),true);assert.equal(validPage(fixture.roster,now),true);assert.equal(validFailure(fixture.denied),true); - assert.equal(validAssignment({...fixture.access,subject:ids.alice},now),false);assert.equal(validAssignment(fixture.access,now+31000),false); + 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/test-worker.ts b/cloudflare/test-worker.ts index 541edba..2141b2e 100644 --- a/cloudflare/test-worker.ts +++ b/cloudflare/test-worker.ts @@ -4,7 +4,7 @@ import { STEM_COURSE_ID } from '../src/classroom/contract.ts'; 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 { @@ -68,8 +68,8 @@ export default { } } as Fetcher, 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:[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:[],nextCursor:null};}, + 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', diff --git a/migrations/0021-classroom.sql b/migrations/0021-classroom.sql index c964f29..2035730 100644 --- a/migrations/0021-classroom.sql +++ b/migrations/0021-classroom.sql @@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS course_conversations ( 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) ); @@ -94,8 +95,8 @@ CREATE TABLE IF NOT EXISTS course_messages ( 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')), - provenance TEXT NOT NULL CHECK(provenance IN ('student','server-assistant','server-tool')), + 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')) ); diff --git a/schema.sql b/schema.sql index 0993109..159c2d7 100644 --- a/schema.sql +++ b/schema.sql @@ -603,6 +603,7 @@ CREATE TABLE IF NOT EXISTS course_conversations ( 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) ); @@ -625,8 +626,8 @@ CREATE TABLE IF NOT EXISTS course_messages ( 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')), - provenance TEXT NOT NULL CHECK(provenance IN ('student','server-assistant','server-tool')), + 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')) ); diff --git a/src/classroom/access.ts b/src/classroom/access.ts index 77ff619..032e0a3 100644 --- a/src/classroom/access.ts +++ b/src/classroom/access.ts @@ -1,9 +1,11 @@ import type { Env } from '../env.ts'; import type { AppPrincipal } from '../identity.ts'; -import { COURSE_APP, STEM_COURSE_ID, validAssignment, validClassId, validFailure, type CourseAssignment } from './contract.ts'; +import { COURSE_APP, STEM_COURSE_ID, validAccess, validClassId, validFailure, type CourseAssignment } from './contract.ts'; export class CourseError extends Error { - constructor(public status: 400 | 403 | 404 | 409 | 503, public code: string) { super(code); } + status: 400 | 403 | 404 | 409 | 503; + code: string; + constructor(status: 400 | 403 | 404 | 409 | 503, code: string) { super(code); this.status=status; this.code=code; } } export const courseIds = (env: Env): string[] => { const ids = env.CAIL_COURSE_IDS?.split(',').map(s => s.trim()) ?? [STEM_COURSE_ID]; @@ -21,11 +23,12 @@ export async function resolveCourse(env: Env, subject: string, classId: string): if (!env.ADMISSION_RESOLVER?.resolveCourseAccess) throw new CourseError(503, 'course_authority_unavailable'); const value = await boundedRpc(() => env.ADMISSION_RESOLVER!.resolveCourseAccess!({ subject, app: COURSE_APP, classId })); if (validFailure(value)) throw new CourseError(403, value.code); - if (!value || typeof value !== 'object' || (value as { ok?: unknown }).ok !== true || !validAssignment(value) || value.classId !== classId) throw new CourseError(503, 'course_authority_unavailable'); + if (!value || typeof value !== 'object' || (value as { ok?: unknown }).ok !== true || !validAccess(value) || value.classId !== classId) throw new CourseError(503, 'course_authority_unavailable'); return value; } /** Used again by delayed operation workers; a remembered launch is not permission. */ export async function authorizeStoredCourseWork(env: Env, subject: string, courseId: string | null): Promise { + if (courseId && env.CAIL_CLASSROOM_ENABLED === 'false') throw new CourseError(503, 'course_work_paused'); const user = await env.DB.prepare('SELECT disabled FROM app_users WHERE subject=?').bind(subject).first<{ disabled: number }>(); if (!user || user.disabled) throw new CourseError(403, 'workspace_disabled'); const access=await resolveCourse(env,subject,courseId??STEM_COURSE_ID); @@ -54,6 +57,7 @@ export async function jobPermission(env: Env, principal: AppPrincipal, id: strin if (!row.course_id) return row.subject === principal.subject || principal.role === 'admin' ? { owner:row.subject===principal.subject,read:true,comment:true,courseId:null,instructor:false,administration:principal.role==='admin' } : null; const course = principal.course?.classId === row.course_id ? principal.course : await resolveCourse(env, principal.subject, row.course_id); const owner = row.subject === principal.subject; + if (env.CAIL_CLASSROOM_ENABLED === 'false') return owner ? {owner:true,read:true,comment:false,courseId:row.course_id,instructor:false} : null; if (owner || course.owner) return { owner, read: true, comment: true, courseId: row.course_id, instructor: course.owner }; const grant = await env.DB.prepare(`SELECT MAX(f.permission='comment') AS comment FROM course_folders f JOIN course_folder_items i ON i.folder_id=f.id WHERE i.job_id=? AND f.course_id=? AND f.permission IN ('read','comment') @@ -62,6 +66,7 @@ export async function jobPermission(env: Env, principal: AppPrincipal, id: strin } export function courseErrorResponse(error: CourseError): Response { const message = error.status === 503 ? 'Course access is temporarily unavailable. Try again shortly.' + : error.code === 'conversation_interrupted' ? 'The previous reply could not be confirmed. Reload the conversation and send a new message; the old request will not be retried.' : error.status === 409 ? 'This course changed. Refresh and try again.' : 'This course work is not available to your account.'; return Response.json({ error: { code: error.code, message } }, { status: error.status, headers: { 'Cache-Control': 'private, no-store' } }); } diff --git a/src/classroom/contract.ts b/src/classroom/contract.ts index 1cb0c54..c3c2253 100644 --- a/src/classroom/contract.ts +++ b/src/classroom/contract.ts @@ -1,4 +1,5 @@ -/** Private Admission course wire v1, mirrored from admission-contract/src/course.ts. +/** Private Admission course wire v1, mirrored from admission-contract/src/course.ts + * at cail-tools-admission 5f13f332c120c6712aead2a355b3cf5bcc6dae58. * Keep runtime validation here dependency-free; never derive authority from a summary. */ export const COURSE_APP = 'stem-splitter' as const; export const STEM_COURSE_ID = 'msh-245-the-american-musical-experience-fall-2026-01'; @@ -19,11 +20,11 @@ export interface AdmissionCourseResolver { const classIdPattern = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; export const validClassId = (value: unknown): value is string => typeof value === 'string' && classIdPattern.test(value) && value !== 'search'; export const validMemberId = (value: unknown): value is string => typeof value === 'string' && /^stem-member-v1-[0-9a-f]{64}$/.test(value); -const date = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v) && Number.isFinite(Date.parse(v)); +const date = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v; const bounded = (v: unknown, n: number) => typeof v === 'string' && v.trim().length > 0 && v.length <= n && !/[\u0000-\u001f\u007f]/.test(v); export function validClock(v: Record, now = Date.now()): boolean { return date(v.checkedAt) && date(v.expiresAt) && Date.parse(v.checkedAt) <= now + 5000 && - now - Date.parse(v.checkedAt) < 30000 && Date.parse(v.expiresAt) > now && + now - Date.parse(v.checkedAt) < 30000 && Date.parse(v.expiresAt) > now && Date.parse(v.checkedAt) < Date.parse(v.expiresAt) && Number.isSafeInteger(v.revision) && Number(v.revision) >= 0; } function memberFields(v: unknown): v is CourseMember { @@ -36,14 +37,16 @@ function memberFields(v: unknown): v is CourseMember { export function validMember(v: unknown): v is CourseMember { return memberFields(v) && Object.keys(v).every(k=>['memberId','displayName','displayNameSource','startsAt','expiresAt'].includes(k)); } -export function validAssignment(v: unknown, now = Date.now()): v is CourseAssignment { +function assignment(v: unknown, now: number, access: boolean): v is CourseAssignment { if (!memberFields(v)) return false; const r = v as unknown as Record; return validClassId(r.classId) && bounded(r.className, 200) && bounded(r.term, 120) && bounded(r.section, 120) && typeof r.owner === 'boolean' && typeof r.participant === 'boolean' && (r.owner || r.participant) && - Object.keys(r).every(k=>['ok','classId','className','term','section','memberId','displayName','displayNameSource','participant','owner','startsAt','checkedAt','expiresAt','revision'].includes(k)) && - validClock(r,now) && Date.parse(r.startsAt as string) <= Date.parse(r.checkedAt as string); + Object.keys(r).every(k=>[...(access?['ok']:[]),'classId','className','term','section','memberId','displayName','displayNameSource','participant','owner','startsAt','checkedAt','expiresAt','revision'].includes(k)) && + (!access || r.ok === true) && validClock(r,now) && Date.parse(r.startsAt as string) <= Date.parse(r.checkedAt as string); } +export const validAssignment = (v: unknown, now=Date.now()): v is CourseAssignment => assignment(v,now,false); +export const validAccess = (v: unknown, now=Date.now()): v is CourseAssignment & {ok:true} => assignment(v,now,true); export function validPage(v: unknown,now = Date.now()): v is CoursePage { if (!v || typeof v !== 'object') return false; const r = v as Record; diff --git a/src/classroom/conversations.ts b/src/classroom/conversations.ts index 754351b..fefd043 100644 --- a/src/classroom/conversations.ts +++ b/src/classroom/conversations.ts @@ -5,7 +5,27 @@ import type { ChatTurn } from '../assistant/types.ts'; import { CourseError } from './access.ts'; type Turn = { input: string; state: string; reply: string | null; tools: string | null; finish_reason: string | null; revision: number }; +/** Longer than the 60-second model deadline. Expiry never retries inference. */ +export const COURSE_TURN_LEASE_SECONDS = 120; +export async function recoverExpiredCourseTurn(env: Env, jobId: string, subject: string): Promise { + const expired=await env.DB.prepare("SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn IS NOT NULL AND (pending_expires_at IS NULL OR pending_expires_at<=datetime('now')) AND expires_at>datetime('now')").bind(jobId,subject).first(); + if(!expired)return false; + const token=`recover-${crypto.randomUUID()}`; + const recovered=await env.DB.batch([ + env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=?,pending_expires_at=datetime('now','+5 seconds') + WHERE job_id=? AND subject=? AND pending_turn IS NOT NULL AND (pending_expires_at IS NULL OR pending_expires_at<=datetime('now')) + AND expires_at>datetime('now') AND EXISTS(SELECT 1 FROM jobs WHERE id=? AND created_at>datetime('now','-90 days'))`).bind(token,jobId,subject,jobId), + env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) + SELECT ?,c.job_id,c.subject,t.turn_id,'status','server-status',? FROM course_conversations c JOIN course_turns t ON t.job_id=c.job_id AND t.subject=c.subject + WHERE c.job_id=? AND c.subject=? AND c.pending_turn=? AND t.state='pending'`).bind(crypto.randomUUID(),'The previous request ended before its completion could be verified. Earlier messages are preserved. It will not be retried automatically; send a new message to continue.',jobId,subject,token), + env.DB.prepare(`UPDATE course_turns SET state='failed',reply=NULL,tools='[]',finish_reason='interrupted',revision=(SELECT revision FROM course_conversations WHERE job_id=? AND subject=?) + WHERE job_id=? AND subject=? AND state='pending' AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?)`).bind(jobId,subject,jobId,subject,jobId,subject,token), + env.DB.prepare('UPDATE course_conversations SET pending_turn=NULL,pending_expires_at=NULL WHERE job_id=? AND subject=? AND pending_turn=?').bind(jobId,subject,token), + ]); + return Boolean(recovered[0].meta.changes); +} export async function conversationPage(env: Env, jobId: string, subject: string, after = 0, expectedRevision?: number) { + await recoverExpiredCourseTurn(env,jobId,subject); const state = await env.DB.prepare(`SELECT revision,expires_at AS expiresAt,pending_turn AS pendingTurn FROM course_conversations WHERE job_id=? AND subject=? AND expires_at>datetime('now')`).bind(jobId,subject).first<{ revision: number; expiresAt: string; pendingTurn: string | null }>(); if (expectedRevision !== undefined && expectedRevision !== (state?.revision ?? 0)) throw new CourseError(409,'conversation_changed'); @@ -19,8 +39,10 @@ export async function conversationPage(env: Env, jobId: string, subject: string, export async function beginCourseTurn(env: Env, jobId: string, principal: AppPrincipal, input: unknown, turnId: unknown, revision: unknown) { if (typeof input !== 'string' || !input.trim() || input.length>2000 || typeof turnId !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(turnId) || !Number.isSafeInteger(revision) || Number(revision)<0) throw new CourseError(400,'invalid_conversation_turn'); const subject = principal.subject; + await recoverExpiredCourseTurn(env,jobId,subject); const existing = await env.DB.prepare('SELECT input,state,reply,tools,finish_reason,revision FROM course_turns WHERE job_id=? AND subject=? AND turn_id=?').bind(jobId,subject,turnId).first(); if (existing) { + if(existing.state==='failed')throw new CourseError(409,'conversation_interrupted'); if (existing.input !== input || existing.state === 'pending') throw new CourseError(409,'conversation_changed'); return { turnId, claimId: '', replay: existing, revision: existing.revision, turns: [] as ChatTurn[] }; } @@ -29,7 +51,7 @@ export async function beginCourseTurn(env: Env, jobId: string, principal: AppPri env.DB.prepare(`INSERT OR IGNORE INTO course_conversations(job_id,subject,expires_at) SELECT j.id,?,datetime(j.created_at,'+90 days') FROM jobs j JOIN job_courses c ON c.job_id=j.id JOIN job_owners o ON o.job_id=j.id WHERE j.id=? AND o.subject=? AND j.created_at>datetime('now','-90 days')`).bind(subject,jobId,subject), - env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=? WHERE job_id=? AND subject=? AND revision=? AND pending_turn IS NULL AND expires_at>datetime('now')`).bind(claimId,jobId,subject,revision), + env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=?,pending_expires_at=datetime('now','+${COURSE_TURN_LEASE_SECONDS} seconds') WHERE job_id=? AND subject=? AND revision=? AND pending_turn IS NULL AND expires_at>datetime('now')`).bind(claimId,jobId,subject,revision), env.DB.prepare(`INSERT INTO course_turns(job_id,subject,turn_id,input,state,revision) SELECT job_id,subject,?,?, 'pending',revision FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?`).bind(turnId,input,jobId,subject,claimId), env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) @@ -44,18 +66,18 @@ export async function finishCourseTurn(env: Env, jobId: string, subject: string, const statements = []; for (const call of result.toolCalls) if (!failed && call.name === 'add_note') { statements.push(env.DB.prepare(`INSERT INTO annotations(id,job_id,at_seconds,text,author_subject,author_name,provenance) - SELECT ?,job_id,?,?,subject,?,'server-assistant' FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now')`) + SELECT ?,job_id,?,?,subject,?,'server-assistant' FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')`) .bind(crypto.randomUUID(),Number(call.args.seconds),String(call.args.text).slice(0,200),displayName,jobId,subject,claimId)); } if (result.reply) statements.push(env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) - SELECT ?,job_id,subject,?,'coach','server-assistant',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,result.reply,jobId,subject,claimId)); + SELECT ?,job_id,subject,?,'coach','server-assistant',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,result.reply,jobId,subject,claimId)); if (result.toolCalls.length) statements.push(env.DB.prepare(`INSERT INTO course_messages(id,job_id,subject,turn_id,kind,provenance,text) - SELECT ?,job_id,subject,?,'action','server-tool',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,JSON.stringify(result.toolCalls),jobId,subject,claimId)); + SELECT ?,job_id,subject,?,'action','server-tool',? FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now')`).bind(crypto.randomUUID(),turnId,JSON.stringify(result.toolCalls),jobId,subject,claimId)); statements.push(env.DB.prepare(`UPDATE course_turns SET state=?,reply=?,tools=?,finish_reason=?,revision=revision+1 - WHERE job_id=? AND subject=? AND turn_id=? AND state='pending' AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now'))`) + WHERE job_id=? AND subject=? AND turn_id=? AND state='pending' AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now'))`) .bind(failed?'failed':'complete',result.reply,JSON.stringify(result.toolCalls),result.finishReason,jobId,subject,turnId,jobId,subject,claimId)); - statements.push(env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=NULL WHERE job_id=? AND subject=? AND pending_turn=? AND expires_at>datetime('now') RETURNING revision`).bind(jobId,subject,claimId)); + statements.push(env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=NULL,pending_expires_at=NULL WHERE job_id=? AND subject=? AND pending_turn=? AND pending_expires_at>datetime('now') AND expires_at>datetime('now') RETURNING revision`).bind(jobId,subject,claimId)); const saved = await env.DB.batch<{ revision: number }>(statements); if(!saved.at(-1)?.meta.changes)return null; const completed=await env.DB.prepare('SELECT revision FROM course_turns WHERE job_id=? AND subject=? AND turn_id=?').bind(jobId,subject,turnId).first<{revision:number}>(); @@ -68,7 +90,7 @@ export async function resetCourseConversation(env: Env, jobId: string, subject: env.DB.prepare(`UPDATE course_conversations SET revision=revision+1,pending_turn=? WHERE job_id=? AND subject=? AND revision=? AND expires_at>datetime('now') RETURNING revision`).bind(token,jobId,subject,revision), env.DB.prepare(`DELETE FROM course_messages WHERE job_id=? AND subject=? AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?)`).bind(jobId,subject,jobId,subject,token), env.DB.prepare(`DELETE FROM course_turns WHERE job_id=? AND subject=? AND EXISTS(SELECT 1 FROM course_conversations WHERE job_id=? AND subject=? AND pending_turn=?)`).bind(jobId,subject,jobId,subject,token), - env.DB.prepare('UPDATE course_conversations SET pending_turn=NULL WHERE job_id=? AND subject=? AND pending_turn=?').bind(jobId,subject,token), + env.DB.prepare('UPDATE course_conversations SET pending_turn=NULL,pending_expires_at=NULL WHERE job_id=? AND subject=? AND pending_turn=?').bind(jobId,subject,token), ]); if (!result[0].meta.changes) { if (revision===0 && !(await env.DB.prepare('SELECT 1 FROM course_conversations WHERE job_id=? AND subject=?').bind(jobId,subject).first())) return { ok:true,revision:0 }; diff --git a/src/classroom/prompts.ts b/src/classroom/prompts.ts index 9e6fd46..5ad0437 100644 --- a/src/classroom/prompts.ts +++ b/src/classroom/prompts.ts @@ -9,10 +9,10 @@ export async function coursePrompt(env: Env, courseId: string | null) { } export async function promptView(env: Env, courseId: string, before = Number.MAX_SAFE_INTEGER) { const state = await coursePrompt(env, courseId); - const { results } = await env.DB.prepare(`SELECT id,revision,amendment,change_note AS changeNote,base_version AS basePromptVersion, + const { results } = await env.DB.prepare(`SELECT id,revision,revision AS settingsRevision,'Course instructor' AS updatedBy,amendment,change_note AS changeNote,base_version AS basePromptVersion, base_hash AS basePromptHash,effective_hash AS effectivePromptHash,created_at AS createdAt FROM course_prompt_revisions WHERE course_id=? AND id(); - return { ...state, basePrompt: buildSystemPromptPreview(), basePromptVersion: SYSTEM_PROMPT_VERSION, + return { ...state, updatedBy:state.updatedBy?'Course instructor':null, basePrompt: buildSystemPromptPreview(), basePromptVersion: SYSTEM_PROMPT_VERSION, effectivePromptHash: await hashSystemPromptFingerprint(state.amendment), basePromptHash: await hashSystemPromptFingerprint(), maxChars: 2000, maxChangeNoteChars: 240, history: results.slice(0, 40), historyHasMore: results.length > 40, historyNextBeforeId: results.length > 40 ? results[39].id : null }; diff --git a/src/env.ts b/src/env.ts index e747d66..d6d8ab6 100644 --- a/src/env.ts +++ b/src/env.ts @@ -13,6 +13,8 @@ export type Env = { ASSISTANT_ABORT_SIGNAL?: AbortSignal; CAIL_BROWSER_ORIGIN?: string; CAIL_COURSE_IDS?: string; + /** Literal false pauses course collaboration while retaining owner-only reads. */ + CAIL_CLASSROOM_ENABLED?: string; /** Trusted request scope set only after stored-job authorization. */ ASSISTANT_COURSE_ID?: string | null; CAIL_LOGIN_URL?: string; diff --git a/src/identity.ts b/src/identity.ts index 168c1b6..a56f433 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -87,6 +87,7 @@ export async function authorizeCailRequest(request: Request, env: Env, authentic const routeCourse = /^\/api\/classroom\/courses\/([^/]+)/.exec(path)?.[1]; const selected = storedCourse?.course_id ?? routeCourse ?? request.headers.get('x-stem-course') ?? courseIds(env)[0]; const personal = selected === 'personal'; + if (env.CAIL_CLASSROOM_ENABLED === 'false' && (routeCourse || path === '/api/classroom/courses' || (storedCourse && !['GET','HEAD'].includes(request.method)) || (!storedCourse && !personal && request.headers.has('x-stem-course') && !['GET','HEAD'].includes(request.method)))) throw new CourseError(503,'course_work_paused'); // Admin/app roles remain separate. They never create a course-owner grant. let course: CourseAssignment | null = null; try { course = await resolveCourse(env, identity.subject, personal ? courseIds(env)[0] : selected); } @@ -113,7 +114,7 @@ export async function authorizeCailRequest(request: Request, env: Env, authentic if (course?.displayNameSource === 'verified_profile') principal.displayName = cleanDisplayName(course.displayName); if (path === '/api/teacher/login' || path === '/api/teacher/logout') return authFailure('admission_required', 403); if (path.startsWith('/api/admin/') && role !== 'admin') return authFailure('admission_required', 403); - if (path.startsWith('/api/teacher/') && path !== '/api/teacher/me' && role === 'student' && !course?.owner) return authFailure('admission_required', 403); + if (path.startsWith('/api/teacher/') && path !== '/api/teacher/me' && role === 'student') return authFailure('admission_required', 403); // Cloudflare amendments belong to a course; the legacy singleton remains Railway-only. if (path.startsWith('/api/teacher/prompt')) return Response.json({ error: 'Choose a course in the instructor page.' }, { status: 409 }); if (jobId) { diff --git a/src/index.ts b/src/index.ts index b5fabba..9a78994 100644 --- a/src/index.ts +++ b/src/index.ts @@ -182,6 +182,7 @@ app.use('/api/*', async (c, next) => { if (denied) return denied; c.header('Cache-Control', 'private, no-store'); const principal = c.get('principal'); + if(principal)c.header('X-Stem-Account',principal.subject); const scope = c.req.method !== 'POST' ? null : c.req.path === '/api/jobs' ? 'split' : !c.env.assistantTransport && /^\/api\/jobs\/[^/]+\/(?:guide|chat)$/.test(c.req.path) ? 'guide' : null; if (scope && principal) { @@ -462,7 +463,7 @@ function ensureTeachersSeeded(c: Context): Promise { async function currentTeacher(c: Context) { if (c.env.AUTH_MODE === 'cail') { const principal = c.get('principal'); - return principal && (principal.role !== 'student' || principal.course?.owner) ? { username: principal.subject, displayName: principal.displayName } : null; + return principal && principal.role !== 'student' ? { username: principal.subject, displayName: principal.displayName } : null; } await ensureTeachersSeeded(c); return resolveSession(c.env, readSessionCookie(c.req.header('Cookie'))); @@ -793,6 +794,7 @@ app.post('/api/teacher/folders/:id/items', requireTeacher, async (c) => { const jobId = (parsed.value as { jobId?: unknown } | null)?.jobId; if (typeof jobId !== 'string' || !jobId) return c.json({ error: 'A jobId is required.' }, 400); const principal = c.get('principal'); + if (principal && await jobCourse(c.env,jobId)) return c.json({ error:'Use a course folder for this split.' },409); if (principal && principal.role !== 'admin' && !(await c.env.DB.prepare('SELECT job_id FROM job_owners WHERE job_id = ? AND subject = ?') .bind(jobId, principal.subject).first())) return c.json({ error: 'Job not found' }, 404); From daaa8c55a51a6c6ef8a2bc82d8eebba623cc87e7 Mon Sep 17 00:00:00 2001 From: zmuhls Date: Tue, 6 Oct 2026 15:15:01 -0400 Subject: [PATCH 3/7] Build scoped classroom workspace and in-place refresh UI --- cloudflare/README.md | 10 ++ cloudflare/account.spec.mjs | 18 +-- cloudflare/browser.spec.mjs | 1 + cloudflare/classroom.spec.mjs | 122 ++++++++++++++++++ cloudflare/playwright.config.mjs | 2 +- cloudflare/sharing.spec.mjs | 4 + cloudflare/sso.test.ts | 20 +++ cloudflare/sso.ts | 11 +- docs/acceptance/classroom-2026-10-06.md | 81 ++++++++++++ docs/classroom-access.md | 151 ++++++++++++++++++++++ public/account.html | 19 +-- public/account.js | 4 +- public/app.js | 160 +++++++++++++++++++----- public/classroom.html | 19 +++ public/classroom.js | 53 ++++++++ public/index.html | 19 +-- public/session-guard.js | 28 +++++ public/styles.css | 64 ++++++++++ public/teacher.html | 10 +- public/teacher.js | 20 +++ 20 files changed, 749 insertions(+), 67 deletions(-) create mode 100644 cloudflare/classroom.spec.mjs create mode 100644 docs/acceptance/classroom-2026-10-06.md create mode 100644 docs/classroom-access.md create mode 100644 public/classroom.html create mode 100644 public/classroom.js create mode 100644 public/session-guard.js 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..1c2eed1 --- /dev/null +++ b/cloudflare/classroom.spec.mjs @@ -0,0 +1,122 @@ +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/*',route=>route.fulfill({contentType:'audio/wav',body:tone()})); + 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/*',route=>route.fulfill({contentType:'audio/wav',body:tone()})); + 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 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/*',route=>route.fulfill({contentType:'audio/wav',body:tone()}));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 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();} +}); 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/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/docs/acceptance/classroom-2026-10-06.md b/docs/acceptance/classroom-2026-10-06.md new file mode 100644 index 0000000..088756d --- /dev/null +++ b/docs/acceptance/classroom-2026-10-06.md @@ -0,0 +1,81 @@ +# 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-classroom-root-reviewed.log` | +| Cloudflare `bun run typecheck && bun run test` | 50 tests pass | `/tmp/stem-classroom-unit-final.log` | +| Full Cloudflare Chrome browser suite | 30 tests pass | `/tmp/stem-classroom-chrome-final.log` | +| Firefox playback, sharing and classroom suite | 19 pass; native Chromium zoom intentionally skipped | `/tmp/stem-classroom-firefox-reviewed.log` | +| WebKit playback, sharing and classroom suite | 19 pass; native Chromium zoom intentionally skipped | `/tmp/stem-classroom-webkit-reviewed.log` | +| Final Firefox classroom rerun | 4 pass; native Chromium zoom skipped | `/tmp/stem-classroom-firefox-final.log` | +| Final WebKit classroom control rerun | 4 pass; native Chromium zoom skipped | `/tmp/stem-classroom-webkit-controls.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-classroom-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. + +## 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. 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 checks the note/SAVE and speed controls. Its 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. + +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. + +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..77a4fee --- /dev/null +++ b/docs/classroom-access.md @@ -0,0 +1,151 @@ +# 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. + +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. + +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/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..43578cd 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'); @@ -1680,11 +1691,19 @@ class Mixer { this.endScrub(true); }); + 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'); @@ -1841,13 +1860,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 +1885,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) { @@ -2216,7 +2245,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; @@ -2306,7 +2335,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 +2419,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 +2428,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 +2482,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 +2530,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(); @@ -2620,7 +2681,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 +2708,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 +2770,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 +2792,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 +2815,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 +2861,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 +2876,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 +2902,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 +3229,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 +3345,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 +3607,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 +4345,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 +4504,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 +4548,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';}); 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 +4575,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..0cb856b --- /dev/null +++ b/public/classroom.js @@ -0,0 +1,53 @@ +const el=id=>document.getElementById(id); +let courses=[],course=null,rosterCursor=null,jobsCursor=null,student=null,conversation=null,folder=null,generation=0; +const status=message=>{el('classroom-status').textContent=message;}; +async function request(path,options={}){ + const response=await fetch(path,{...options,cache:'no-store',headers:{'Content-Type':'application/json',...(course?{'x-stem-course':course.classId}:{}),...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; +} +function chooseSplit(job){el('folder-job').value=job.id;el('folder-selected').textContent=job.filename;el('folder-add-button').disabled=false;} +const path=suffix=>`/api/classroom/courses/${encodeURIComponent(course.classId)}${suffix}`; +function item(list,text){const row=document.createElement('li');const span=document.createElement('span');span.textContent=text;row.append(span);list.append(row);return row;} +function button(row,label,action){const b=document.createElement('button');b.className='account-button';b.textContent=label;b.type='button';b.addEventListener('click',()=>run(b,action));row.append(b);return b;} +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(button,action){if(button.disabled)return;button.disabled=true;try{await action();}catch(error){status(error.message);if([401,403].includes(error.status)){el('classroom-content').hidden=true;courses=[];course=null;}}finally{button.disabled=button.id==='folder-add-button'&&!el('folder-job').value;}} +async function roster(more=false){ + const epoch=generation;const page=await request(path('/roster')+(more&&rosterCursor?`?cursor=${encodeURIComponent(rosterCursor)}`:''));if(epoch!==generation)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',()=>loadJobs(member));} + 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){ + const epoch=generation;const page=await request(path(`/participants/${encodeURIComponent(member.memberId)}/jobs`)+(more&&jobsCursor?`?cursor=${encodeURIComponent(jobsCursor)}`:''));if(epoch!==generation)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',()=>loadConversation(job));}if(job.retained&&job.status==='done')button(row,'Use in folder',()=>{chooseSplit(job);el('course-folders').scrollIntoView({block:'nearest'});});} + 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){ + const epoch=generation;const page=await request(path(`/jobs/${encodeURIComponent(job.id)}/conversation`)+(more&&conversation?.cursor?`?cursor=${encodeURIComponent(conversation.cursor)}&revision=${conversation.revision}`:''));if(epoch!==generation)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(){const epoch=generation;const page=await request(path('/folders'));if(epoch!==generation)return;el('course-folders').replaceChildren();for(const f of page.folders){const row=item(el('course-folders'),`${f.name} · ${f.permission==='private'?'Private':f.permission==='comment'?'Course comments':'Course listening'}`);button(row,'Open folder',()=>openFolder(f.id));}if(!page.folders.length)item(el('course-folders'),'No course folders yet.');} +async function openFolder(id){ + const epoch=generation;const page=await request(path(`/folders/${encodeURIComponent(id)}`));if(epoch!==generation)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()=>{await request(path(`/folders/${folder.id}/items/${job.jobId}`),{method:'DELETE'});await openFolder(folder.id);});} + if(!page.items.length)item(el('folder-items'),'This folder is empty.'); +} +async function selectCourse(){ + generation++;course=courses.find(c=>c.classId===el('classroom-course').value);if(!course)return; + for(const id of ['classroom-jobs','classroom-conversation','folder-detail'])el(id).hidden=true;student=null;conversation=null;folder=null;el('folder-job').value='';el('folder-selected').textContent='Choose a split from course work above.';el('folder-add-button').disabled=true; + el('classroom-roster').hidden=!course.owner;el('folder-create').hidden=!course.owner;el('classroom-prompt').hidden=!course.owner;el('classroom-prompt').href=`/teacher.html?course=${encodeURIComponent(course.classId)}`; + status('Loading…');await Promise.all([folders(),course.owner?roster():Promise.resolve()]);status('Course access checked.'); +} +el('classroom-course').addEventListener('change',()=>run(el('classroom-refresh'),selectCourse)); +el('classroom-refresh').addEventListener('click',()=>run(el('classroom-refresh'),selectCourse)); +el('roster-more').addEventListener('click',()=>run(el('roster-more'),()=>roster(true))); +el('jobs-more').addEventListener('click',()=>run(el('jobs-more'),()=>loadJobs(student,true))); +el('conversation-more').addEventListener('click',()=>run(el('conversation-more'),()=>loadConversation(conversation.job,true))); +el('folder-create').addEventListener('submit',event=>{event.preventDefault();run(event.submitter,async()=>{const result=await request(path('/folders'),{method:'POST',body:JSON.stringify({name:el('folder-name').value})});el('folder-name').value='';await folders();await openFolder(result.folder.id);});}); +el('folder-add').addEventListener('submit',event=>{event.preventDefault();run(event.submitter,async()=>{await request(path(`/folders/${folder.id}/items`),{method:'POST',body:JSON.stringify({jobId:el('folder-job').value.trim()})});el('folder-job').value='';el('folder-selected').textContent='Choose a split from course work above.';el('folder-add-button').disabled=true;await openFolder(folder.id);});}); +el('folder-grant').addEventListener('click',()=>run(el('folder-grant'),async()=>{await request(path(`/folders/${folder.id}`),{method:'PUT',body:JSON.stringify({permission:el('folder-permission').value,revision:folder.revision})});el('folder-status').textContent='Sharing updated. Only current course members can open a course link.';await openFolder(folder.id);await folders();})); +el('folder-link').addEventListener('click',()=>run(el('folder-link'),async()=>{await navigator.clipboard.writeText(`${location.origin}/classroom.html?course=${encodeURIComponent(course.classId)}&folder=${encodeURIComponent(folder.id)}`);el('folder-status').textContent='Course link copied. Sign-in and current enrollment are required.';})); +window.addEventListener('pageshow',event=>{if(event.persisted)location.reload();}); +(async()=>{try{const {account}=await request('/api/account');window.StemSessionGuard?.start(account.subject,()=>{generation++;courses=[];course=null;student=null;conversation=null;folder=null;});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 c of courses)el('classroom-course').add(new Option(`${c.className} · ${c.term} · ${c.section}`,c.classId));const query=new URLSearchParams(location.search);if(courses.some(c=>c.classId===query.get('course')))el('classroom-course').value=query.get('course');el('classroom-content').hidden=false;await selectCourse();if(query.get('folder'))await openFolder(query.get('folder'));if(query.get('job')){const job=await request(`/api/jobs/${encodeURIComponent(query.get('job'))}`);if(job.courseId===course.classId)chooseSplit(job);}}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..d7da3ad --- /dev/null +++ b/public/session-guard.js @@ -0,0 +1,28 @@ +/* A page belongs to one verified account. Never carry its visible drafts or + * private data through sign-out, a second sign-in, or a restored browser page. */ +(() => { + let subject=null, clearing=false, checking=null, cleanup=()=>{}; + function clear() { + if(clearing)return;clearing=true; + cleanup(); + for(const audio of document.querySelectorAll('audio'))audio.pause(); + const main=document.querySelector('main'); + if(main){const message=document.createElement('p');message.setAttribute('role','status');message.textContent='Your account changed. Reloading your workspace…';main.replaceChildren(message);} + 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; + checking=(async()=>{try{const response=observe(await fetch('/api/account',{cache:'no-store',credentials:'same-origin'}));if([401,403].includes(response.status)){clear();return;}if(response.ok&&(await response.json()).account?.subject!==subject)clear();}catch{/* An outage does not change an identity. */}finally{checking=null;}})(); + return checking; + } + window.StemSessionGuard={start(value,onClear){subject=value;cleanup=onClear||cleanup;},observe,check,clear}; + window.addEventListener('focus',()=>void check()); + window.addEventListener('pageshow',event=>{if(event.persisted&&subject)clear();}); + document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')void check();}); +})(); diff --git a/public/styles.css b/public/styles.css index 1b1409f..c44c770 100644 --- a/public/styles.css +++ b/public/styles.css @@ -2745,3 +2745,67 @@ 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; } +.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..0ae7276 100644 --- a/public/teacher.html +++ b/public/teacher.html @@ -2,11 +2,12 @@ Stem Splitter · Instructor - +
+
@@ -30,6 +31,7 @@

SIGN IN

+