Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .agent/knowledge/data-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions activities/syncdeck/server/learnIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RouteHandler>()
const postHandlers = new Map<string, RouteHandler>()
registerLearnSyncDeckRoutes({
app: {
get(path, handler) { getHandlers.set(path, handler) },
post(path, handler) { postHandlers.set(path, handler) },
},
sessions: store([]),
ws: { wss: { clients: new Set<ActiveBitsWebSocket>(), 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'
Expand All @@ -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'
Comment thread
mrbdahlem marked this conversation as resolved.
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.')
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
})
12 changes: 11 additions & 1 deletion activities/syncdeck/server/learnIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` })
})
Expand Down
Loading