tessera-learn 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,9 +15,11 @@ const VERBS = {
15
15
  completed: 'http://adlnet.gov/expapi/verbs/completed',
16
16
  passed: 'http://adlnet.gov/expapi/verbs/passed',
17
17
  failed: 'http://adlnet.gov/expapi/verbs/failed',
18
- suspended: 'http://adlnet.gov/expapi/verbs/suspended',
19
18
  terminated: 'http://adlnet.gov/expapi/verbs/terminated',
20
- satisfied: 'https://w3id.org/xapi/adl/verbs/satisfied',
19
+ // Intentionally absent: "satisfied" (LMS-only, §9.3.9) and
20
+ // "suspended" (not a cmi5-defined verb — §9.3 enumerates nine, none
21
+ // of them Suspended). The LMS infers Abandoned vs resume from
22
+ // registration state when Terminated lands without Completed.
21
23
  } as const;
22
24
 
23
25
  const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
@@ -25,6 +27,16 @@ const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interactio
25
27
  const CMI5_MASTERYSCORE_EXT =
26
28
  'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
27
29
 
30
+ // cmi5 §9.6 — every cmi5 Defined Statement MUST carry the "cmi5" Category
31
+ // Activity in context.contextActivities.category, and "completed", "passed",
32
+ // "failed" MUST additionally carry the "moveOn" Category. Without these, an
33
+ // LRS will accept the statement as an arbitrary xAPI verb but won't roll it
34
+ // up into cmi5 lifecycle state — the LMS never sees the AU as completed.
35
+ const CMI5_CATEGORY_CMI5 =
36
+ 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
37
+ const CMI5_CATEGORY_MOVEON =
38
+ 'https://w3id.org/xapi/cmi5/context/categories/moveon';
39
+
28
40
  export type CMI5MoveOn =
29
41
  | 'Passed'
30
42
  | 'Completed'
@@ -40,6 +52,67 @@ const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
40
52
  'NotApplicable',
41
53
  ]);
42
54
 
55
+ /** cmi5 §10.2.2 — launch mode dictates which Defined Statements the AU may emit. */
56
+ export type CMI5LaunchMode = 'Normal' | 'Browse' | 'Review';
57
+ const VALID_LAUNCH_MODE: ReadonlySet<CMI5LaunchMode> = new Set([
58
+ 'Normal',
59
+ 'Browse',
60
+ 'Review',
61
+ ]);
62
+
63
+ /** State doc id (cmi5 §10) the LMS pre-populates with launch metadata. */
64
+ const LMS_LAUNCH_DATA_STATE_ID = 'LMS.LaunchData';
65
+
66
+ /** Agent Profile id (cmi5 §11) where the LMS stores learner preferences. */
67
+ const CMI5_LEARNER_PREFS_PROFILE_ID = 'cmi5LearnerPreferences';
68
+
69
+ /** xAPI cmi5 sessionid context extension IRI (cmi5 §9.6.3.1). */
70
+ const CMI5_SESSIONID_EXT_IRI =
71
+ 'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
72
+
73
+ /** cmi5 §10 `LMS.LaunchData` document. `contextTemplate` is the base context for every Defined Statement (§9.6.2). */
74
+ interface CMI5LaunchData {
75
+ contextTemplate?: {
76
+ contextActivities?: {
77
+ category?: Array<{ id: string; objectType?: string }>;
78
+ grouping?: Array<{ id: string }>;
79
+ [k: string]: unknown;
80
+ };
81
+ extensions?: Record<string, unknown>;
82
+ [k: string]: unknown;
83
+ };
84
+ launchMode?: CMI5LaunchMode;
85
+ launchMethod?: 'OwnWindow' | 'AnyWindow';
86
+ launchParameters?: string;
87
+ returnURL?: string;
88
+ masteryScore?: number;
89
+ moveOn?: CMI5MoveOn;
90
+ entitlementKey?: Record<string, string>;
91
+ [k: string]: unknown;
92
+ }
93
+
94
+ /** cmi5 §11.1 Learner Preferences Agent Profile document. */
95
+ interface CMI5LearnerPreferences {
96
+ languagePreference?: string;
97
+ audioPreference?: 'on' | 'off';
98
+ [k: string]: unknown;
99
+ }
100
+
101
+ /** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
102
+ function warnOnLRSReject(
103
+ verbName: string
104
+ ): (res: { destinations?: Array<{ ok?: boolean; status?: number; error?: Error }> }) => void {
105
+ return (res) => {
106
+ const dest = res.destinations?.[0];
107
+ if (dest && !dest.ok) {
108
+ console.warn(
109
+ `Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
110
+ dest.error
111
+ );
112
+ }
113
+ };
114
+ }
115
+
43
116
  /**
44
117
  * CMI5 persistence adapter using xAPI.
45
118
  *
@@ -67,9 +140,7 @@ export class CMI5Adapter implements PersistenceAdapter {
67
140
  #durationSeconds = 0;
68
141
  #state: SavedState | null = null;
69
142
  #completedSent = false;
70
- #completionStatus: 'incomplete' | 'complete' = 'incomplete';
71
143
  #successSent = false;
72
- #passed = false;
73
144
  #terminated = false;
74
145
 
75
146
  // cmi5 §8 launch params. masteryScore (when present) overrides the
@@ -77,7 +148,19 @@ export class CMI5Adapter implements PersistenceAdapter {
77
148
  // authority. moveOn drives the optional Satisfied statement (§9.5.3).
78
149
  #masteryScore: number | null = null;
79
150
  #moveOn: CMI5MoveOn = 'NotApplicable';
80
- #satisfiedSent = false;
151
+
152
+ // cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
153
+ // (§9.6.2) — Publisher Activity and session id live there, and strict
154
+ // LRSes validate every Defined Statement against it.
155
+ #launchData: CMI5LaunchData | null = null;
156
+ /** cmi5 §10.2.2 — Browse/Review forbid every Defined Statement except Initialized/Terminated. */
157
+ #launchMode: CMI5LaunchMode = 'Normal';
158
+ /** cmi5 §10.2.6 — AU redirects here on `exit()`. */
159
+ #returnURL: string | undefined;
160
+ /** cmi5 §10.2.3 — opaque per-launch content config string. */
161
+ #launchParameters: string | undefined;
162
+ /** cmi5 §11.1 Learner Preferences. */
163
+ #learnerPreferences: CMI5LearnerPreferences | null = null;
81
164
 
82
165
  async init(): Promise<void> {
83
166
  const params = new URLSearchParams(window.location.search);
@@ -150,27 +233,105 @@ export class CMI5Adapter implements PersistenceAdapter {
150
233
  `Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
151
234
  );
152
235
  }
153
- const text = await resp.text();
154
- // The fetch URL returns the token, possibly with "auth-token=" prefix
155
- // (cmi5 §6.2). The credential itself is the value used as the
156
- // "Basic" Authorization header NOT a Bearer token.
157
- this.#authToken = text.replace(/^auth-token=/, '').trim();
236
+ const text = (await resp.text()).trim();
237
+ // cmi5 §11.2 response is `{"auth-token": "..."}`. Some
238
+ // non-conformant LMSes return bare text with an `auth-token=`
239
+ // prefix, so we fall back to that. The token value is the literal
240
+ // Basic credential (already base64); we don't re-encode.
241
+ let token = '';
242
+ if (text.startsWith('{')) {
243
+ try {
244
+ const parsed = JSON.parse(text);
245
+ if (parsed && typeof parsed['auth-token'] === 'string') {
246
+ token = parsed['auth-token'].trim();
247
+ }
248
+ } catch {
249
+ // fall through to legacy parsing
250
+ }
251
+ }
252
+ if (!token) {
253
+ token = text.replace(/^auth-token=/, '').trim();
254
+ }
255
+ this.#authToken = token;
158
256
  if (!this.#authToken) {
159
257
  throw new Error(
160
- 'Tessera cmi5: fetch token request returned an empty body. Expected an "auth-token=..." or bare token.'
258
+ 'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.'
161
259
  );
162
260
  }
163
261
 
262
+ // cmi5 §10 — LaunchData is the only spec-defined channel for the
263
+ // session id (§9.6.3.1) and Publisher Activity (§9.6.2.3) the LRS
264
+ // validates against, plus launchMode/returnURL/launchParameters/
265
+ // masteryScore/moveOn (§10.2). LaunchData values override the URL
266
+ // masteryScore parsed earlier (§10.2.4 makes it authoritative).
267
+ this.#launchData = await this.#fetchLaunchData();
268
+ const tmpl = this.#launchData?.contextTemplate ?? {};
269
+ let sessionId: string | undefined;
270
+ const launchSession = (tmpl.extensions ?? {})[CMI5_SESSIONID_EXT_IRI];
271
+ if (typeof launchSession === 'string' && launchSession.trim()) {
272
+ sessionId = launchSession.trim();
273
+ }
274
+ if (this.#launchData) {
275
+ if (
276
+ typeof this.#launchData.launchMode === 'string' &&
277
+ VALID_LAUNCH_MODE.has(this.#launchData.launchMode)
278
+ ) {
279
+ this.#launchMode = this.#launchData.launchMode;
280
+ }
281
+ if (
282
+ typeof this.#launchData.returnURL === 'string' &&
283
+ this.#launchData.returnURL
284
+ ) {
285
+ this.#returnURL = this.#launchData.returnURL;
286
+ }
287
+ if (typeof this.#launchData.launchParameters === 'string') {
288
+ this.#launchParameters = this.#launchData.launchParameters;
289
+ }
290
+ if (
291
+ typeof this.#launchData.masteryScore === 'number' &&
292
+ Number.isFinite(this.#launchData.masteryScore) &&
293
+ this.#launchData.masteryScore >= 0 &&
294
+ this.#launchData.masteryScore <= 1
295
+ ) {
296
+ this.#masteryScore = this.#launchData.masteryScore;
297
+ }
298
+ if (
299
+ typeof this.#launchData.moveOn === 'string' &&
300
+ VALID_MOVE_ON.has(this.#launchData.moveOn)
301
+ ) {
302
+ this.#moveOn = this.#launchData.moveOn;
303
+ }
304
+ }
305
+
306
+ // cmi5 §11 — fetch the Agent Profile BEFORE Initialized. Strict
307
+ // LRSes track the GET and reject Initialized otherwise. A 404 here
308
+ // is legitimate (no prefs set); the GET itself is what's required.
309
+ this.#learnerPreferences = await this.#fetchLearnerPreferences();
310
+
164
311
  this.#publisher = new XAPIPublisher({
165
312
  endpoint: this.#endpoint,
166
313
  auth: this.#authToken,
167
314
  actor: this.#actor,
168
315
  activityId: this.#activityId,
169
316
  registration: this.#registration,
317
+ sessionId,
170
318
  cmi5Mode: true,
171
319
  });
172
320
  await this.#publisher.init();
173
321
 
322
+ // cmi5 §9.3.2 — queue Initialized before the resume State GET so a
323
+ // slow LRS can't push it past the spec's "reasonable period". The
324
+ // publisher queue keeps it ordered before any later Defined Statement.
325
+ this.#publisher
326
+ .sendStatement({
327
+ verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
328
+ context: this.#cmi5Context(),
329
+ })
330
+ .then(warnOnLRSReject('Initialized'))
331
+ .catch((err) => {
332
+ console.warn('Tessera cmi5: failed to send Initialized statement', err);
333
+ });
334
+
174
335
  // Retrieve saved state from xAPI State API. The State API is a different
175
336
  // URL than statements/, so it doesn't go through the publisher's send
176
337
  // path — but we still use the same auth/headers.
@@ -179,22 +340,17 @@ export class CMI5Adapter implements PersistenceAdapter {
179
340
  const resp = await this.#xapiFetch(stateUrl, { method: 'GET' });
180
341
  if (resp.ok) {
181
342
  this.#state = await resp.json();
343
+ } else if (resp.status !== 404) {
344
+ console.warn(
345
+ `Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`
346
+ );
182
347
  }
183
- } catch {
348
+ } catch (err) {
349
+ console.warn(
350
+ `Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`
351
+ );
184
352
  this.#state = null;
185
353
  }
186
-
187
- // Send Initialized statement (queued through publisher). Log failures
188
- // here too — the publisher's per-destination outcome covers transport
189
- // errors but won't surface to console; lifecycle statements are rare
190
- // enough that an explicit warning helps production triage.
191
- await this.#publisher
192
- .sendStatement({
193
- verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
194
- })
195
- .catch((err) => {
196
- console.warn('Tessera cmi5: failed to send Initialized statement', err);
197
- });
198
354
  }
199
355
 
200
356
  /**
@@ -221,6 +377,26 @@ export class CMI5Adapter implements PersistenceAdapter {
221
377
  return this.#moveOn;
222
378
  }
223
379
 
380
+ /** cmi5 §10.2.2 — "Normal" is the only mode where progress-bearing Defined Statements are permitted. */
381
+ getLaunchMode(): CMI5LaunchMode {
382
+ return this.#launchMode;
383
+ }
384
+
385
+ /** cmi5 §10.2.6 — URL the AU navigates to on `exit()`. Returns undefined when the LMS didn't supply one. */
386
+ getReturnURL(): string | undefined {
387
+ return this.#returnURL;
388
+ }
389
+
390
+ /** cmi5 §10.2.3 — opaque per-launch content-config string. */
391
+ getLaunchParameters(): string | undefined {
392
+ return this.#launchParameters;
393
+ }
394
+
395
+ /** cmi5 §11.1 Learner Preferences. Null when the LMS didn't publish one. */
396
+ getLearnerPreferences(): CMI5LearnerPreferences | null {
397
+ return this.#learnerPreferences;
398
+ }
399
+
224
400
  getState(): SavedState | null {
225
401
  return this.#state;
226
402
  }
@@ -232,11 +408,16 @@ export class CMI5Adapter implements PersistenceAdapter {
232
408
  // Terminated. We can't use sendStatement here (different URL/verb).
233
409
  this.#publisher.chainTask(async () => {
234
410
  try {
235
- await this.#xapiFetch(this.#buildStateUrl(), {
411
+ const resp = await this.#xapiFetch(this.#buildStateUrl(), {
236
412
  method: 'PUT',
237
413
  headers: { 'Content-Type': 'application/json' },
238
414
  body: JSON.stringify(state),
239
415
  });
416
+ if (!resp.ok) {
417
+ console.warn(
418
+ `Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`
419
+ );
420
+ }
240
421
  } catch (err) {
241
422
  console.warn('Tessera: Failed to save CMI5 state', err);
242
423
  }
@@ -244,36 +425,42 @@ export class CMI5Adapter implements PersistenceAdapter {
244
425
  }
245
426
 
246
427
  setScore(score: number): void {
247
- this.#score = score;
428
+ // Clamped to [0, 100] so the /100 division yields a spec-legal
429
+ // scaled value in [0, 1] (xAPI).
430
+ if (!Number.isFinite(score)) {
431
+ this.#score = null;
432
+ return;
433
+ }
434
+ this.#score = Math.max(0, Math.min(100, score));
248
435
  }
249
436
 
250
437
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
251
- this.#completionStatus = status;
252
438
  if (status !== 'complete' || this.#completedSent || !this.#publisher) return;
439
+ // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
440
+ if (this.#launchMode !== 'Normal') return;
253
441
  this.#completedSent = true;
442
+ // cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
254
443
  const result: Record<string, unknown> = {
255
444
  completion: true,
256
445
  duration: formatISO8601Duration(this.#durationSeconds),
257
446
  };
258
- if (this.#score !== null) {
259
- result.score = { scaled: this.#score / 100 };
260
- }
261
447
  this.#publisher
262
448
  .sendStatement({
263
449
  verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
264
450
  result,
265
- context: this.#masteryContext(),
451
+ context: this.#cmi5Context({ moveOn: true }),
266
452
  })
453
+ .then(warnOnLRSReject('Completed'))
267
454
  .catch((err) => {
268
455
  console.warn('Tessera cmi5: failed to send Completed statement', err);
269
456
  });
270
- this.#maybeSendSatisfied();
271
457
  }
272
458
 
273
459
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
274
460
  if (status === 'unknown' || this.#successSent || !this.#publisher) return;
461
+ // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
462
+ if (this.#launchMode !== 'Normal') return;
275
463
  this.#successSent = true;
276
- this.#passed = status === 'passed';
277
464
 
278
465
  const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
279
466
  const verbName = status === 'passed' ? 'passed' : 'failed';
@@ -282,18 +469,38 @@ export class CMI5Adapter implements PersistenceAdapter {
282
469
  duration: formatISO8601Duration(this.#durationSeconds),
283
470
  };
284
471
  if (this.#score !== null) {
285
- result.score = { scaled: this.#score / 100 };
472
+ const scaled = this.#score / 100;
473
+ // cmi5 §9.3.4 / §9.3.5 — Passed-with-score requires scaled >=
474
+ // masteryScore; Failed-with-score requires scaled < masteryScore.
475
+ // The author asserted the verb, so on contradiction we keep the
476
+ // verb and drop the score (and warn).
477
+ if (this.#masteryScore !== null) {
478
+ const violatesPassed = status === 'passed' && scaled < this.#masteryScore;
479
+ const violatesFailed = status === 'failed' && scaled >= this.#masteryScore;
480
+ if (violatesPassed || violatesFailed) {
481
+ console.warn(
482
+ `Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
483
+ `${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
484
+ `per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
485
+ `Statement will be sent without a score.`
486
+ );
487
+ } else {
488
+ result.score = { scaled };
489
+ }
490
+ } else {
491
+ result.score = { scaled };
492
+ }
286
493
  }
287
494
  this.#publisher
288
495
  .sendStatement({
289
496
  verb: { id: verb, display: { 'en-US': verbName } },
290
497
  result,
291
- context: this.#masteryContext(),
498
+ context: this.#cmi5Context({ moveOn: true, mastery: true }),
292
499
  })
500
+ .then(warnOnLRSReject(verbName === 'passed' ? 'Passed' : 'Failed'))
293
501
  .catch((err) => {
294
502
  console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
295
503
  });
296
- this.#maybeSendSatisfied();
297
504
  }
298
505
 
299
506
  setDuration(seconds: number): void {
@@ -334,7 +541,10 @@ export class CMI5Adapter implements PersistenceAdapter {
334
541
  },
335
542
  result,
336
543
  })
337
- .catch(() => {});
544
+ .then(warnOnLRSReject('Answered'))
545
+ .catch((err) => {
546
+ console.warn('Tessera cmi5: failed to send Answered statement', err);
547
+ });
338
548
  }
339
549
 
340
550
  commit(): void {
@@ -350,87 +560,102 @@ export class CMI5Adapter implements PersistenceAdapter {
350
560
  // must be the last statement of the cmi5 session (§9.3.6).
351
561
  this.#publisher.markUnloading();
352
562
  const duration = formatISO8601Duration(this.#durationSeconds);
353
- // cmi5 §10.1: when the AU exits without Completed, send Suspended
354
- // first so the LMS distinguishes a deliberate pause from abandonment.
355
- if (!this.#completedSent && this.#completionStatus !== 'complete') {
356
- this.#publisher
357
- .sendStatement({
358
- verb: { id: VERBS.suspended, display: { 'en-US': 'suspended' } },
359
- result: { duration },
360
- })
361
- .catch((err) => {
362
- console.warn('Tessera cmi5: failed to send Suspended statement', err);
363
- });
364
- }
365
- // cmi5 §9.5.4.1: Terminated MUST include result.duration.
563
+ // No Suspended cmi5 doesn't define that verb (§9.3); the LMS
564
+ // handles resume vs Abandoned itself when a new session opens
565
+ // against an active registration (§9.3.6).
566
+ // cmi5 §9.5.4.1 — Terminated MUST include result.duration.
366
567
  this.#publisher
367
568
  .sendStatement({
368
569
  verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
369
570
  result: { duration },
571
+ context: this.#cmi5Context(),
370
572
  })
573
+ .then(warnOnLRSReject('Terminated'))
371
574
  .catch((err) => {
372
575
  console.warn('Tessera cmi5: failed to send Terminated statement', err);
373
576
  });
374
577
  }
375
578
 
376
- // ---- Private helpers ----
377
-
378
579
  /**
379
- * Build a context object carrying the cmi5 masteryscore extension when
380
- * the LMS provided one. Returns undefined otherwise so the publisher
381
- * doesn't add an empty `context.extensions` block.
580
+ * cmi5 §10.2.6 explicit-Exit path. Terminate, wait for the
581
+ * publisher queue to drain so Terminated lands first, then redirect
582
+ * to `returnURL`. `terminate()` alone (called from pagehide) can't
583
+ * redirect — the page is already unloading.
382
584
  */
383
- #masteryContext(): Record<string, unknown> | undefined {
384
- if (this.#masteryScore === null) return undefined;
385
- return {
386
- extensions: { [CMI5_MASTERYSCORE_EXT]: this.#masteryScore },
387
- };
585
+ async exit(): Promise<void> {
586
+ this.terminate();
587
+ if (this.#publisher) {
588
+ // chainTask with a no-op task awaits the queue head.
589
+ try {
590
+ await this.#publisher.chainTask(async () => {});
591
+ } catch {
592
+ // never rejects today; don't let a refactor block redirect.
593
+ }
594
+ }
595
+ if (this.#returnURL && typeof window !== 'undefined') {
596
+ window.location.assign(this.#returnURL);
597
+ }
388
598
  }
389
599
 
600
+ // ---- Private helpers ----
601
+
390
602
  /**
391
- * cmi5 §9.5.3: when the moveOn criterion has been met, the AU MAY send
392
- * a Satisfied statement so LMSes that don't compute moveOn themselves
393
- * still see satisfaction. NotApplicable disables emission entirely.
603
+ * Build the cmi5 context for a Defined Statement, starting from the
604
+ * LMS contextTemplate (§9.6.2 AU MUST NOT overwrite). Adds the
605
+ * cmi5 Category Activity (§9.6.2.1), the moveOn Category for
606
+ * Completed/Passed/Failed (§9.6.2.2), and the masteryScore extension
607
+ * for Passed/Failed (§9.6.3.2).
394
608
  */
395
- #maybeSendSatisfied(): void {
396
- if (this.#satisfiedSent || !this.#publisher) return;
397
- if (this.#moveOn === 'NotApplicable') return;
398
-
399
- let satisfied = false;
400
- switch (this.#moveOn) {
401
- case 'Passed':
402
- satisfied = this.#passed;
403
- break;
404
- case 'Completed':
405
- satisfied = this.#completedSent;
406
- break;
407
- case 'CompletedAndPassed':
408
- satisfied = this.#completedSent && this.#passed;
409
- break;
410
- case 'CompletedOrPassed':
411
- satisfied = this.#completedSent || this.#passed;
412
- break;
609
+ #cmi5Context(
610
+ opts: { moveOn?: boolean; mastery?: boolean } = {}
611
+ ): Record<string, unknown> {
612
+ const tmpl = this.#launchData?.contextTemplate ?? {};
613
+ const tmplActivities = (tmpl.contextActivities ?? {}) as Record<string, unknown>;
614
+
615
+ // Concat-dedupe category to preserve any template-supplied entries
616
+ // (§10.2.1 forbids overwriting them).
617
+ const seen = new Set<string>();
618
+ const category: Array<{ id: string; objectType: string }> = [];
619
+ const push = (id: string) => {
620
+ if (!seen.has(id)) {
621
+ seen.add(id);
622
+ category.push({ id, objectType: 'Activity' });
623
+ }
624
+ };
625
+ const templateCategory = Array.isArray((tmplActivities as { category?: unknown }).category)
626
+ ? ((tmplActivities as { category: Array<{ id: string; objectType?: string }> }).category)
627
+ : [];
628
+ for (const c of templateCategory) {
629
+ if (c && typeof c.id === 'string') push(c.id);
413
630
  }
414
- if (!satisfied) return;
631
+ push(CMI5_CATEGORY_CMI5);
632
+ if (opts.moveOn) push(CMI5_CATEGORY_MOVEON);
415
633
 
416
- this.#satisfiedSent = true;
417
- this.#publisher
418
- .sendStatement({
419
- verb: { id: VERBS.satisfied, display: { 'en-US': 'satisfied' } },
420
- result: { duration: formatISO8601Duration(this.#durationSeconds) },
421
- context: this.#masteryContext(),
422
- })
423
- .catch((err) => {
424
- console.warn('Tessera cmi5: failed to send Satisfied statement', err);
425
- });
634
+ const contextActivities: Record<string, unknown> = {
635
+ ...tmplActivities,
636
+ category,
637
+ };
638
+
639
+ const ctx: Record<string, unknown> = {
640
+ ...(tmpl as Record<string, unknown>),
641
+ contextActivities,
642
+ };
643
+ // cmi5 §9.6.3.2 — masteryScore extension is scoped to Passed/Failed.
644
+ if (opts.mastery && this.#masteryScore !== null) {
645
+ ctx.extensions = {
646
+ ...((tmpl.extensions ?? {}) as Record<string, unknown>),
647
+ [CMI5_MASTERYSCORE_EXT]: this.#masteryScore,
648
+ };
649
+ }
650
+ return ctx;
426
651
  }
427
652
 
428
- #buildStateUrl(): string {
653
+ #buildStateUrl(stateId: string = 'tessera-state'): string {
429
654
  const agentJson = JSON.stringify(this.#actor);
430
655
  const params = new URLSearchParams({
431
656
  activityId: this.#activityId,
432
657
  agent: agentJson,
433
- stateId: 'tessera-state',
658
+ stateId,
434
659
  });
435
660
  // registration is optional per CMI5 spec — omit it when not provided
436
661
  if (this.#registration) {
@@ -439,6 +664,58 @@ export class CMI5Adapter implements PersistenceAdapter {
439
664
  return `${this.#endpoint}activities/state?${params.toString()}`;
440
665
  }
441
666
 
667
+ /** cmi5 §11 — Agent Profile URL. Scoped to agent only (no activity/registration). */
668
+ #buildAgentProfileUrl(profileId: string): string {
669
+ const agentJson = JSON.stringify(this.#actor);
670
+ const params = new URLSearchParams({
671
+ agent: agentJson,
672
+ profileId,
673
+ });
674
+ return `${this.#endpoint}agents/profile?${params.toString()}`;
675
+ }
676
+
677
+ /** GET the cmi5 §10 `LMS.LaunchData` document. Null if absent — strict LRSes will then reject Defined Statements. */
678
+ async #fetchLaunchData(): Promise<CMI5LaunchData | null> {
679
+ try {
680
+ const url = this.#buildStateUrl(LMS_LAUNCH_DATA_STATE_ID);
681
+ const resp = await this.#xapiFetch(url, { method: 'GET' });
682
+ if (resp.ok) {
683
+ return (await resp.json()) as CMI5LaunchData;
684
+ }
685
+ console.warn(
686
+ `Tessera cmi5: LMS.LaunchData State GET returned ${resp.status}; ` +
687
+ 'cmi5 Defined Statements may be rejected by strict LRSes ' +
688
+ '(missing Publisher Activity / session id).'
689
+ );
690
+ } catch (err) {
691
+ console.warn(
692
+ `Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`
693
+ );
694
+ }
695
+ return null;
696
+ }
697
+
698
+ /** GET cmi5 §11.1 Learner Preferences. 404 is normal (no prefs set). */
699
+ async #fetchLearnerPreferences(): Promise<CMI5LearnerPreferences | null> {
700
+ try {
701
+ const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
702
+ const resp = await this.#xapiFetch(url, { method: 'GET' });
703
+ if (resp.ok) {
704
+ return (await resp.json()) as CMI5LearnerPreferences;
705
+ }
706
+ if (resp.status !== 404) {
707
+ console.warn(
708
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`
709
+ );
710
+ }
711
+ } catch (err) {
712
+ console.warn(
713
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`
714
+ );
715
+ }
716
+ return null;
717
+ }
718
+
442
719
  async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
443
720
  const headers = new Headers(options.headers);
444
721
  if (this.#authToken) {
@@ -568,11 +568,30 @@ export class XAPIPublisher {
568
568
  })
569
569
  );
570
570
  }
571
- return {
572
- ok: false,
573
- status: resp.status,
574
- error: new Error(`LRS responded ${resp.status}`),
575
- };
571
+ // Append the LRS body to the error message so callers see the
572
+ // specific reason (e.g. "Forbidden cmi5 defined statement: ...").
573
+ // Cap at 500 chars; on read failure, fall back to bare status.
574
+ if (typeof resp.text !== 'function') {
575
+ return {
576
+ ok: false,
577
+ status: resp.status,
578
+ error: new Error(`LRS responded ${resp.status}`),
579
+ };
580
+ }
581
+ return resp.text().then(
582
+ (respBody): SendOutcome => ({
583
+ ok: false,
584
+ status: resp.status,
585
+ error: new Error(
586
+ `LRS responded ${resp.status}${respBody ? `: ${respBody.slice(0, 500)}` : ''}`
587
+ ),
588
+ }),
589
+ (): SendOutcome => ({
590
+ ok: false,
591
+ status: resp.status,
592
+ error: new Error(`LRS responded ${resp.status}`),
593
+ })
594
+ );
576
595
  }
577
596
 
578
597
  #resolveAuth(forceRefresh: boolean): Promise<string> {