From 95cc8966021de2f41e4680289eeffda6a670365b Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 9 Aug 2026 03:35:08 +0000 Subject: [PATCH 1/4] fix(syncdeck): support LMS iframe waiting handoffs --- .agent/knowledge/data-contracts.md | 9 +++++++++ DEPLOYMENT.md | 5 +++++ activities/syncdeck/server/learnIntegration.test.ts | 8 ++++++++ activities/syncdeck/server/learnIntegration.ts | 11 ++++++++++- 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.agent/knowledge/data-contracts.md b/.agent/knowledge/data-contracts.md index b006d1d4..5e0a8d7f 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 an 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; full third-party-cookie enforcement is covered by production HTTPS deployment. +- Owner: Codex + ## Generic session-store `linkedSessionId` keepalive contract - Date: 2026-08-08 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 05d5d113..4986471c 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -134,6 +134,11 @@ 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..cf076696 100644 --- a/activities/syncdeck/server/learnIntegration.test.ts +++ b/activities/syncdeck/server/learnIntegration.test.ts @@ -129,12 +129,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.') @@ -263,6 +265,10 @@ void test('Learn routes transition a one-time waiting-room entry into an active assert.equal(waitLaunchResponse.headers['Referrer-Policy'], 'no-referrer') const waitCookie = waitLaunchResponse.cookies.find((item) => item.name === 'learn_syncdeck_wait') 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 +767,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..5ecd840b 100644 --- a/activities/syncdeck/server/learnIntegration.ts +++ b/activities/syncdeck/server/learnIntegration.ts @@ -960,7 +960,16 @@ 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), { + 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` }) }) From 4bdd6f8b625bdfdca17d3783479a2b7d63474ccb Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 9 Aug 2026 03:45:16 +0000 Subject: [PATCH 2/4] test(syncdeck): cover local waiting handoff cookie --- .../syncdeck/server/learnIntegration.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/activities/syncdeck/server/learnIntegration.test.ts b/activities/syncdeck/server/learnIntegration.test.ts index cf076696..8fc38555 100644 --- a/activities/syncdeck/server/learnIntegration.test.ts +++ b/activities/syncdeck/server/learnIntegration.test.ts @@ -110,6 +110,55 @@ 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.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' From fd37fdead3604061f1f64df3b1df30953dd26de9 Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 9 Aug 2026 03:55:33 +0000 Subject: [PATCH 3/4] docs(syncdeck): clarify iframe cookie validation --- .agent/knowledge/data-contracts.md | 2 +- DEPLOYMENT.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.agent/knowledge/data-contracts.md b/.agent/knowledge/data-contracts.md index 5e0a8d7f..17de3906 100644 --- a/.agent/knowledge/data-contracts.md +++ b/.agent/knowledge/data-contracts.md @@ -724,7 +724,7 @@ Document API and data-shape assumptions that must stay compatible over time. - Surface: Learn student waiting-room browser handoff - Contract: The one-time Learn `waitingLaunchUrl` is consumed in the student's browser and establishes an 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; full third-party-cookie enforcement is covered by production HTTPS deployment. +- 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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4986471c..d0fde5ad 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -134,8 +134,7 @@ 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 +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. From a606d4383074f6441b3f2744c6800061a26dee2e Mon Sep 17 00:00:00 2001 From: Brian Dahlem Date: Sun, 9 Aug 2026 04:07:22 +0000 Subject: [PATCH 4/4] fix(syncdeck): bound waiting handoff cookie --- .agent/knowledge/data-contracts.md | 2 +- activities/syncdeck/server/learnIntegration.test.ts | 2 ++ activities/syncdeck/server/learnIntegration.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.agent/knowledge/data-contracts.md b/.agent/knowledge/data-contracts.md index 17de3906..546d7abf 100644 --- a/.agent/knowledge/data-contracts.md +++ b/.agent/knowledge/data-contracts.md @@ -722,7 +722,7 @@ Document API and data-shape assumptions that must stay compatible over time. - 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 an 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. +- 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 diff --git a/activities/syncdeck/server/learnIntegration.test.ts b/activities/syncdeck/server/learnIntegration.test.ts index 8fc38555..5e3dfedc 100644 --- a/activities/syncdeck/server/learnIntegration.test.ts +++ b/activities/syncdeck/server/learnIntegration.test.ts @@ -146,6 +146,7 @@ void test('Learn waiting-room handoff retains local cookie attributes outside pr 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) @@ -313,6 +314,7 @@ 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) diff --git a/activities/syncdeck/server/learnIntegration.ts b/activities/syncdeck/server/learnIntegration.ts index 5ecd840b..e89c7aa8 100644 --- a/activities/syncdeck/server/learnIntegration.ts +++ b/activities/syncdeck/server/learnIntegration.ts @@ -964,6 +964,7 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): // 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',