diff --git a/.agent/knowledge/data-contracts.md b/.agent/knowledge/data-contracts.md index b006d1d4..546d7abf 100644 --- a/.agent/knowledge/data-contracts.md +++ b/.agent/knowledge/data-contracts.md @@ -718,6 +718,15 @@ Document API and data-shape assumptions that must stay compatible over time. - Follow-up action: Once Learn ships its matching fingerprint implementation, verify a sample of correlated `provider`/`resourceLinkId` values produce identical fingerprints across both systems' logs before relying on this for incident tracing. - Owner: Codex +## Learn SyncDeck iframe waiting-room handoff + +- Date: 2026-08-09 +- Surface: Learn student waiting-room browser handoff +- Contract: The one-time Learn `waitingLaunchUrl` is consumed in the student's browser and establishes a 10-minute httpOnly `learn_syncdeck_wait` cookie. In production it uses `Secure; SameSite=None; Partitioned` so an LMS-hosted ActiveBits iframe can send the handoff cookie on its same-origin `/wait/status` poll and receive the active student-session URL without a global third-party cookie. +- Compatibility constraints: Production requires HTTPS. Development retains `SameSite=Lax` without `Secure` or `Partitioned` for local HTTP test environments. +- Validation rules: The server integration test consumes the one-time URL, asserts production cookie attributes, and verifies that the cookie resolves an active mapping. The existing Playwright waiting-room test verifies the browser transitions to an active student-session URL after a successful status poll. Validate third-party-cookie behavior in a real LMS cross-site iframe before relying on the production integration. +- Owner: Codex + ## Generic session-store `linkedSessionId` keepalive contract - Date: 2026-08-08 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 05d5d113..d0fde5ad 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -134,6 +134,10 @@ Learn substitute-instructor capability URLs are signed bearer links. Configure r proxy and access logging to redact their query strings, and use bounded expiration when issuing them from Learn. +Learn student waiting-room launches embedded in an LMS use a `Secure; SameSite=None; Partitioned` httpOnly handoff cookie so the ActiveBits iframe can poll its same-origin +waiting-room status endpoint without requiring a global third-party cookie. Production +must therefore use HTTPS. + ## Source Map Policy (Open-Source Repo) ActiveBits intentionally ships source maps in production for debugging and teaching transparency. diff --git a/activities/syncdeck/server/learnIntegration.test.ts b/activities/syncdeck/server/learnIntegration.test.ts index c4c6fc52..5e3dfedc 100644 --- a/activities/syncdeck/server/learnIntegration.test.ts +++ b/activities/syncdeck/server/learnIntegration.test.ts @@ -110,6 +110,56 @@ void test('buildLearnHmacCanonicalRequest orders object keys by codepoint', () = assert.equal(request, `POST\n/path\n123\nnonce\nprovider\n${expectedHash}`) }) +void test('Learn waiting-room handoff retains local cookie attributes outside production', async () => { + const previousSecret = process.env.LEARN_SYNCDECK_HMAC_SECRET + const previousKeyId = process.env.LEARN_SYNCDECK_HMAC_KEY_ID + const previousNodeEnv = process.env.NODE_ENV + process.env.LEARN_SYNCDECK_HMAC_SECRET = 'a test-only Learn integration secret that is long enough' + process.env.LEARN_SYNCDECK_HMAC_KEY_ID = 'test-key' + process.env.NODE_ENV = 'development' + + try { + const getHandlers = new Map() + const postHandlers = new Map() + registerLearnSyncDeckRoutes({ + app: { + get(path, handler) { getHandlers.set(path, handler) }, + post(path, handler) { postHandlers.set(path, handler) }, + }, + sessions: store([]), + ws: { wss: { clients: new Set(), close() {} }, register() {} }, + async createInstructorSession() { throw new Error('not used by the waiting-room handoff test') }, + writeInstructorRecoveryCookie() {}, + }) + + const resourceLinkId = 'learn-resource-non-production-cookie' + const entryPath = `/api/integrations/learn/v1/activities/syncdeck/resources/${resourceLinkId}/student-entry` + const entryResponse = response() + await postHandlers.get('/api/integrations/learn/v1/activities/:activityId/resources/:resourceLinkId/student-entry')!( + { params: { activityId: 'syncdeck', resourceLinkId }, ...signedRequest('POST', entryPath, {}, 'non-production-cookie-entry') }, + entryResponse, + ) + const launchUrl = new URL(String((entryResponse.body as { waitingLaunchUrl: string }).waitingLaunchUrl), 'https://bits.example') + const handoffResponse = response() + await getHandlers.get('/integrations/learn/:activityId/wait/:tokenId')!( + { params: { activityId: 'syncdeck', tokenId: launchUrl.pathname.split('/').at(-1) }, query: { token: launchUrl.searchParams.get('token') } }, + handoffResponse, + ) + const waitCookie = handoffResponse.cookies.find((item) => item.name === 'learn_syncdeck_wait') + assert.equal(waitCookie?.options.maxAge, 10 * 60 * 1000) + assert.equal(waitCookie?.options.sameSite, 'lax') + assert.equal(waitCookie?.options.secure, false) + assert.equal(waitCookie?.options.partitioned, false) + } finally { + if (previousSecret === undefined) delete process.env.LEARN_SYNCDECK_HMAC_SECRET + else process.env.LEARN_SYNCDECK_HMAC_SECRET = previousSecret + if (previousKeyId === undefined) delete process.env.LEARN_SYNCDECK_HMAC_KEY_ID + else process.env.LEARN_SYNCDECK_HMAC_KEY_ID = previousKeyId + if (previousNodeEnv === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = previousNodeEnv + } +}) + void test('Learn identity fingerprints match the documented cross-system vector', () => { const secret = 'example-shared-learn-syncdeck-hmac-secret' const provider = 'learn-district-42' @@ -129,12 +179,14 @@ void test('Learn identity fingerprints match the documented cross-system vector' void test('Learn routes transition a one-time waiting-room entry into an active SyncDeck session', async () => { const previousSecret = process.env.LEARN_SYNCDECK_HMAC_SECRET const previousKeyId = process.env.LEARN_SYNCDECK_HMAC_KEY_ID + const previousNodeEnv = process.env.NODE_ENV const previousInfo = console.info const previousError = console.error const infoLogs: string[] = [] const errorLogs: string[] = [] process.env.LEARN_SYNCDECK_HMAC_SECRET = 'a test-only Learn integration secret that is long enough' process.env.LEARN_SYNCDECK_HMAC_KEY_ID = 'test-key' + process.env.NODE_ENV = 'production' console.info = (...args: unknown[]) => { infoLogs.push(args.map(String).join(' ')) } console.error = (...args: unknown[]) => { errorLogs.push(args.map(String).join(' ')) } previousInfo('[TEST] Expected Learn integration failure logs are captured by this test.') @@ -262,7 +314,12 @@ void test('Learn routes transition a one-time waiting-room entry into an active assert.equal(waitLaunchResponse.redirectTo, '/integrations/learn/syncdeck/wait') assert.equal(waitLaunchResponse.headers['Referrer-Policy'], 'no-referrer') const waitCookie = waitLaunchResponse.cookies.find((item) => item.name === 'learn_syncdeck_wait') + assert.equal(waitCookie?.options.maxAge, 10 * 60 * 1000) assert.equal(waitCookie?.options.path, '/') + assert.equal(waitCookie?.options.sameSite, 'none') + assert.equal(waitCookie?.options.secure, true) + assert.equal(waitCookie?.options.partitioned, true) + assert.equal(waitCookie?.options.httpOnly, true) const waitingCookie = waitCookie?.value assert.ok(waitingCookie) @@ -761,5 +818,7 @@ void test('Learn routes transition a one-time waiting-room entry into an active else process.env.LEARN_SYNCDECK_HMAC_SECRET = previousSecret if (previousKeyId === undefined) delete process.env.LEARN_SYNCDECK_HMAC_KEY_ID else process.env.LEARN_SYNCDECK_HMAC_KEY_ID = previousKeyId + if (previousNodeEnv === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = previousNodeEnv } }) diff --git a/activities/syncdeck/server/learnIntegration.ts b/activities/syncdeck/server/learnIntegration.ts index d240560c..e89c7aa8 100644 --- a/activities/syncdeck/server/learnIntegration.ts +++ b/activities/syncdeck/server/learnIntegration.ts @@ -960,7 +960,17 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): logLearnRequestFailure('waiting-room-launch', 'invalid-or-expired-browser-launch', { status: 403 }) return void res.status(403).json({ error: 'Invalid or expired waiting-room launch' }) } - res.cookie?.('learn_syncdeck_wait', cookieValue(key.secret, token.mappingId), { path: '/', httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }) + // Learn launches this route inside an LMS iframe. Production needs a Secure third-party + // cookie so the waiting page can send the signed mapping handoff to its same-origin status poll. + const isProduction = process.env.NODE_ENV === 'production' + res.cookie?.('learn_syncdeck_wait', cookieValue(key.secret, token.mappingId), { + maxAge: WAITING_TTL_MS, + path: '/', + httpOnly: true, + sameSite: isProduction ? 'none' : 'lax', + secure: isProduction, + partitioned: isProduction, + }) if (typeof res.redirect === 'function') return void res.redirect(302, `${BROWSER_PREFIX}/${ACTIVITY_ID}/wait`) res.status(302).json({ redirectTo: `${BROWSER_PREFIX}/${ACTIVITY_ID}/wait` }) })