diff --git a/.agent/knowledge/data-contracts.md b/.agent/knowledge/data-contracts.md index eb7086fb..b006d1d4 100644 --- a/.agent/knowledge/data-contracts.md +++ b/.agent/knowledge/data-contracts.md @@ -706,3 +706,60 @@ Document API and data-shape assumptions that must stay compatible over time. - Evidence (schema/tests/path): `activities/mobcode/shared/types.ts`; `activities/mobcode/server/routes.ts`; `activities/mobcode/server/routes.test.ts`; `activities/mobcode/client/manager/MobCodeManager.tsx`; `activities/mobcode/client/student/MobCodeStudent.tsx` - Follow-up action: Keep any new student-visible payload derived from the published instructor version and participant-scoped workspace snapshot. - Owner: Codex + +## Learn SyncDeck identity-fingerprint cross-system correlation contract + +- Date: 2026-08-07 +- Surface: internal module | Learn SyncDeck integration logging +- Contract: ActiveBits' only session identity is `mappingId = HMAC-SHA256(sharedSecret, "syncdeck\n\n")` (`mappingId()` in `activities/syncdeck/server/learnIntegration.ts`) — there is no `context_id` or "slot" concept on the ActiveBits side; any such composition happens upstream, in whatever `provider`/`resourceLinkId` Learn sends per request. To let Learn and ActiveBits correlate requests without either side logging raw identity values, ActiveBits computes three **non-reversible** log fingerprints using the same shared HMAC secret used for Learn request signing: `providerFingerprint = HMAC-SHA256(sharedSecret, "learn-provider|" + provider).hex.slice(0,16)`, `resourceLinkFingerprint = HMAC-SHA256(sharedSecret, "learn-resource-link|" + resourceLinkId).hex.slice(0,16)`, `mappingFingerprint = HMAC-SHA256(sharedSecret, "learn-mapping|" + mappingId).hex.slice(0,16)` (see `identityFingerprint`/`identityFingerprints` in `learnIntegration.ts`; the HMAC input is `parts.join('|')` over `[domainTag, value]`). A `sessionFingerprint` for the internal SyncDeck session id uses the domain tag `"learn-session"` the same way. Learn must use the identical domain-separation strings, `|`-joined two-part HMAC input, secret, and 16-hex-char truncation to produce fingerprints that match ActiveBits' logs byte-for-byte for the same underlying value. +- Compatibility constraints: These are log-correlation identifiers only, never returned in any API response and never used for authorization or session lookup (`mappingId` remains the actual lookup key). Changing the domain-separation tags, the join character (`|`), the HMAC algorithm, or the truncation length breaks correlation with Learn's matching implementation and with historical logs — treat this as a cross-system contract, not an internal implementation detail, and coordinate any change with the Learn team. +- Validation rules: `status`, `student-entry`, `start`, and `stop` each emit a `learn-identity-resolved` log line (or, for the in-flight `start` retry case, a schema-compatible `learn-instructor-session-start-pending` line) with `operation`, `state`, `reused` (where applicable), the three identity fingerprints, and `sessionFingerprint`. Pending starts always emit `operation: "start"`, `reused: false`, and `sessionFingerprint: null`. None of these lines include raw `provider`, `resourceLinkId`, the internal mapping id, internal session ids, handoff/launch URLs, or browser tokens. +- Evidence (schema/tests/path): `activities/syncdeck/server/learnIntegration.ts`; `activities/syncdeck/server/learnIntegration.test.ts`; `.agent/knowledge/security-notes.md` (2026-08-07 entry). +- 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 + +## Generic session-store `linkedSessionId` keepalive contract + +- Date: 2026-08-08 +- Surface: internal module | `server/core/sessions.ts` (`SessionStore.touch`) +- Contract: Any session record may declare `data.linkedSessionId: string` pointing at another record in the same store. `SessionStore.touch(id)` — for both `InMemorySessionStore` and the Valkey-wrapped store returned by `createSessionStore()` — checks the touched record's `data.linkedSessionId` and, when present and not self-referential, directly refreshes that linked record without inspecting its own link (single hop, not recursive/multi-hop; mirrors the existing `embeddedParentSessionId` propagation already used by `get`/`consumeSessionDataToken`/`refreshSessionExpiry`, but that field is intentionally kept separate and is not itself propagated by `touch`). Any activity-agnostic caller that already touches session A on real activity (e.g. `server/core/wsRouter.ts`'s 30s websocket ping/pong/message loop, which calls `sessions.touch(ws.sessionId)`) will therefore also keep session B alive for as long as A has a connected client, with no changes needed to `wsRouter.ts` or to whatever drives A's own touches. +- Compatibility constraints: This is a generic store-level primitive, not owned by any one activity — the field name and propagation behavior must stay activity-agnostic per the Activity Containment Policy. First consumer: `activities/syncdeck/server/learnIntegration.ts` stamps `data.linkedSessionId` on a live SyncDeck session (`type: 'syncdeck'`) with its Learn entry-mapping id (`type: 'syncdeck-learn-entry'`) via `linkLiveSessionToEntry()`, so the entry's lifetime tracks the session's actual connectivity instead of depending solely on Learn re-polling `status`/`start`. Any future activity needing "this record should stay alive exactly as long as that other record has real activity" should reuse this field rather than adding a new one. +- Validation rules: `touch()` only propagates one hop and only when `linkedSessionId !== id`; the direct-refresh helper never follows the linked record's own link, so chains stop after one hop and two-record cycles complete safely. Only `touch()` propagates `linkedSessionId` — `get()`/`refreshSessionExpiry()`/`consumeSessionDataToken()` do not, since reading a session (as opposed to genuine touch/keepalive activity) is not the right signal for "keep the linked record alive." +- Evidence (schema/tests/path): `server/core/sessions.ts` (`getLinkedSessionId`, `InMemorySessionStore.touch`, the Valkey-wrapped `touch` closure); `server/sessionStore.test.ts`; `activities/syncdeck/server/learnIntegration.ts` (`linkLiveSessionToEntry`); `activities/syncdeck/server/learnIntegration.test.ts`; `.agent/knowledge/security-notes.md` (2026-08-08 entry). +- Follow-up action: If a second activity adopts `linkedSessionId`, consider whether single-hop propagation is still sufficient before extending it to chains. +- Owner: Codex + +## Learn active-entry lifetime and fingerprint regression vector + +- Date: 2026-08-08 +- Contract: A Learn entry in `waiting` state is bounded by `data.expiresAt`; once `active`, its backing session-store TTL is the lifetime authority and is refreshed through the live session's `linkedSessionId`. Active-entry reads must not reject the mapping solely because its historical `data.expiresAt` has passed. +- Observability: Learn lifecycle logs use the documented HMAC fingerprints for provider, resource, mapping, and session identity. The exact shared test vector in `.agent/plans/learn-syncdeck-session-integration.md` is asserted in `activities/syncdeck/server/learnIntegration.test.ts`; change it only through a coordinated cross-system migration. +- Owner: Codex + +## Activity normalizers preserve generic keepalive links + +- Date: 2026-08-08 +- Contract: An activity normalizer that reconstructs `session.data` must retain a valid generic `data.linkedSessionId`. SyncDeck's normalizer preserves the trimmed non-empty string so its Learn live session can keep the linked entry mapping alive. +- Validation: `server/sessionStore.test.ts` registers the real SyncDeck normalizer, writes a `syncdeck` record through `createSessionStore()`, and verifies both persistence and linked touch propagation. +- Owner: Codex + +## Learn live-session link lifecycle + +- Date: 2026-08-08 +- Contract: A Learn-created SyncDeck session carries `linkedSessionId` only while its entry mapping is active. Learn stop and either instructor-activation rollback clear the link, but only if it still matches that mapping, so a stale socket cannot refresh a recreated entry mapping. +- Validation: `activities/syncdeck/server/learnIntegration.test.ts` verifies the link is absent from the live session after a Learn stop. +- Owner: Codex + +## Cross-instance linked-session invalidation + +- Date: 2026-08-08 +- Contract: In the Valkey-backed store, a cached source record is re-read from Valkey at most once per five seconds before its `linkedSessionId` is propagated. This makes a remote stop/unlink authoritative within that bounded interval without a Valkey read for every websocket touch. +- Validation: `server/sessionStore.test.ts` uses two independent wrapped stores over one fake Valkey record map and verifies the second store does not refresh a target after the first store removes the link, while an immediate subsequent touch performs no extra read. +- Owner: Codex + +## Learn lifecycle failure logging + +- Date: 2026-08-08 +- Contract: Instructor-start lifecycle errors use an allowlisted schema of event, operation, requestId, stable errorCode, identity fingerprints, and `sessionFingerprint`; error messages and arbitrary context values are excluded. Error logs never include raw Learn resource identifiers or internal session IDs. +- Validation: `activities/syncdeck/server/learnIntegration.test.ts` scans both captured info and error lifecycle logs, including a forced instructor-start failure containing sentinel resource, session, URL, and token values. +- Owner: Codex diff --git a/.agent/knowledge/security-notes.md b/.agent/knowledge/security-notes.md index 5893b4a4..115be0e2 100644 --- a/.agent/knowledge/security-notes.md +++ b/.agent/knowledge/security-notes.md @@ -31,6 +31,101 @@ Track security-relevant boundaries, risks, and mitigation decisions. - Follow-up action: Use a short class/day expiry from Learn when generating links. - Owner: Codex +- Date: 2026-08-07 +- Area: Learn SyncDeck identity-fingerprint logging (session-split investigation) +- Threat or risk: A session-split incident (instructor editor showed an active session/join + code; a student's launch with the same intended placement was handed off to a waiting + room; the instructor's later reopen landed in a separate session the student had started) + could not be traced after the fact, because `status`/`student-entry` logged nothing on + success and no log line captured `provider`, `resourceLinkId`, or the computed mapping id + needed to tell whether the three requests resolved to the same internal identity. +- Control or mitigation: Added `learn-identity-resolved` log lines (and inline fingerprint + fields on the existing `learn-instructor-session-start-pending` log) to `status`, + `student-entry`, `start`, and `stop`. Each line records `operation`, resolved `state`, + `reused` (for `student-entry`/`start`), and three **non-reversible** fingerprints — + `providerFingerprint`, `resourceLinkFingerprint`, `mappingFingerprint` — plus a + `sessionFingerprint` for the internal SyncDeck session, all derived via a domain-separated + HMAC-SHA256 (truncated to 16 hex chars) keyed by the same shared Learn HMAC secret used for + request signing. Raw `provider`, `resourceLinkId`, the internal mapping id, and internal + session ids are never included in these lines; handoff/launch URLs and browser tokens are + never logged. See `identityFingerprint`/`identityFingerprints`/`logIdentityResolution` in + `activities/syncdeck/server/learnIntegration.ts`. +- Residual risk: Fingerprints are stable only as long as the shared HMAC secret is unchanged; + rotating the secret makes historical and post-rotation fingerprints for the same identity + incomparable (expected — same tradeoff as `mappingId` itself). Fingerprints reveal identity + *equality/inequality* across log lines, not the underlying values, by design. +- Validation (test/review/path): `activities/syncdeck/server/learnIntegration.test.ts` (asserts + fingerprint stability across `student-entry`/`start`/`status`/`stop` calls for the same + resource, divergence for a different `resourceLinkId`, and that `learn-identity-resolved` + lines never contain the raw resourceLinkId, session id, or handoff/token material). +- Follow-up action: Learn is adding a matching fingerprint scheme on their side (see the + `learn-provider`/`learn-resource-link`/`learn-mapping` domain-separation contract in + `.agent/knowledge/data-contracts.md`) so calls can be correlated across both systems without + either side logging raw identity values. If a future incident needs finer-grained tracing, + extend `logIdentityResolution` call sites rather than logging raw values. +- Owner: Codex + +- Date: 2026-08-08 +- Area: Learn SyncDeck entry-mapping TTL (session-split root cause) +- Threat or risk: Root-caused the session-split class from the entry above to two compounding + bugs, neither requiring any `provider`/`resourceLinkId` mismatch: (1) the active-entry TTL + fallback `sessions.ttlMs ?? WAITING_TTL_MS` in `learnIntegration.ts` silently resolved to the + 10-minute `WAITING_TTL_MS` in production, because the Valkey-backed store returned by + `createSessionStore()` never exposes a top-level `ttlMs` (only `InMemorySessionStore`, used + without `VALKEY_URL`, has one; `server/routes/statusRoute.ts` already had to work around this + same gap). (2) Even at the intended ~1h value, the entry's expiry was refreshed only by Learn + REST calls (`status`/`student-entry`/`start`/`stop` -> `loadEntry`), never by the live + SyncDeck session's own websocket activity — so an entry could silently expire out of the store + mid-class while the session it pointed at was genuinely live and busy (confirmed against an + incident where an embedded videosync child had just run under the "orphaned" session). Once + the entry was gone, the next student launch created a fresh waiting entry under the same + identity, and the instructor's next `start`/reopen created a brand-new session under that + entry — splitting the class exactly as observed, entirely independent of the identity-mismatch + hypothesis in the entry above. +- Control or mitigation: (1) Added `resolveActiveEntryTtlMs()` in `learnIntegration.ts`, which + checks `sessions.ttlMs`, then `sessions.valkeyStore?.ttlMs`, before falling back to + `WAITING_TTL_MS`, replacing all three `sessions.ttlMs ?? WAITING_TTL_MS` call sites (`start`'s + create branch, the substitute-instructor-link create branch, and `loadEntry`'s refresh calc). + (2) Added a generic, activity-agnostic `linkedSessionId` convention to `server/core/sessions.ts` + (parallel to the existing `embeddedParentSessionId` pattern): any session may declare + `data.linkedSessionId`, and `SessionStore.touch()` (both `InMemorySessionStore` and the + Valkey-wrapped store) propagates a touch to that linked record. `learnIntegration.ts` now + stamps the live SyncDeck session with `data.linkedSessionId = ` via + `linkLiveSessionToEntry()` whenever `start` or the substitute-link route creates a session. + Since `wsRouter.ts`'s existing 30s ping loop already calls `sessions.touch(ws.sessionId)` on + any connected instructor/student/embedded-child socket, the entry now stays alive for as long + as anyone is actually connected to the class session, independent of whether Learn ever polls + `status` again. +- Residual risk: Sessions created before this change lack `linkedSessionId` and won't benefit + from propagation until they naturally end and a new one is created (no migration needed — the + old poll-driven refresh path still applies to them). The entry still has no live-session + connection during the window between `start` creating it and the first client connecting; the + TTL value fix (1) covers that window, not propagation (2). +- Validation (test/review/path): `server/sessionStore.test.ts` (`linkedSessionId` touch + propagation, and an entry surviving past its own ttl purely via a linked session being + touched, with a negative control); `activities/syncdeck/server/learnIntegration.test.ts` + (`linkedSessionId` stamped on the live session after `start`; active-entry `expiresAt` + resolves from `valkeyStore.ttlMs` when the wrapped store's top-level `ttlMs` is undefined). +- Follow-up action: If a future activity needs the same "entry mapping tied to a live session's + actual connectivity" shape, reuse the `linkedSessionId` convention rather than inventing a new + one — see the generic contract in `.agent/knowledge/data-contracts.md`. +- Owner: Codex + +## Learn pending-start logging and linked-session safety + +- Date: 2026-08-08 +- Finding: The `learn-instructor-session-start-pending` event previously included raw `resourceLinkId`, despite the fingerprint-only logging contract. The linked-session helper also needed to avoid recursive traversal so malformed `A -> B -> C` and `A <-> B` links cannot refresh beyond one hop or recurse indefinitely. +- Resolution: The pending event now retains `requestId`, state, and identity fingerprints but omits the raw resource identifier. Both in-memory and Valkey-backed session stores use a non-propagating direct refresh for `linkedSessionId` targets. +- Validation: `activities/syncdeck/server/learnIntegration.test.ts` parses the pending event and verifies the raw field is absent; `server/sessionStore.test.ts` covers chain and two-record-cycle behavior plus a widened expiry margin. +- Owner: Codex + +## Learn active-entry expiry and lifecycle logging + +- Date: 2026-08-08 +- Contract: Waiting Learn entries retain their bounded `data.expiresAt` gate. Active entries rely on the underlying session-store TTL, refreshed through the single-hop live-session link, so a stale logical timestamp cannot split an active class from its mapping. +- Privacy: Learn lifecycle audit events use provider/resource/mapping/session fingerprints rather than raw identifiers. Exact tests lock the cross-system HMAC vector from `.agent/plans/learn-syncdeck-session-integration.md`. +- Owner: Codex + - Date: 2026-07-15 - Area: waiting-room student display-name persistence - Threat or risk: Remembering a student's lobby name across days in browser persistence could inadvertently expand into storing participant IDs, credentials, or activity-specific form data. diff --git a/.agent/knowledge/testing-patterns.md b/.agent/knowledge/testing-patterns.md index 09fddc8b..585eb62e 100644 --- a/.agent/knowledge/testing-patterns.md +++ b/.agent/knowledge/testing-patterns.md @@ -24,6 +24,21 @@ Capture reusable test setup patterns, common failure modes, and reliability guid - Follow-up action: Keep the terminal-running assertion as the popup readiness check and use the longer timeout only for actual execution completion. - Owner: Codex +## Valkey session-wrapper coverage without an external service + +- Date: 2026-08-08 +- Pattern: Pass a minimal in-memory `ValkeySessionStore` test double as the optional third argument to `createSessionStore()` when a test must execute the real cache/wrapped-store branch. Assert through its record map and touch call list after `flushCache()` for cached touches, and immediately for uncached touches. +- Why it helps: This exercises production cache/direct-touch sequencing without requiring a running Valkey instance or opening a network connection in unit tests. +- Evidence: `server/sessionStore.test.ts`. +- Owner: Codex + +## Session-store selection logging + +- Date: 2026-08-08 +- Pattern: Treat session-store startup selection as a structured server event, and capture `console.info` in a focused unit test to assert its stable discriminator fields for both in-memory and Valkey-backed modes. +- Evidence: `server/core/sessions.ts`; `server/sessionStore.test.ts`. +- Owner: Codex + - Date: 2026-07-27 - Scope: typecheck | activities - Pattern: Run the activities TypeScript check one activity directory at a time through `scripts/typecheck-activities.mjs`. The runner reuses `activities/tsconfig.json` compiler options and shared contracts, but narrows source inclusion to the current activity. diff --git a/.agent/plans/learn-syncdeck-session-integration.md b/.agent/plans/learn-syncdeck-session-integration.md index 8fe14edb..42f9b4e2 100644 --- a/.agent/plans/learn-syncdeck-session-integration.md +++ b/.agent/plans/learn-syncdeck-session-integration.md @@ -492,6 +492,102 @@ ID. Expired or consumed tokens must fail closed with a friendly re-launch instru --- +## Identity-Fingerprint Correlation Logging + +Both systems independently resolve identity on every `status`/`start`/`student-entry`/ +`stop` call from `provider` + `resourceLinkId`. Because neither side has visibility into +the other's resolution, a split (e.g. an instructor's status check and a student's +launch silently resolving to different sessions) can only be diagnosed after the fact if +both systems' logs can be correlated — without either system logging the raw `provider`, +`resourceLinkId`, internal mapping id, or internal session id anywhere. + +ActiveBits emits a `learn-identity-resolved` log line (event name) on every resolution +path of `status`, `student-entry`, `start`, and `stop`, and inline fingerprint fields on +the in-flight `learn-instructor-session-start-pending` log. Each line carries: + +- `operation`: `"status" | "student-entry" | "start" | "stop"` +- `state`: the resolved entry state (`"inactive" | "waiting" | "active" | "starting"`) +- `reused`: boolean, only present for `student-entry`/`start`. For `student-entry`, it + means an existing entry mapping was found rather than created. For `start`, it means + an already-active instructor session was reused; finding a waiting entry and creating + its live session still reports `false`. +- `providerFingerprint`, `resourceLinkFingerprint`, `mappingFingerprint`: non-reversible + 16-hex-character fingerprints (below) +- `sessionFingerprint`: same fingerprint scheme applied to the internal SyncDeck + session id, or `null` when there is no active session yet + +None of these lines ever contain the raw `provider`, `resourceLinkId`, the internal +mapping id, internal session ids, handoff/launch URLs, or browser tokens. + +### Fingerprint algorithm (implement identically on the Learn side) + +All four fingerprints are truncated HMAC-SHA256 digests keyed by the **same shared +`LEARN_SYNCDECK_HMAC_SECRET`** already used for request signing — Learn does not need a +new secret to implement this. + +```text +fingerprint(secret, domainTag, value) = + hex(HMAC-SHA256(secret, domainTag + "|" + value))[0:16] +``` + +- `providerFingerprint = fingerprint(secret, "learn-provider", provider)` +- `resourceLinkFingerprint = fingerprint(secret, "learn-resource-link", resourceLinkId)` +- `mappingFingerprint = fingerprint(secret, "learn-mapping", mappingId)`, where + `mappingId = "learn-syncdeck-entry-" + hex(HMAC-SHA256(secret, activityId + "\n" + provider + "\n" + resourceLinkId))[0:40]` + and `activityId` is always the literal string `"syncdeck"` +- `sessionFingerprint = fingerprint(secret, "learn-session", activeSessionId)`, where + `activeSessionId` is the `activeSessionId`/`joinCode` value ActiveBits already returns + in `status` and `start` responses + +Learn has every input needed to reproduce all four values exactly: `provider` and +`resourceLinkId` are whatever it sent on the request, `activityId` is fixed, and +`activeSessionId` is returned in the response body of `status`/`start`. Matching this +recipe exactly (domain-separation tag text, the `|` join character, UTF-8 encoding, +lower-case hex, and the 16-char / 40-char truncation lengths) is required — any +deviation silently breaks correlation instead of erroring. + +**Worked test vector** (verify your implementation against this before relying on it): + +| Input | Value | +| --- | --- | +| `secret` | `example-shared-learn-syncdeck-hmac-secret` | +| `activityId` | `syncdeck` | +| `provider` | `learn-district-42` | +| `resourceLinkId` | `course-101-unit-3-syncdeck` | +| `activeSessionId` | `a1b2c3d4e5` | + +| Output | Value | +| --- | --- | +| `mappingId` | `learn-syncdeck-entry-b9c5d450c1f196cebf05fbe6b034ae5b51b6b134` | +| `providerFingerprint` | `db808f2d2f402a98` | +| `resourceLinkFingerprint` | `3a45e4cde6b2ad4c` | +| `mappingFingerprint` | `33f001c81d0a7df5` | +| `sessionFingerprint` | `342b379d90c2f61d` | + +### Correlating a split + +Given logs from both systems for the same time window, compare `providerFingerprint` + +`resourceLinkFingerprint` across an instructor's `status`/`start` calls and a student's +`student-entry` call: + +- **Same fingerprints throughout, but the entry/session state diverges anyway** — not an + identity mismatch; look at ActiveBits' TTL/keepalive behavior for that mapping instead + (see the entry-mapping TTL and `linkedSessionId` keepalive notes in + `.agent/knowledge/security-notes.md` and `.agent/knowledge/data-contracts.md`). +- **Different fingerprints between the instructor's and student's calls** — Learn + computed a different `provider` or `resourceLinkId` for the two launches (e.g. the + instructor editor re-reading drifted/mutable launch placement across tabs). This is + the class of bug the editor's planned launch-token binding is meant to close. + +This is a cross-system contract: changing the domain-separation tags, the `|` join +character, the HMAC algorithm, or either truncation length on either side breaks +correlation with the other system's logs and with historical logs. Coordinate any +change here between both teams. See `.agent/knowledge/data-contracts.md` for the +ActiveBits-side implementation reference (`identityFingerprint`/`identityFingerprints`/ +`logIdentityResolution` in `activities/syncdeck/server/learnIntegration.ts`). + +--- + ## Learn Implementation Checklist (Pending) - [ ] Configure the dedicated request-HMAC key ID and secret in Learn's server-only @@ -531,6 +627,10 @@ ID. Expired or consumed tokens must fail closed with a friendly re-launch instru generate a new value only for a new instructor click. - [ ] Test concurrent instructor Start clicks, stale browser launch URLs, Stop after an external session end, and student launch during/after a start race. +- [ ] Implement the identity-fingerprint logging scheme (see "Identity-Fingerprint + Correlation Logging" above) on every `status`/`start`/`student-entry`/`stop` call, + using the shared `LEARN_SYNCDECK_HMAC_SECRET`. Verify against the worked test vector + before relying on it to correlate logs with ActiveBits. --- @@ -579,6 +679,15 @@ ID. Expired or consumed tokens must fail closed with a friendly re-launch instru waiting-room polling and redirect are covered using the shared root harness. - [x] Update `README.md`, `ARCHITECTURE.md`, `DEPLOYMENT.md`, data-contract notes, and any SyncDeck payload documentation affected by the final launch contract. +- [x] Emit the identity-fingerprint logging scheme (see "Identity-Fingerprint + Correlation Logging" above) on `status`/`student-entry`/`start`/`stop`, without + logging raw `provider`, `resourceLinkId`, internal mapping ids, internal session ids, + handoff/launch URLs, or browser tokens. Fixed the active-entry TTL fallback resolving + to 10 minutes instead of the configured session TTL on the Valkey-backed store, and + added `linkedSessionId` keepalive propagation so the entry mapping's lifetime tracks + the live session's actual websocket connectivity instead of depending solely on Learn + re-polling `status`/`start`. See `.agent/knowledge/security-notes.md` (2026-08-07 and + 2026-08-08 entries) and `.agent/knowledge/data-contracts.md`. --- diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 382170ed..6d0538e6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,6 +87,7 @@ For live MobCode sessions, the instructor workspace remains `groups.default`. Wh ### Session Lifecycle - **Temporary sessions**: Created on-demand, expire after inactivity +- **Linked-session keepalive**: A session may refresh one directly linked session on genuine activity; the relationship is deliberately single-hop to keep cycles safe. Activity session normalizers must preserve this generic field. An active Learn entry relies on this store-backed lifetime rather than a separate logical-expiry gate, and its link is cleared when that entry stops or activation rolls back. - **Persistent sessions**: Permanent URLs that create on-demand sessions and allow both teacher and student to enter - **Session termination**: Teacher can end any session, broadcasting to all connected students - **WebSocket notifications**: Students automatically redirected when session ends diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 7db85c05..05d5d113 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -24,6 +24,7 @@ ActiveBits supports two session storage modes: ### Components - **Session Store**: Temporary session data (1-hour TTL) +- **Session-store selection logging**: Startup emits a structured `session-store` / `store-selected` event identifying the in-memory or Valkey-backed mode; use this event when confirming deployed storage configuration. - **Persistent Metadata**: Waiting room state (10-minute TTL) - **WebSocket Keepalive Cache**: In-memory cache (30s TTL) for reducing Valkey traffic - **Pub/Sub Channels**: Cross-instance broadcasting for session events @@ -188,6 +189,7 @@ When scaling to multiple instances: 8. **Embedded child bootstrap payloads**: SyncDeck embedded launches now persist child-session bootstrap data under `session.data.embeddedLaunch.selectedOptions`. That session record must survive reloads and hot redeploys because embedded managers such as Video Sync rehydrate launch intent from the sanitized `GET /api/session/:childSessionId/embedded-launch` endpoint. In production, validate that this route remains available after deploys and returns only `{ embeddedLaunch: { selectedOptions } }`, not the raw session record. 9. **SyncDeck embedded-session keepalive coupling**: launched embedded child sessions are expected to stay alive while their parent SyncDeck session is still active, and child-session reads now refresh the parent too. In production, treat unexpected pruning of either side as a keepalive regression rather than as normal temporary-session expiry. 10. **Canonical persistent-link recovery**: Persistent manager recovery routes that return bootstrap data (for example Video Sync `persistentSourceUrl`) should source that data from canonical remembered permalink `selectedOptions` rather than from raw query params on redirected manage routes. +11. **Linked-session refresh scope**: `data.linkedSessionId` refreshes only the directly linked record. Do not depend on transitive refreshes; keeping the relationship single-hop prevents a malformed cycle from blocking a keepalive request. For active Learn entries, this store TTL is authoritative; do not introduce a second logical expiry that can reject a still-live mapping. In Valkey mode, source data is revalidated on a bounded five-second cadence before propagation, so a Stop handled on another instance is observed without turning every websocket event into a Valkey read. **To scale horizontally**: 1. Go to **Settings** → **Scaling** diff --git a/README.md b/README.md index 5523b177..80dda8d1 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,7 @@ contract and launch lifecycle. Learn can also issue a time-bounded signed substitute-instructor link that opens a SyncDeck instructor session directly in ActiveBits. It is a bearer capability: use a bounded expiry and do not expose it in logs or analytics. + +While a Learn-backed SyncDeck session has real activity, its entry mapping receives a +single-hop keepalive refresh. Active mappings use that session-store lifetime as the +authority; mapping chains are intentionally not followed. diff --git a/activities/syncdeck/server/learnIntegration.test.ts b/activities/syncdeck/server/learnIntegration.test.ts index 061f9fe3..c4c6fc52 100644 --- a/activities/syncdeck/server/learnIntegration.test.ts +++ b/activities/syncdeck/server/learnIntegration.test.ts @@ -3,7 +3,7 @@ import { createHash, createHmac } from 'node:crypto' import test from 'node:test' import type { SessionRecord, SessionStore } from 'activebits-server/core/sessions.js' import type { ActiveBitsWebSocket, WsRouter } from '../../../types/websocket.js' -import { buildLearnHmacCanonicalRequest, registerLearnSyncDeckRoutes } from './learnIntegration.js' +import { buildLearnHmacCanonicalRequest, identityFingerprint, identityFingerprints, mappingId, registerLearnSyncDeckRoutes } from './learnIntegration.js' interface MockResponse { statusCode: number @@ -110,6 +110,22 @@ void test('buildLearnHmacCanonicalRequest orders object keys by codepoint', () = assert.equal(request, `POST\n/path\n123\nnonce\nprovider\n${expectedHash}`) }) +void test('Learn identity fingerprints match the documented cross-system vector', () => { + const secret = 'example-shared-learn-syncdeck-hmac-secret' + const provider = 'learn-district-42' + const resourceLinkId = 'course-101-unit-3-syncdeck' + const sessionId = 'a1b2c3d4e5' + const entryMappingId = mappingId(secret, 'syncdeck', provider, resourceLinkId) + + assert.equal(entryMappingId, 'learn-syncdeck-entry-b9c5d450c1f196cebf05fbe6b034ae5b51b6b134') + assert.deepEqual(identityFingerprints(secret, provider, resourceLinkId, entryMappingId), { + providerFingerprint: 'db808f2d2f402a98', + resourceLinkFingerprint: '3a45e4cde6b2ad4c', + mappingFingerprint: '33f001c81d0a7df5', + }) + assert.equal(identityFingerprint(secret, 'learn-session', sessionId), '342b379d90c2f61d') +}) + 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 @@ -134,6 +150,7 @@ void test('Learn routes transition a one-time waiting-room entry into an active let delayedInstructorSession: Promise | null = null let notifyInstructorSessionStart: (() => void) | null = null let failInstructorSessionCreation = false + let instructorFailureMessage = 'test instructor-session creation failure' registerLearnSyncDeckRoutes({ app: { get(path, handler) { getHandlers.set(path, handler) }, @@ -145,7 +162,7 @@ void test('Learn routes transition a one-time waiting-room entry into an active instructorSessionCreateCount += 1 notifyInstructorSessionStart?.() if (delayedInstructorSession) await delayedInstructorSession - if (failInstructorSessionCreation) throw new Error('test instructor-session creation failure') + if (failInstructorSessionCreation) throw new Error(instructorFailureMessage) createdSessionId = 'syncdeck-live' await sessions.set(createdSessionId, { id: createdSessionId, @@ -209,6 +226,17 @@ void test('Learn routes transition a one-time waiting-room entry into an active const waitingLaunchUrl = String((entryResponse.body as { waitingLaunchUrl: string }).waitingLaunchUrl) const launchUrl = new URL(waitingLaunchUrl, 'https://bits.example') + const initialStudentEntryLog = JSON.parse( + infoLogs.find((message) => message.includes('"event":"learn-identity-resolved"') && message.includes('"operation":"student-entry"'))!, + ) as Record + assert.equal(initialStudentEntryLog.state, 'waiting') + assert.equal(initialStudentEntryLog.reused, false) + assert.equal(initialStudentEntryLog.sessionFingerprint, null) + assert.match(String(initialStudentEntryLog.providerFingerprint), /^[a-f0-9]{16}$/) + assert.match(String(initialStudentEntryLog.resourceLinkFingerprint), /^[a-f0-9]{16}$/) + assert.match(String(initialStudentEntryLog.mappingFingerprint), /^[a-f0-9]{16}$/) + const resourceMappingFingerprint = initialStudentEntryLog.mappingFingerprint + const invalidWaitLaunchResponse = response() await getHandlers.get('/integrations/learn/:activityId/wait/:tokenId')!( { params: { activityId: 'syncdeck', tokenId: launchUrl.pathname.split('/').at(-1) }, query: { token: 'invalid-token' } }, @@ -264,6 +292,49 @@ void test('Learn routes transition a one-time waiting-room entry into an active assert.equal(startResponse.statusCode, 200) assert.equal((startResponse.body as { activeSessionId?: unknown }).activeSessionId, createdSessionId) + const startIdentityLog = JSON.parse( + infoLogs.find((message) => message.includes('"event":"learn-identity-resolved"') && message.includes('"operation":"start"'))!, + ) as Record + assert.equal(startIdentityLog.state, 'active') + assert.equal(startIdentityLog.reused, false) + assert.equal(startIdentityLog.mappingFingerprint, resourceMappingFingerprint) + assert.match(String(startIdentityLog.sessionFingerprint), /^[a-f0-9]{16}$/) + const startedSessionFingerprint = startIdentityLog.sessionFingerprint + assert.ok(infoLogs.some((message) => message.includes('learn-instructor-session-start-pending') && message.includes(`"mappingFingerprint":"${resourceMappingFingerprint}"`))) + const pendingStartLog = JSON.parse( + infoLogs.find((message) => message.includes('"event":"learn-instructor-session-start-pending"'))!, + ) as Record + assert.equal(pendingStartLog.operation, 'start') + assert.equal(pendingStartLog.requestId, 'start-1') + assert.equal(pendingStartLog.state, 'starting') + assert.equal(pendingStartLog.sessionFingerprint, null) + assert.equal(pendingStartLog.reused, false) + assert.equal(pendingStartLog.resourceLinkId, undefined) + assert.match(String(pendingStartLog.resourceLinkFingerprint), /^[a-f0-9]{16}$/) + + const learnEntryIds = (await sessions.getAllIds()).filter((candidateId) => candidateId.startsWith('learn-syncdeck-entry-')) + let resourceEntryId: string | undefined + for (const candidateId of learnEntryIds) { + const candidate = await sessions.get(candidateId) + if ((candidate?.data as { resourceLinkId?: unknown })?.resourceLinkId === resourceId) { + resourceEntryId = candidateId + break + } + } + assert.ok(resourceEntryId, 'expected exactly one Learn entry mapping for resourceId') + const liveSessionRecord = await sessions.get(createdSessionId) + assert.equal( + (liveSessionRecord?.data as { linkedSessionId?: unknown })?.linkedSessionId, + resourceEntryId, + 'starting a Learn instructor session should stamp the live session with its entry mapping id so websocket keepalive refreshes the entry too', + ) + + const activeEntryWithExpiredLogicalTimestamp = await sessions.get(resourceEntryId) + assert.ok(activeEntryWithExpiredLogicalTimestamp) + activeEntryWithExpiredLogicalTimestamp.data.expiresAt = Date.now() - 1 + await sessions.set(resourceEntryId, activeEntryWithExpiredLogicalTimestamp) + await sessions.touch(createdSessionId) + ws.wss.clients.add({ readyState: 1, sessionId: createdSessionId, isInstructor: true } as unknown as ActiveBitsWebSocket) ws.wss.clients.add({ readyState: 1, sessionId: createdSessionId, studentId: 'student-1' } as unknown as ActiveBitsWebSocket) ws.wss.clients.add({ readyState: 1, sessionId: createdSessionId, studentId: 'student-1' } as unknown as ActiveBitsWebSocket) @@ -296,6 +367,13 @@ void test('Learn routes transition a one-time waiting-room entry into an active }, ) + const statusIdentityLog = JSON.parse( + infoLogs.find((message) => message.includes('"event":"learn-identity-resolved"') && message.includes('"operation":"status"') && message.includes('"state":"active"'))!, + ) as Record + assert.equal(statusIdentityLog.mappingFingerprint, resourceMappingFingerprint) + assert.equal(statusIdentityLog.sessionFingerprint, startedSessionFingerprint) + assert.equal(statusIdentityLog.reused, undefined) + const substituteLaunch = substituteInstructorLink(resourceId, 'https://slides.example/deck') const substituteLaunchResponse = response() await getHandlers.get('/api/syncdeck/learn/substitute')!( @@ -389,6 +467,10 @@ void test('Learn routes transition a one-time waiting-room entry into an active startingStudentEntryResponse, ) assert.equal((startingStudentEntryResponse.body as { state?: unknown }).state, 'waiting') + const otherResourceStudentEntryLogs = infoLogs.filter((message) => message.includes('"event":"learn-identity-resolved"') && message.includes('"operation":"student-entry"')) + const otherResourceIdentityLog = JSON.parse(otherResourceStudentEntryLogs.at(-1)!) as Record + assert.notEqual(otherResourceIdentityLog.mappingFingerprint, resourceMappingFingerprint) + assert.notEqual(otherResourceIdentityLog.resourceLinkFingerprint, initialStudentEntryLog.resourceLinkFingerprint) let releaseDelayedSubstituteStart!: () => void delayedInstructorSession = new Promise((resolve) => { releaseDelayedSubstituteStart = resolve }) let resolveSubstituteSessionStart!: () => void @@ -438,8 +520,28 @@ void test('Learn routes transition a one-time waiting-room entry into an active substituteCleanupFailureResponse, ) assert.equal(substituteCleanupFailureResponse.statusCode, 500) - assert.ok(errorLogs.some((message) => message.includes('learn-substitute-link-start-cleanup-failed') && message.includes('test substitute cleanup failure'))) - assert.ok(errorLogs.some((message) => message.includes('learn-substitute-link-start-failed') && message.includes('test instructor-session creation failure'))) + const substituteCleanupError = JSON.parse( + errorLogs.find((message) => message.includes('"event":"learn-substitute-link-start-cleanup-failed"'))!, + ) as Record + const substituteStartError = JSON.parse( + errorLogs.find((message) => message.includes('"event":"learn-substitute-link-start-failed"'))!, + ) as Record + for (const lifecycleError of [substituteCleanupError, substituteStartError]) { + assert.deepEqual(Object.keys(lifecycleError).sort(), [ + 'activity', + 'errorCode', + 'event', + 'mappingFingerprint', + 'operation', + 'providerFingerprint', + 'resourceLinkFingerprint', + 'sessionFingerprint', + ]) + assert.equal(lifecycleError.operation, 'substitute-launch') + assert.equal(lifecycleError.sessionFingerprint, null) + } + assert.equal(substituteCleanupError.errorCode, 'entry-cleanup-failed') + assert.equal(substituteStartError.errorCode, 'activation-failed') sessions.delete = originalDeleteForSubstituteCleanup failInstructorSessionCreation = false @@ -452,6 +554,10 @@ void test('Learn routes transition a one-time waiting-room entry into an active assert.match(String((replayResponse.body as { error?: unknown }).error), /replayed/i) const stopPath = `/api/integrations/learn/v1/activities/syncdeck/resources/${resourceId}/stop` + const sessionBeforeStop = await sessions.get(createdSessionId) + assert.ok(sessionBeforeStop) + sessionBeforeStop.data.linkedSessionId = resourceEntryId + await sessions.set(createdSessionId, sessionBeforeStop) const stopResponse = response() await postHandlers.get('/api/integrations/learn/v1/activities/:activityId/resources/:resourceLinkId/stop')!( { params: { activityId: 'syncdeck', resourceLinkId: resourceId }, ...signedRequest('POST', stopPath, {}, 'stop-nonce') }, @@ -459,8 +565,15 @@ void test('Learn routes transition a one-time waiting-room entry into an active ) assert.deepEqual(stopResponse.body, { state: 'inactive', alreadyInactive: false }) assert.equal(typeof (await sessions.get(createdSessionId))?.data.learnIntegrationStoppedAt, 'number') + assert.equal((await sessions.get(createdSessionId))?.data.linkedSessionId, undefined) assert.deepEqual(broadcasts, [{ event: 'session-ended', payload: { sessionId: createdSessionId } }]) + const stopIdentityLog = JSON.parse( + infoLogs.find((message) => message.includes('"event":"learn-identity-resolved"') && message.includes('"operation":"stop"') && message.includes('"sessionFingerprint":"'))!, + ) as Record + assert.equal(stopIdentityLog.mappingFingerprint, resourceMappingFingerprint) + assert.equal(stopIdentityLog.sessionFingerprint, startedSessionFingerprint) + console.info('[TEST] Expected idempotent Learn stop no-op log.') const repeatedStopResponse = response() await postHandlers.get('/api/integrations/learn/v1/activities/:activityId/resources/:resourceLinkId/stop')!( @@ -471,10 +584,21 @@ void test('Learn routes transition a one-time waiting-room entry into an active assert.deepEqual(repeatedStopResponse.body, { state: 'inactive', alreadyInactive: true }) assert.ok(infoLogs.some((message) => message.includes('learn-integration-stop-noop') && message.includes('"reason":"already-inactive"'))) - console.info('[TEST] Expected Learn instructor-session creation failure to return a safe server error.') + const noopStopIdentityLog = JSON.parse( + infoLogs.filter((message) => message.includes('"event":"learn-identity-resolved"') && message.includes('"operation":"stop"')).at(-1)!, + ) as Record + assert.equal(noopStopIdentityLog.state, 'inactive') + assert.equal(noopStopIdentityLog.sessionFingerprint, null) + + const failureSentinelResource = 'sentinel-resource-identifier' + const failureSentinelSession = 'sentinel-session-identifier' + const failureSentinelUrl = 'https://sentinel.example/private-launch' + const failureSentinelToken = 'sentinel-browser-token' + console.info('[TEST] Expected Learn instructor-session creation failure to return a safe server error without logging failure sentinels.') failInstructorSessionCreation = true + instructorFailureMessage = `${failureSentinelResource} ${failureSentinelSession} ${failureSentinelUrl} ${failureSentinelToken}` const failedInstructorStartResponse = response() - const failedInstructorResourceId = 'learn-resource-start-failure' + const failedInstructorResourceId = failureSentinelResource const failedInstructorStartPath = `/api/integrations/learn/v1/activities/syncdeck/resources/${failedInstructorResourceId}/start` await postHandlers.get('/api/integrations/learn/v1/activities/:activityId/resources/:resourceLinkId/start')!( { params: { activityId: 'syncdeck', resourceLinkId: failedInstructorResourceId }, ...signedRequest('POST', failedInstructorStartPath, { presentationUrl: 'https://slides.example/deck', requestId: 'start-creation-failure' }, 'start-creation-failure-nonce') }, @@ -484,6 +608,7 @@ void test('Learn routes transition a one-time waiting-room entry into an active assert.match(String((failedInstructorStartResponse.body as { error?: unknown }).error), /unable to start/i) assert.ok(errorLogs.some((message) => message.includes('learn-instructor-session-start-failed'))) failInstructorSessionCreation = false + instructorFailureMessage = 'test instructor-session creation failure' console.info('[TEST] Expected Learn start to release its lock after a mapping-load failure.') const originalGet = sessions.get.bind(sessions) @@ -559,6 +684,76 @@ void test('Learn routes transition a one-time waiting-room entry into an active assert.equal(startLockFailureResponse.statusCode, 503) assert.match(String((startLockFailureResponse.body as { error?: unknown }).error), /coordination/i) assert.ok(errorLogs.some((message) => message.includes('learn-start-lock-claim-failed'))) + + console.info('[TEST] Expected Learn start to resolve the active-entry TTL from valkeyStore.ttlMs when the wrapped store omits a top-level ttlMs, matching production Valkey wiring.') + const ttlFallbackResourceId = 'learn-resource-ttl-fallback' + const ttlFallbackStartPath = `/api/integrations/learn/v1/activities/syncdeck/resources/${ttlFallbackResourceId}/start` + const originalTtlMs = sessions.ttlMs + sessions.ttlMs = undefined + sessions.valkeyStore = { ttlMs: 4_242_000 } as unknown as SessionStore['valkeyStore'] + const beforeTtlFallbackStart = Date.now() + const ttlFallbackStartResponse = response() + await postHandlers.get('/api/integrations/learn/v1/activities/:activityId/resources/:resourceLinkId/start')!( + { params: { activityId: 'syncdeck', resourceLinkId: ttlFallbackResourceId }, ...signedRequest('POST', ttlFallbackStartPath, { presentationUrl: 'https://slides.example/ttl-fallback', requestId: 'ttl-fallback-start' }, 'ttl-fallback-nonce') }, + ttlFallbackStartResponse, + ) + assert.equal(ttlFallbackStartResponse.statusCode, 200) + let ttlFallbackEntryId: string | undefined + for (const candidateId of await sessions.getAllIds()) { + if (!candidateId.startsWith('learn-syncdeck-entry-')) continue + const candidate = await sessions.get(candidateId) + if ((candidate?.data as { resourceLinkId?: unknown })?.resourceLinkId === ttlFallbackResourceId) { + ttlFallbackEntryId = candidateId + break + } + } + assert.ok(ttlFallbackEntryId, 'expected a Learn entry mapping for the ttl-fallback resource') + const ttlFallbackEntry = await sessions.get(ttlFallbackEntryId!) + const ttlFallbackExpiresAt = (ttlFallbackEntry?.data as { expiresAt?: unknown })?.expiresAt + assert.equal(typeof ttlFallbackExpiresAt, 'number') + assert.ok( + (ttlFallbackExpiresAt as number) >= beforeTtlFallbackStart + 4_242_000 && (ttlFallbackExpiresAt as number) <= Date.now() + 4_242_000, + 'active entry expiresAt should reflect valkeyStore.ttlMs, not fall back to the 10-minute waiting TTL', + ) + const ttlFallbackLiveSession = await sessions.get((ttlFallbackEntry?.data as { activeSessionId?: unknown })?.activeSessionId as string) + assert.equal( + (ttlFallbackLiveSession?.data as { linkedSessionId?: unknown })?.linkedSessionId, + ttlFallbackEntryId, + ) + sessions.ttlMs = originalTtlMs + + console.info('[TEST] Verifying Learn lifecycle logs never leak raw resourceLinkId, sessionId, or handoff material.') + const learnLifecycleLogs = [...infoLogs, ...errorLogs].filter((message) => message.includes('"event":"learn-identity-resolved"') || message.includes('"event":"learn-instructor-session-') || message.includes('"event":"learn-integration-stop-noop"') || message.includes('"event":"learn-integration-request-failed"')) + assert.ok(learnLifecycleLogs.length >= 8) + const failedLifecycleError = JSON.parse( + errorLogs.find((message) => message.includes('"event":"learn-instructor-session-start-failed"'))!, + ) as Record + assert.deepEqual(Object.keys(failedLifecycleError).sort(), [ + 'activity', + 'errorCode', + 'event', + 'mappingFingerprint', + 'operation', + 'providerFingerprint', + 'requestId', + 'resourceLinkFingerprint', + 'sessionFingerprint', + ]) + assert.equal(failedLifecycleError.activity, 'syncdeck') + assert.equal(failedLifecycleError.operation, 'start') + assert.equal(failedLifecycleError.requestId, 'start-creation-failure') + assert.equal(failedLifecycleError.errorCode, 'activation-failed') + assert.equal(failedLifecycleError.sessionFingerprint, null) + for (const message of learnLifecycleLogs) { + assert.ok(!message.includes(resourceId)) + assert.ok(!message.includes(createdSessionId)) + assert.ok(!message.includes(failedInstructorResourceId)) + assert.ok(!message.includes(failureSentinelSession)) + assert.ok(!message.includes(failureSentinelToken)) + assert.ok(!message.includes('waitingLaunchUrl')) + assert.ok(!message.includes('instructorLaunchUrl')) + assert.ok(!message.includes('browserToken')) + } } finally { console.info = previousInfo console.error = previousError diff --git a/activities/syncdeck/server/learnIntegration.ts b/activities/syncdeck/server/learnIntegration.ts index ec2b308d..d240560c 100644 --- a/activities/syncdeck/server/learnIntegration.ts +++ b/activities/syncdeck/server/learnIntegration.ts @@ -268,11 +268,139 @@ async function verifyHmac(req: RouteRequest, method: string, path: string, sessi return { ok: true, key, provider } } -function mappingId(secret: string, activityId: string, provider: string, resourceLinkId: string): string { +export function mappingId(secret: string, activityId: string, provider: string, resourceLinkId: string): string { const digest = createHmac('sha256', secret).update(`${activityId}\n${provider}\n${resourceLinkId}`, 'utf8').digest('hex') return `learn-syncdeck-entry-${digest.slice(0, 40)}` } +// Domain-separated, secret-keyed digests so provider/resourceLinkId/session identifiers can be correlated +// across ActiveBits and Learn logs without either side logging the underlying opaque values. +export function identityFingerprint(secret: string, domainTag: string, value: string): string { + return createHmac('sha256', secret).update(`${domainTag}|${value}`, 'utf8').digest('hex').slice(0, 16) +} + +export function identityFingerprints(secret: string, provider: string, resourceLinkId: string, mapping: string): { + providerFingerprint: string + resourceLinkFingerprint: string + mappingFingerprint: string +} { + return { + providerFingerprint: identityFingerprint(secret, 'learn-provider', provider), + resourceLinkFingerprint: identityFingerprint(secret, 'learn-resource-link', resourceLinkId), + mappingFingerprint: identityFingerprint(secret, 'learn-mapping', mapping), + } +} + +function logIdentityResolution( + secret: string, + operation: string, + provider: string, + resourceLinkId: string, + mapping: string, + state: 'inactive' | 'waiting' | 'active', + activeSessionId: string | null, + reused?: boolean, +): void { + console.info(JSON.stringify({ + activity: ACTIVITY_ID, + event: 'learn-identity-resolved', + operation, + ...identityFingerprints(secret, provider, resourceLinkId, mapping), + state, + sessionFingerprint: activeSessionId ? identityFingerprint(secret, 'learn-session', activeSessionId) : null, + ...(reused === undefined ? {} : { reused }), + })) +} + +function logLearnLifecycle( + secret: string, + event: string, + provider: string, + resourceLinkId: string, + mapping: string, + sessionId: string | null, + context: Record = {}, +): void { + console.info(JSON.stringify({ + activity: ACTIVITY_ID, + event, + ...context, + ...identityFingerprints(secret, provider, resourceLinkId, mapping), + sessionFingerprint: sessionId ? identityFingerprint(secret, 'learn-session', sessionId) : null, + })) +} + +function logLearnLifecycleError( + secret: string, + event: string, + provider: string, + resourceLinkId: string, + mapping: string, + sessionId: string | null, + requestId: string, + errorCode: 'activation-failed' | 'entry-cleanup-failed' | 'unlink-failed', +): void { + console.error(JSON.stringify({ + activity: ACTIVITY_ID, + event, + operation: 'start', + requestId, + errorCode, + ...identityFingerprints(secret, provider, resourceLinkId, mapping), + sessionFingerprint: sessionId ? identityFingerprint(secret, 'learn-session', sessionId) : null, + })) +} + +function logLearnSubstituteLifecycleError( + secret: string, + event: string, + provider: string, + resourceLinkId: string, + mapping: string, + sessionId: string | null, + errorCode: 'activation-failed' | 'entry-cleanup-failed' | 'unlink-failed', +): void { + console.error(JSON.stringify({ + activity: ACTIVITY_ID, + event, + operation: 'substitute-launch', + errorCode, + ...identityFingerprints(secret, provider, resourceLinkId, mapping), + sessionFingerprint: sessionId ? identityFingerprint(secret, 'learn-session', sessionId) : null, + })) +} + +// `sessions.ttlMs` is only populated on the in-memory store; the Valkey-backed store (production) +// exposes its real configured TTL on `sessions.valkeyStore.ttlMs` instead, so an active entry's TTL +// must check both before falling back to the (much shorter) waiting-room default. +function resolveActiveEntryTtlMs(sessions: SessionStore): number { + if (typeof sessions.ttlMs === 'number') return sessions.ttlMs + if (typeof sessions.valkeyStore?.ttlMs === 'number') return sessions.valkeyStore.ttlMs + return WAITING_TTL_MS +} + +// Stamps the live SyncDeck session with a back-reference to its Learn entry mapping so that +// ordinary websocket keepalive activity on the session (see `linkedSessionId` handling in +// server/core/sessions.ts) also refreshes the entry, instead of the entry's lifetime depending +// solely on Learn re-polling `status`/`start` while the class may already be underway. +async function linkLiveSessionToEntry(sessions: SessionStore, sessionId: string, entryMappingId: string): Promise { + const liveSession = await sessions.get(sessionId) + if (!liveSession) return + liveSession.data = { ...liveSession.data, linkedSessionId: entryMappingId } + await sessions.set(sessionId, liveSession) +} + +// Clear the link only when it still belongs to this entry. A stopped or rolled-back +// session can retain open sockets, so it must not refresh a later mapping with the same id. +async function unlinkLiveSessionFromEntry(sessions: SessionStore, sessionId: string, entryMappingId: string): Promise { + const liveSession = await sessions.get(sessionId) + if (!liveSession || liveSession.data.linkedSessionId !== entryMappingId) return + const { linkedSessionId: _linkedSessionId, ...data } = liveSession.data + void _linkedSessionId + liveSession.data = data + await sessions.set(sessionId, liveSession) +} + function cookieValue(secret: string, mapping: string): string { const signature = createHmac('sha256', secret).update(mapping, 'utf8').digest('base64url') return `${mapping}.${signature}` @@ -294,7 +422,7 @@ function getEntryData(session: SessionRecord | null): LearnEntryData | null { const resourceLinkId = readString(data.resourceLinkId, MAX_RESOURCE_ID_LENGTH) const state = data.state === 'waiting' || data.state === 'active' ? data.state : null const expiresAt = typeof data.expiresAt === 'number' && Number.isFinite(data.expiresAt) ? data.expiresAt : 0 - if (!activityId || !provider || !resourceLinkId || !state || expiresAt <= Date.now()) return null + if (!activityId || !provider || !resourceLinkId || !state || (state === 'waiting' && expiresAt <= Date.now())) return null return { learnIntegrationKind: 'entry', activityId, @@ -374,8 +502,21 @@ function respondToSubstituteLaunchError(res: RouteResponse, status: number, mess res.status(status).json({ error: message }) } -function logLearnRequestFailure(route: string, reason: string, context: Record = {}): void { - console.info(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-integration-request-failed', route, reason, ...context })) +function logLearnRequestFailure(route: string, reason: string, context: Record = {}, identity?: { secret: string; provider: string; resourceLinkId: string; mapping: string; sessionId?: string | null }): void { + const { resourceLinkId: _resourceLinkId, sessionId: _sessionId, ...safeContext } = context + void _resourceLinkId + void _sessionId + console.info(JSON.stringify({ + activity: ACTIVITY_ID, + event: 'learn-integration-request-failed', + route, + reason, + ...safeContext, + ...(identity ? { + ...identityFingerprints(identity.secret, identity.provider, identity.resourceLinkId, identity.mapping), + sessionFingerprint: identity.sessionId ? identityFingerprint(identity.secret, 'learn-session', identity.sessionId) : null, + } : {}), + })) } function integrationPath(activityId: string, resourceLinkId: string, suffix: string): string { @@ -418,7 +559,7 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): await sessions.delete(id) return null } - const ttl = data.state === 'waiting' ? WAITING_TTL_MS : (sessions.ttlMs ?? WAITING_TTL_MS) + const ttl = data.state === 'waiting' ? WAITING_TTL_MS : resolveActiveEntryTtlMs(sessions) const nextExpiresAt = Date.now() + ttl let refreshed: SessionRecord | null = null try { @@ -455,12 +596,18 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): return void res.status(400).json({ error: 'Invalid resourceLinkId' }) } const provider = auth.provider - const entry = await loadEntry(mappingId(auth.key.secret, ACTIVITY_ID, provider, resourceLinkId)) - if (!entry) return void res.json({ resourceLinkId, state: 'inactive', activeSessionId: null, studentLaunchUrl: null, connectedParticipantCount: 0, connectedInstructorCount: 0 }) + const id = mappingId(auth.key.secret, ACTIVITY_ID, provider, resourceLinkId) + const entry = await loadEntry(id) + if (!entry) { + logIdentityResolution(auth.key.secret, 'status', provider, resourceLinkId, id, 'inactive', null) + return void res.json({ resourceLinkId, state: 'inactive', activeSessionId: null, studentLaunchUrl: null, connectedParticipantCount: 0, connectedInstructorCount: 0 }) + } if (entry.data.state === 'waiting' || !entry.data.activeSessionId) { + logIdentityResolution(auth.key.secret, 'status', provider, resourceLinkId, id, 'waiting', null) return void res.json({ resourceLinkId, state: 'waiting', activeSessionId: null, studentLaunchUrl: null, connectedParticipantCount: 0, connectedInstructorCount: 0 }) } const counts = countConnections(ws, entry.data.activeSessionId) + logIdentityResolution(auth.key.secret, 'status', provider, resourceLinkId, id, 'active', entry.data.activeSessionId) res.json({ resourceLinkId, state: 'active', @@ -490,19 +637,21 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): const provider = auth.provider const id = mappingId(auth.key.secret, ACTIVITY_ID, provider, resourceLinkId) let entry = await loadEntry(id) + let reused = Boolean(entry) if (!entry) { const startLock = await claimStartLock(sessions, id) if (startLock.state !== 'acquired') { if (startLock.state === 'unavailable') { - logLearnRequestFailure('student-entry', 'start-lock-unavailable', { resourceLinkId, status: 503 }) + logLearnRequestFailure('student-entry', 'start-lock-unavailable', { status: 503 }, { secret: auth.key.secret, provider, resourceLinkId, mapping: id }) return void res.status(503).json({ error: 'Learn session coordination is unavailable' }) } - logLearnRequestFailure('student-entry', 'session-transition-in-progress', { resourceLinkId, status: 409 }) + logLearnRequestFailure('student-entry', 'session-transition-in-progress', { status: 409 }, { secret: auth.key.secret, provider, resourceLinkId, mapping: id }) return void res.status(409).json({ error: 'A Learn session transition is already in progress; retry shortly' }) } const releaseStartLock = startLock.release try { entry = await loadEntry(id) + reused = Boolean(entry) if (!entry) { const created = await createSession(sessions, { data: { learnIntegrationKind: 'entry', activityId: ACTIVITY_ID, provider, resourceLinkId, state: 'waiting', activeSessionId: null, presentationUrl: null, expiresAt: Date.now() + WAITING_TTL_MS } }) const generatedId = created.id @@ -516,6 +665,7 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): await releaseStartLock() } } + logIdentityResolution(auth.key.secret, 'student-entry', provider, resourceLinkId, id, entry.data.state, entry.data.activeSessionId, reused) const token = await createBrowserToken(sessions, { learnIntegrationKind: 'browser-token', activityId: ACTIVITY_ID, purpose: 'student-wait', mappingId: id, expiresAt: Date.now() + BROWSER_TOKEN_TTL_MS }) res.json({ waitingLaunchUrl: `${BROWSER_PREFIX}/${ACTIVITY_ID}/wait/${encodeURIComponent(token.id)}?token=${encodeURIComponent(token.value)}`, state: entry.data.state }) }) @@ -534,38 +684,38 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): return void res.status(400).json({ error: 'Invalid resourceLinkId' }) } const provider = auth.provider + const id = mappingId(auth.key.secret, ACTIVITY_ID, provider, resourceLinkId) const body = isPlainObject(req.body) ? req.body : {} const presentationUrl = readString(body.presentationUrl, 4096) const requestId = readString(body.requestId, 256) if (!presentationUrl || !requestId || !isValidHttpUrl(presentationUrl)) { - logLearnRequestFailure('start', 'invalid-start-payload', { resourceLinkId, status: 400 }) + logLearnRequestFailure('start', 'invalid-start-payload', { status: 400 }, { secret: auth.key.secret, provider, resourceLinkId, mapping: id }) return void res.status(400).json({ error: 'Missing or invalid Learn start payload' }) } - const id = mappingId(auth.key.secret, ACTIVITY_ID, provider, resourceLinkId) let entry: { session: SessionRecord; data: LearnEntryData } | null const startLock = await claimStartLock(sessions, id) if (startLock.state !== 'acquired') { if (startLock.state === 'unavailable') { - logLearnRequestFailure('start', 'start-lock-unavailable', { resourceLinkId, requestId, status: 503 }) + logLearnRequestFailure('start', 'start-lock-unavailable', { requestId, status: 503 }, { secret: auth.key.secret, provider, resourceLinkId, mapping: id }) return void res.status(503).json({ error: 'Learn session coordination is unavailable' }) } const pendingEntry = await loadEntry(id) if (pendingEntry?.data.startRequestId === requestId) { - console.info(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-instructor-session-start-pending', resourceLinkId, requestId })) + console.info(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-instructor-session-start-pending', operation: 'start', requestId, ...identityFingerprints(auth.key.secret, provider, resourceLinkId, id), state: 'starting', sessionFingerprint: null, reused: false })) return void res.status(202).json({ state: 'starting', activeSessionId: null, reused: false }) } - logLearnRequestFailure('start', 'instructor-start-in-progress', { resourceLinkId, requestId, status: 409 }) + logLearnRequestFailure('start', 'instructor-start-in-progress', { requestId, status: 409 }, { secret: auth.key.secret, provider, resourceLinkId, mapping: id }) return void res.status(409).json({ error: 'A Learn instructor start is already in progress; retry shortly' }) } const releaseStartLock = startLock.release try { entry = await loadEntry(id) - let sessionId: string + let sessionId: string | null = null let reused = false if (entry?.data.state === 'active' && entry.data.activeSessionId) { if (entry.data.presentationUrl !== presentationUrl) { - logLearnRequestFailure('start', 'presentation-url-change-while-active', { resourceLinkId, requestId, sessionId: entry.data.activeSessionId, status: 409 }) + logLearnRequestFailure('start', 'presentation-url-change-while-active', { requestId, status: 409 }, { secret: auth.key.secret, provider, resourceLinkId, mapping: id, sessionId: entry.data.activeSessionId }) return void res.status(409).json({ error: 'Presentation URL cannot change while the instructor session is active' }) } const activeSession = await sessions.get(entry.data.activeSessionId) @@ -576,7 +726,7 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): sessionId = entry.data.activeSessionId const token = typeof activeSession.data.instructorRecoveryToken === 'string' ? activeSession.data.instructorRecoveryToken : null if (!token) { - logLearnRequestFailure('start', 'active-instructor-recovery-unavailable', { resourceLinkId, requestId, sessionId, status: 500 }) + logLearnRequestFailure('start', 'active-instructor-recovery-unavailable', { requestId, status: 500 }, { secret: auth.key.secret, provider, resourceLinkId, mapping: id, sessionId }) return void res.status(500).json({ error: 'Active instructor recovery is unavailable' }) } reused = true @@ -600,6 +750,7 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): } const created = await options.createInstructorSession(presentationUrl) sessionId = created.sessionId + await linkLiveSessionToEntry(sessions, sessionId, id) const nextData: LearnEntryData = { learnIntegrationKind: 'entry', activityId: ACTIVITY_ID, @@ -609,12 +760,17 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): startRequestId: requestId, activeSessionId: sessionId, presentationUrl, - expiresAt: Date.now() + (sessions.ttlMs ?? WAITING_TTL_MS), + expiresAt: Date.now() + resolveActiveEntryTtlMs(sessions), } entry.session.data = nextData await sessions.set(id, entry.session) - console.info(JSON.stringify({ activity: 'syncdeck', event: 'learn-instructor-session-started', resourceLinkId, requestId, sessionId, reused: false })) - } catch (error) { + logLearnLifecycle(auth.key.secret, 'learn-instructor-session-started', provider, resourceLinkId, id, sessionId, { requestId, reused: false }) + } catch { + try { + if (sessionId) await unlinkLiveSessionFromEntry(sessions, sessionId, id) + } catch { + logLearnLifecycleError(auth.key.secret, 'learn-instructor-session-unlink-failed', provider, resourceLinkId, id, sessionId, requestId, 'unlink-failed') + } try { if (previousData && entry) { entry.session.data = previousData @@ -622,14 +778,15 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): } else { await sessions.delete(id) } - } catch (cleanupError) { - console.error(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-instructor-session-start-cleanup-failed', resourceLinkId, requestId, error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) })) + } catch { + logLearnLifecycleError(auth.key.secret, 'learn-instructor-session-start-cleanup-failed', provider, resourceLinkId, id, sessionId, requestId, 'entry-cleanup-failed') } - console.error(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-instructor-session-start-failed', resourceLinkId, requestId, error: error instanceof Error ? error.message : String(error) })) + logLearnLifecycleError(auth.key.secret, 'learn-instructor-session-start-failed', provider, resourceLinkId, id, sessionId, requestId, 'activation-failed') return void res.status(500).json({ error: 'Unable to start the Learn instructor session' }) } } + logIdentityResolution(auth.key.secret, 'start', provider, resourceLinkId, id, 'active', sessionId, reused) const browserToken = await createBrowserToken(sessions, { learnIntegrationKind: 'browser-token', activityId: ACTIVITY_ID, @@ -667,10 +824,12 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): const id = mappingId(auth.key.secret, ACTIVITY_ID, provider, resourceLinkId) const entry = await loadEntry(id) if (!entry?.data.activeSessionId) { - console.info(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-integration-stop-noop', route: 'stop', reason: 'already-inactive', resourceLinkId, status: 200 })) + logIdentityResolution(auth.key.secret, 'stop', provider, resourceLinkId, id, 'inactive', null) + logLearnLifecycle(auth.key.secret, 'learn-integration-stop-noop', provider, resourceLinkId, id, null, { route: 'stop', reason: 'already-inactive', status: 200 }) return void res.json({ state: 'inactive', alreadyInactive: true }) } const sessionId = entry.data.activeSessionId + await unlinkLiveSessionFromEntry(sessions, sessionId, id) const activeSession = await sessions.get(sessionId) if (activeSession) { activeSession.data.learnIntegrationStoppedAt = Date.now() @@ -678,7 +837,8 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): await sessions.publishBroadcast?.('session-ended', { sessionId }) } await sessions.delete(id) - console.info(JSON.stringify({ activity: 'syncdeck', event: 'learn-instructor-session-stopped', resourceLinkId, sessionId })) + logIdentityResolution(auth.key.secret, 'stop', provider, resourceLinkId, id, 'inactive', sessionId) + logLearnLifecycle(auth.key.secret, 'learn-instructor-session-stopped', provider, resourceLinkId, id, sessionId) res.json({ state: 'inactive', alreadyInactive: false }) }) @@ -752,9 +912,15 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): const created = await options.createInstructorSession(link.presentationUrl) sessionId = created.sessionId recoveryToken = created.instructorRecoveryToken - entry.session.data = { learnIntegrationKind: 'entry', activityId: ACTIVITY_ID, provider: link.provider, resourceLinkId: link.resourceLinkId, state: 'active', startRequestId: `substitute:${link.jti}`, activeSessionId: sessionId, presentationUrl: link.presentationUrl, expiresAt: Date.now() + (sessions.ttlMs ?? WAITING_TTL_MS) } + await linkLiveSessionToEntry(sessions, sessionId, id) + entry.session.data = { learnIntegrationKind: 'entry', activityId: ACTIVITY_ID, provider: link.provider, resourceLinkId: link.resourceLinkId, state: 'active', startRequestId: `substitute:${link.jti}`, activeSessionId: sessionId, presentationUrl: link.presentationUrl, expiresAt: Date.now() + resolveActiveEntryTtlMs(sessions) } await sessions.set(id, entry.session) - } catch (error) { + } catch { + try { + if (sessionId) await unlinkLiveSessionFromEntry(sessions, sessionId, id) + } catch { + logLearnSubstituteLifecycleError(key.secret, 'learn-substitute-link-unlink-failed', link.provider, link.resourceLinkId, id, sessionId, 'unlink-failed') + } try { if (previousData && entry) { entry.session.data = previousData @@ -762,10 +928,10 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions): } else { await sessions.delete(id) } - } catch (cleanupError) { - console.error(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-substitute-link-start-cleanup-failed', resourceLinkId: link.resourceLinkId, jti: link.jti, error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) })) + } catch { + logLearnSubstituteLifecycleError(key.secret, 'learn-substitute-link-start-cleanup-failed', link.provider, link.resourceLinkId, id, sessionId, 'entry-cleanup-failed') } - console.error(JSON.stringify({ activity: ACTIVITY_ID, event: 'learn-substitute-link-start-failed', jti: link.jti, error: error instanceof Error ? error.message : String(error) })) + logLearnSubstituteLifecycleError(key.secret, 'learn-substitute-link-start-failed', link.provider, link.resourceLinkId, id, sessionId, 'activation-failed') return void respondToSubstituteLaunchError(res, 500, 'Unable to start the substitute instructor session') } } diff --git a/activities/syncdeck/server/routes.ts b/activities/syncdeck/server/routes.ts index 0b456fab..94e9d996 100644 --- a/activities/syncdeck/server/routes.ts +++ b/activities/syncdeck/server/routes.ts @@ -574,7 +574,7 @@ function extractIndicesFromInstructorPayload(payload: unknown): { h: number; v: return extractIndicesFromRevealStateObject(payload.payload.revealState) ?? extractIndicesFromRevealStateObject(payload.payload) } -function normalizeSessionData(data: unknown): SyncDeckSessionData { +export function normalizeSyncDeckSessionData(data: unknown): SyncDeckSessionData { const source = isPlainObject(data) ? data : {} const normalizedLastInstructorPayload = source.lastInstructorPayload ?? null const normalizedLastInstructorStatePayload = @@ -591,6 +591,9 @@ function normalizeSessionData(data: unknown): SyncDeckSessionData { : undefined return { + ...(typeof source.linkedSessionId === 'string' && source.linkedSessionId.trim().length > 0 + ? { linkedSessionId: source.linkedSessionId.trim() } + : {}), ...(preservedAcceptedEntryParticipants ? { acceptedEntryParticipants: preservedAcceptedEntryParticipants } : {}), ...(preservedEntryParticipants ? { entryParticipants: preservedEntryParticipants } : {}), presentationUrl: typeof source.presentationUrl === 'string' ? source.presentationUrl : null, @@ -913,7 +916,7 @@ function asSyncDeckSession(session: SessionRecord | null): SyncDeckSession | nul return null } - session.data = normalizeSessionData(session.data) + session.data = normalizeSyncDeckSessionData(session.data) return session as SyncDeckSession } @@ -1484,7 +1487,7 @@ function buildEmbeddedActivityEndPayload(instanceKey: string, childSessionId: st } registerSessionNormalizer('syncdeck', (session) => { - session.data = normalizeSessionData(session.data) + session.data = normalizeSyncDeckSessionData(session.data) }) async function createSyncDeckInstructorSession( @@ -1493,7 +1496,7 @@ async function createSyncDeckInstructorSession( ): Promise<{ sessionId: string; instructorRecoveryToken: string; instructorPasscode: string }> { const session = await createSession(sessions, { data: {} }) session.type = 'syncdeck' - session.data = normalizeSessionData({ + session.data = normalizeSyncDeckSessionData({ ...session.data, ...(presentationUrl ? { presentationUrl, standaloneMode: false } : {}), }) diff --git a/server/core/sessions.ts b/server/core/sessions.ts index e581b110..142fb06f 100644 --- a/server/core/sessions.ts +++ b/server/core/sessions.ts @@ -40,6 +40,16 @@ function getEmbeddedParentSessionId(session: SessionRecord | SessionLike | null return parentSessionId.length > 0 ? parentSessionId : null } +// Generic cross-record keepalive: any session may declare `data.linkedSessionId` to have its own +// touch()es refresh another store record (e.g. a Learn integration entry mapping keyed off this +// live session, so the mapping stays alive for as long as anyone is actually connected to it, +// independent of whatever external polling cadence would otherwise refresh it). +function getLinkedSessionId(session: SessionRecord | SessionLike | null | undefined): string | null { + const data = ensurePlainObject(session?.data) + const linkedSessionId = typeof data.linkedSessionId === 'string' ? data.linkedSessionId.trim() : '' + return linkedSessionId.length > 0 ? linkedSessionId : null +} + function toSessionRecord(session: SessionLike): SessionRecord { return { ...session, @@ -141,6 +151,20 @@ class InMemorySessionStore implements SessionStore { return false } + session.lastActivity = Date.now() + const linkedSessionId = getLinkedSessionId(session) + if (linkedSessionId && linkedSessionId !== id) { + this.touchDirect(linkedSessionId) + } + return true + } + + private touchDirect(id: string): boolean { + const session = this.store[id] + if (!session) { + return false + } + session.lastActivity = Date.now() return true } @@ -184,14 +208,41 @@ class InMemorySessionStore implements SessionStore { async publishBroadcast(): Promise {} } -export function createSessionStore(valkeyUrl: string | null = null, ttlMs = 60 * 60 * 1000): SessionStore { - if (!valkeyUrl) { - console.log('Using in-memory session store (no VALKEY_URL configured)') +export function createSessionStore(valkeyUrl: string | null = null, ttlMs = 60 * 60 * 1000, providedValkeyStore: ValkeySessionStore | null = null): SessionStore { + if (!valkeyUrl && !providedValkeyStore) { + console.info(JSON.stringify({ + component: 'session-store', + event: 'store-selected', + store: 'in-memory', + reason: 'valkey-url-not-configured', + })) return new InMemorySessionStore(ttlMs) } - console.log('Using Valkey session store with caching') - const valkeyStore = new ValkeySessionStore(valkeyUrl, { ttlMs }) + console.info(JSON.stringify({ + component: 'session-store', + event: 'store-selected', + store: 'valkey', + cacheEnabled: true, + })) + const valkeyStore = providedValkeyStore ?? new ValkeySessionStore(valkeyUrl!, { ttlMs }) + const linkedSessionRevalidatedAt = new Map() + const LINKED_SESSION_REVALIDATION_MS = 5_000 + const MAX_LINKED_SESSION_REVALIDATIONS = 1_000 + const recordLinkedSessionRevalidation = (id: string, timestamp: number): void => { + linkedSessionRevalidatedAt.delete(id) + linkedSessionRevalidatedAt.set(id, timestamp) + while (linkedSessionRevalidatedAt.size > MAX_LINKED_SESSION_REVALIDATIONS) { + const oldestId = linkedSessionRevalidatedAt.keys().next().value + if (oldestId === undefined) break + linkedSessionRevalidatedAt.delete(oldestId) + } + } + const pruneLinkedSessionRevalidations = (): void => { + for (const id of linkedSessionRevalidatedAt.keys()) { + if (!cache.has(id)) linkedSessionRevalidatedAt.delete(id) + } + } const cache = new SessionCache({ ttlMs: 30_000, maxSize: 1000, @@ -242,18 +293,20 @@ export function createSessionStore(valkeyUrl: string | null = null, ttlMs = 60 * const del = async (id: string): Promise => { cache.invalidate(id) + linkedSessionRevalidatedAt.delete(id) return await valkeyStore.delete(id) } - const touch = async (id: string): Promise => { - if (cache.getFresh(id)) { + const touchDirect = async (id: string): Promise<{ touched: boolean; session: SessionRecord | null; fromCache: boolean }> => { + const cached = cache.getFresh(id) + if (cached) { cache.touch(id) - return true + return { touched: true, session: cached, fromCache: true } } const touched = await valkeyStore.touch(id) if (!touched) { - return false + return { touched: false, session: null, fromCache: false } } const session = await loadSessionRecord(id) @@ -261,6 +314,33 @@ export function createSessionStore(valkeyUrl: string | null = null, ttlMs = 60 * cache.set(id, session, false) } + return { touched: true, session, fromCache: false } + } + + const touch = async (id: string): Promise => { + const { touched, session, fromCache } = await touchDirect(id) + if (!touched) return false + + // A stop handled by another process may have removed linkedSessionId after this + // process cached the live session. Revalidate source data on a bounded cadence, + // separate from high-frequency keepalive touches, before following the link. + const now = Date.now() + const shouldRevalidate = fromCache && (now - (linkedSessionRevalidatedAt.get(id) ?? 0) >= LINKED_SESSION_REVALIDATION_MS) + const authoritativeSession = shouldRevalidate ? await loadSessionRecord(id) : session + if (shouldRevalidate) recordLinkedSessionRevalidation(id, now) + if (shouldRevalidate && !authoritativeSession) { + cache.invalidate(id) + linkedSessionRevalidatedAt.delete(id) + return false + } + if (shouldRevalidate && authoritativeSession) { + cache.set(id, authoritativeSession, false) + } + const linkedSessionId = getLinkedSessionId(authoritativeSession) + if (linkedSessionId && linkedSessionId !== id) { + await touchDirect(linkedSessionId) + } + return true } @@ -288,6 +368,7 @@ export function createSessionStore(valkeyUrl: string | null = null, ttlMs = 60 * const cleanup = (): void => { cache.cleanup() + pruneLinkedSessionRevalidations() } const flushCache = async (): Promise => { @@ -301,6 +382,7 @@ export function createSessionStore(valkeyUrl: string | null = null, ttlMs = 60 * await valkeyStore.touch(id) }) await valkeyStore.close() + linkedSessionRevalidatedAt.clear() } const subscribeToBroadcast = (channel: string, handler: (message: unknown) => void): void => { diff --git a/server/sessionStore.test.ts b/server/sessionStore.test.ts index eaa317cc..d84e33ac 100644 --- a/server/sessionStore.test.ts +++ b/server/sessionStore.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert' import http from 'node:http' import { WebSocket } from 'ws' import { createSessionStore, createSession, type SessionRecord } from './core/sessions.js' +import { type ValkeySessionStore } from './core/valkeyStore.js' import { createWsRouter } from './core/wsRouter.js' import { EMBEDDED_CHILD_SESSION_PREFIX } from '../types/session.js' import { registerSessionNormalizer, resetSessionNormalizersForTests } from './core/sessionNormalization.js' @@ -10,6 +11,66 @@ import { listenForTest } from './testPortBinding.js' const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) +function valkeyStoreForTest(records: Map, touches: string[], ttlMs = 1_000, gets: string[] = []): ValkeySessionStore { + return { + ttlMs, + async get(id: string) { + gets.push(id) + const session = records.get(id) + return session ? structuredClone(session) : null + }, + async set(id: string, session: SessionRecord) { + records.set(id, structuredClone(session)) + }, + async delete(id: string) { + return records.delete(id) + }, + async touch(id: string) { + const session = records.get(id) + if (!session) return false + touches.push(id) + session.lastActivity = Date.now() + return true + }, + async getAll() { + return Array.from(records.values()).map((session) => structuredClone(session)) + }, + async getAllIds() { + return Array.from(records.keys()) + }, + async refreshSessionExpiry() { + return null + }, + async consumeSessionDataToken() { + return null + }, + async close() {}, + subscribeToBroadcast() {}, + initializePubSub() {}, + async publishBroadcast() {}, + } as unknown as ValkeySessionStore +} + +void test('session-store selection is logged with stable structured fields', async () => { + const previousInfo = console.info + const logs: string[] = [] + try { + console.info = (...args: unknown[]) => { logs.push(args.map(String).join(' ')) } + + const inMemoryStore = createSessionStore(null) + const valkeyStore = createSessionStore('redis://test', 1_000, valkeyStoreForTest(new Map(), [])) + await inMemoryStore.close() + await valkeyStore.close() + + assert.deepEqual(logs.map((message) => JSON.parse(message)), [ + { component: 'session-store', event: 'store-selected', store: 'in-memory', reason: 'valkey-url-not-configured' }, + { component: 'session-store', event: 'store-selected', store: 'valkey', cacheEnabled: true }, + ]) + } finally { + console.info = previousInfo + } +}) + void test('inactive sessions expire', async () => { const sessions = createSessionStore(null, 50) const session = await createSession(sessions) @@ -89,6 +150,33 @@ void test('registered session normalizers populate activity defaults', async (t) assert.equal(loadedItems.length, 0) }) +void test('SyncDeck normalization preserves linked-session keepalive records', async (t) => { + const { normalizeSyncDeckSessionData } = await import('../activities/syncdeck/server/routes.js') + resetSessionNormalizersForTests() + registerSessionNormalizer('syncdeck', (session) => { + session.data = normalizeSyncDeckSessionData(session.data) + }) + const sessions = createSessionStore(null, 1_000) + t.after(async () => { + await sessions.close() + resetSessionNormalizersForTests() + }) + + const linkedSession = await createSession(sessions) + linkedSession.lastActivity = 1 + await sessions.set(linkedSession.id, linkedSession) + + const liveSession = await createSession(sessions) + liveSession.type = 'syncdeck' + liveSession.lastActivity = 1 + liveSession.data = { linkedSessionId: linkedSession.id } + await sessions.set(liveSession.id, liveSession) + + assert.equal((await sessions.get(liveSession.id))?.data.linkedSessionId, linkedSession.id) + assert.equal(await sessions.touch(liveSession.id), true) + assert.ok((await sessions.get(linkedSession.id))!.lastActivity! > 1) +}) + void test('embedded child session reads refresh the parent session activity timestamp', async (t) => { const sessions = createSessionStore(null, 1_000) t.after(async () => { @@ -147,3 +235,163 @@ void test('refreshing an embedded child session refreshes its parent activity an const refreshedParent = await sessions.get(parentSession.id) assert.ok((refreshedParent?.lastActivity ?? 0) > 1) }) + +void test('touching a session refreshes a linked session declared via data.linkedSessionId', async (t) => { + const sessions = createSessionStore(null, 1_000) + t.after(async () => { + await sessions.close() + }) + + const linkedSession = await createSession(sessions) + linkedSession.lastActivity = 1 + await sessions.set(linkedSession.id, linkedSession) + + const liveSession = await createSession(sessions) + liveSession.lastActivity = 1 + liveSession.data = { linkedSessionId: linkedSession.id } + await sessions.set(liveSession.id, liveSession) + + assert.equal(await sessions.touch(liveSession.id), true) + + const refreshedLinked = await sessions.get(linkedSession.id) + assert.ok((refreshedLinked?.lastActivity ?? 0) > 1) +}) + +void test('touching a linked session refreshes exactly one hop', async (t) => { + const sessions = createSessionStore(null, 1_000) + t.after(async () => { + await sessions.close() + }) + + const terminalSession = await createSession(sessions) + terminalSession.lastActivity = 1 + await sessions.set(terminalSession.id, terminalSession) + + const linkedSession = await createSession(sessions) + linkedSession.lastActivity = 1 + linkedSession.data = { linkedSessionId: terminalSession.id } + await sessions.set(linkedSession.id, linkedSession) + + const liveSession = await createSession(sessions) + liveSession.lastActivity = 1 + liveSession.data = { linkedSessionId: linkedSession.id } + await sessions.set(liveSession.id, liveSession) + + assert.equal(await sessions.touch(liveSession.id), true) + assert.ok(liveSession.lastActivity! > 1) + assert.ok(linkedSession.lastActivity! > 1) + assert.equal(terminalSession.lastActivity, 1) +}) + +void test('touching a two-record linked-session cycle completes', async (t) => { + const sessions = createSessionStore(null, 1_000) + t.after(async () => { + await sessions.close() + }) + + const firstSession = await createSession(sessions) + const secondSession = await createSession(sessions) + firstSession.lastActivity = 1 + firstSession.data = { linkedSessionId: secondSession.id } + secondSession.lastActivity = 1 + secondSession.data = { linkedSessionId: firstSession.id } + await sessions.set(firstSession.id, firstSession) + await sessions.set(secondSession.id, secondSession) + + assert.equal(await sessions.touch(firstSession.id), true) + assert.ok(firstSession.lastActivity! > 1) + assert.ok(secondSession.lastActivity! > 1) +}) + +void test('Valkey-backed linked touches refresh cached and uncached records exactly one hop', async (t) => { + const records = new Map() + const touches: string[] = [] + const sessions = createSessionStore('redis://test', 1_000, valkeyStoreForTest(records, touches)) + t.after(async () => { + await sessions.close() + }) + + const terminalSession = await createSession(sessions) + terminalSession.lastActivity = 1 + await sessions.set(terminalSession.id, terminalSession) + const linkedSession = await createSession(sessions) + linkedSession.lastActivity = 1 + linkedSession.data = { linkedSessionId: terminalSession.id } + await sessions.set(linkedSession.id, linkedSession) + const liveSession = await createSession(sessions) + liveSession.lastActivity = 1 + liveSession.data = { linkedSessionId: linkedSession.id } + await sessions.set(liveSession.id, liveSession) + + assert.equal(await sessions.touch(liveSession.id), true) + await sessions.flushCache!() + assert.deepEqual(touches.sort(), [linkedSession.id, liveSession.id].sort(), 'cached touches should flush the live and direct linked records to Valkey only') + assert.ok((records.get(linkedSession.id)?.lastActivity ?? 0) > 1) + assert.equal(records.get(terminalSession.id)?.lastActivity, 1) + + touches.length = 0 + sessions.cache!.invalidate(liveSession.id) + sessions.cache!.invalidate(linkedSession.id) + assert.equal(await sessions.touch(liveSession.id), true) + assert.deepEqual(touches.sort(), [linkedSession.id, liveSession.id].sort(), 'uncached touches should directly refresh the live and direct linked records in Valkey only') + assert.equal(records.get(terminalSession.id)?.lastActivity, 1) +}) + +void test('a Valkey-backed cached touch observes a remote linked-session unlink', async (t) => { + const records = new Map() + const firstTouches: string[] = [] + const secondTouches: string[] = [] + const secondGets: string[] = [] + const firstStore = createSessionStore('redis://first', 1_000, valkeyStoreForTest(records, firstTouches)) + const secondStore = createSessionStore('redis://second', 1_000, valkeyStoreForTest(records, secondTouches, 1_000, secondGets)) + t.after(async () => { + await firstStore.close() + await secondStore.close() + }) + + const linkedSession = await createSession(firstStore) + await firstStore.set(linkedSession.id, linkedSession) + const liveSession = await createSession(firstStore) + liveSession.data = { linkedSessionId: linkedSession.id } + await firstStore.set(liveSession.id, liveSession) + await secondStore.get(liveSession.id) + + liveSession.data = {} + await firstStore.set(liveSession.id, liveSession) + + secondGets.length = 0 + assert.equal(await secondStore.touch(liveSession.id), true) + await secondStore.flushCache!() + assert.deepEqual(secondTouches, [liveSession.id], 'a cached touch must not use a link cleared by another Valkey-backed process') + assert.deepEqual(secondGets, [liveSession.id], 'the first cached touch revalidates its source record') + + secondGets.length = 0 + assert.equal(await secondStore.touch(liveSession.id), true) + assert.deepEqual(secondGets, [], 'subsequent cached touches reuse bounded source-data freshness instead of reading Valkey per event') +}) + +void test('a linked session survives past its own ttl as long as the session pointing at it keeps getting touched', async (t) => { + const sessions = createSessionStore(null, 200) + t.after(async () => { + await sessions.close() + }) + + const linkedSession = await createSession(sessions) + const unlinkedControlSession = await createSession(sessions) + const liveSession = await createSession(sessions) + liveSession.data = { linkedSessionId: linkedSession.id } + await sessions.set(liveSession.id, liveSession) + + await wait(125) + await sessions.touch(liveSession.id) + await wait(125) + sessions.cleanup() + + const survivingIds = await sessions.getAllIds() + assert.ok(survivingIds.includes(linkedSession.id), 'linked session should survive because the session pointing at it was touched') + assert.ok(!survivingIds.includes(unlinkedControlSession.id), 'an untouched, unlinked session should not survive the same window') + + await wait(250) + sessions.cleanup() + assert.ok(!(await sessions.getAllIds()).includes(linkedSession.id)) +})