0046. cmi5 on the content origin with a one-time launch token and an LRS-only session token
Generated from docs/decisions/0046-cmi5-content-origin-and-launch-token.md
- Status: Proposed
- Date: 2026-10-09
- Plan:
docs/plans/leftovers-0-2.md(L0-09)
Context and problem statement
Section titled “Context and problem statement”cmi5 has three problems today:
- Files. AU files sit on the local disk and play from the API origin.
- Permissions. Students lack the read permission.
- Token exposure.
LrsService::launchParamsputs the learner’s full Passport token into thefetchURL, which is passed to third-party AU JavaScript.
Considered options
Section titled “Considered options”- Serve AUs from the content origin.
fetchexchanges a one-time launch token for an LRS-only session token. - Serve AUs from the content origin and keep the Passport token.
- Proxy every LRS call through the BFF.
Decision
Section titled “Decision”Option 1:
- Storage and serving. AUs move to the tenant bucket and are served from
<slug>.content.<domain>/cmi5/*through/api/content. - Launch token. The launch URL carries a random one-time token, stored hashed in
lrs_launch_tokens. - LRS session token.
POST /api/cmi5/fetchreturns an HMAC-signed token scoped to LRS statements, bound to the registration and AU, valid for 120 minutes. Repeat fetches within the session return the same token. The LRS guard accepts it, and every other API guard rejects it. - Permissions. Students get
cmi5_read, and deletion requires a newcmi5_deletepermission.
Implementation notes
Section titled “Implementation notes”lrs_launch_tokensstores the SHA-256 of the launch token, the user, registration, AU and xAPI access. Before the first fetchexpires_atends a 10-minute launch window; the first fetch setsused_atand movesexpires_atto the end of the session (CMI5_SESSION_MINUTES).- The session token is
ulrs1.<payload>.<HMAC>with a key derived from the tenantAPP_KEY. The LRS guard also checks that the launch row is used and unexpired, so a session can be revoked by deleting the row. The session reads and writes only its registration (statements, state) and may read, never write, profiles. - The LMS writes
LMS.LaunchDataand the learner preferences in process, no longer through an internal HTTP request carrying the learner’s token. - A
completedorpassedstatement firesAuCompletionReported;topic-typescompletes the topics that use the AU for learners who may attend the course (as for SCORM, ADR 0018). - Laravel’s CORS config answers any origin without credentials, but Caddy refuses to reflect a content
origin on API responses (ADR 0014). A dedicated
@cmi5block (dev Caddyfile and the production example) answersAccess-Control-Allow-Origin: *on/api/cmi5/fetchand/trax/api/*/xapi/std/*only, dropsCookie, keepsAuthorization(the session token) and names it in the preflight. This is consistent with ADR 0014: the routes take no ambient credentials. Found by playing an AU in the browser against the dev stack. POST /api/cmi5/fetchis exempt from the Origin check (the AU calls it from the content origin) and throttled to 60 requests a minute.
Consequences
Section titled “Consequences”- Good: an AU can no longer act as the learner on the rest of the API.
- Good: cmi5 matches SCORM’s isolation.
- Bad: one more token type in the LRS guard, plus CORS from the content origin to
/api/lrs/*.