tessera-learn 0.2.2 → 0.3.0

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.
Files changed (48) hide show
  1. package/AGENTS.md +161 -535
  2. package/README.md +2 -2
  3. package/dist/{audit-B9VHgVjk.js → audit-DkXqQTqn.js} +92 -38
  4. package/dist/audit-DkXqQTqn.js.map +1 -0
  5. package/dist/{build-commands-D127jw0J.js → build-commands-CyzuCDXg.js} +2 -2
  6. package/dist/{build-commands-D127jw0J.js.map → build-commands-CyzuCDXg.js.map} +1 -1
  7. package/dist/{inline-config-eHjv9XuA.js → inline-config-BEXyRqsJ.js} +2 -2
  8. package/dist/{inline-config-eHjv9XuA.js.map → inline-config-BEXyRqsJ.js.map} +1 -1
  9. package/dist/plugin/cli.d.ts.map +1 -1
  10. package/dist/plugin/cli.js +62 -54
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +280 -3
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +3 -3
  15. package/dist/{plugin--8H9xQIl.js → plugin-CFUFgwHB.js} +126 -83
  16. package/dist/plugin-CFUFgwHB.js.map +1 -0
  17. package/package.json +12 -9
  18. package/src/components/DefaultLayout.svelte +2 -5
  19. package/src/components/Quiz.svelte +18 -26
  20. package/src/plugin/a11y/audit.ts +8 -13
  21. package/src/plugin/a11y-cli.ts +1 -4
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/cli.ts +46 -48
  24. package/src/plugin/csp.ts +59 -0
  25. package/src/plugin/duplicate-cli.ts +37 -1
  26. package/src/plugin/export.ts +56 -27
  27. package/src/plugin/index.ts +117 -61
  28. package/src/plugin/manifest.ts +3 -23
  29. package/src/plugin/new-cli.ts +2 -0
  30. package/src/plugin/validate-cli.ts +10 -4
  31. package/src/plugin/validation.ts +48 -12
  32. package/src/runtime/App.svelte +10 -8
  33. package/src/runtime/Sidebar.svelte +3 -1
  34. package/src/runtime/adapters/cmi5.ts +59 -402
  35. package/src/runtime/adapters/discovery.ts +11 -0
  36. package/src/runtime/adapters/index.ts +27 -60
  37. package/src/runtime/adapters/lms-error.ts +61 -0
  38. package/src/runtime/adapters/scorm2004.ts +2 -1
  39. package/src/runtime/adapters/web.ts +19 -4
  40. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  41. package/src/runtime/adapters/xapi.ts +26 -0
  42. package/src/runtime/types.ts +19 -1
  43. package/src/runtime/xapi/publisher.ts +5 -1
  44. package/src/runtime/xapi/setup.ts +24 -15
  45. package/src/virtual.d.ts +4 -1
  46. package/templates/course/course.config.js +1 -0
  47. package/dist/audit-B9VHgVjk.js.map +0 -1
  48. package/dist/plugin--8H9xQIl.js.map +0 -1
@@ -1,42 +1,12 @@
1
- import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
- import type { Interaction } from '../interaction.js';
3
- import {
4
- formatResponse,
5
- formatCorrectPattern,
6
- XAPI_INTERACTION_FORMAT,
7
- } from '../interaction-format.js';
8
- import { formatISO8601Duration, parseScaled01 } from './format.js';
9
- import { XAPIPublisher } from '../xapi/publisher.js';
10
- import { X_API_VERSION } from '../xapi/version.js';
11
- import type { XAPIAgent } from '../xapi/types.js';
12
-
13
- /**
14
- * xAPI verb IRIs used by CMI5.
15
- */
16
- const VERBS = {
17
- initialized: 'http://adlnet.gov/expapi/verbs/initialized',
18
- answered: 'http://adlnet.gov/expapi/verbs/answered',
19
- completed: 'http://adlnet.gov/expapi/verbs/completed',
20
- passed: 'http://adlnet.gov/expapi/verbs/passed',
21
- failed: 'http://adlnet.gov/expapi/verbs/failed',
22
- terminated: 'http://adlnet.gov/expapi/verbs/terminated',
23
- // Intentionally absent: "satisfied" (LMS-only, §9.3.9) and
24
- // "suspended" (not a cmi5-defined verb — §9.3 enumerates nine, none
25
- // of them Suspended). The LMS infers Abandoned vs resume from
26
- // registration state when Terminated lands without Completed.
27
- } as const;
28
-
29
- const CMI_INTERACTION_TYPE =
30
- 'http://adlnet.gov/expapi/activities/cmi.interaction';
1
+ import { parseScaled01 } from './format.js';
2
+ import { BaseXAPILaunchAdapter } from './xapi-launch-base.js';
31
3
 
32
4
  const CMI5_MASTERYSCORE_EXT =
33
5
  'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
34
6
 
35
- // cmi5 §9.6 — every cmi5 Defined Statement MUST carry the "cmi5" Category
36
- // Activity in context.contextActivities.category, and "completed", "passed",
37
- // "failed" MUST additionally carry the "moveOn" Category. Without these, an
38
- // LRS will accept the statement as an arbitrary xAPI verb but won't roll it
39
- // up into cmi5 lifecycle state — the LMS never sees the AU as completed.
7
+ // cmi5 §9.6.2 — Defined Statements MUST carry the "cmi5" Category; completed/
8
+ // passed/failed MUST also carry "moveOn", or the LRS won't roll them up into
9
+ // cmi5 lifecycle state and the LMS never sees the AU as completed.
40
10
  const CMI5_CATEGORY_CMI5 = 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
41
11
  const CMI5_CATEGORY_MOVEON =
42
12
  'https://w3id.org/xapi/cmi5/context/categories/moveon';
@@ -78,23 +48,6 @@ interface CMI5LaunchData {
78
48
  [k: string]: unknown;
79
49
  }
80
50
 
81
- /** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
82
- function warnOnLRSReject(
83
- verbName: string,
84
- ): (res: {
85
- destinations?: Array<{ ok?: boolean; status?: number; error?: Error }>;
86
- }) => void {
87
- return (res) => {
88
- const dest = res.destinations?.[0];
89
- if (dest && !dest.ok) {
90
- console.warn(
91
- `Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
92
- dest.error,
93
- );
94
- }
95
- };
96
- }
97
-
98
51
  /**
99
52
  * CMI5 persistence adapter using xAPI.
100
53
  *
@@ -105,49 +58,33 @@ function warnOnLRSReject(
105
58
  * and `cmi5Mode` injects the required `sessionid` context extension on
106
59
  * every statement (cmi5 §9.6.1.1).
107
60
  *
108
- * State API GET/PUT cannot be expressed as `sendStatement` calls (different
109
- * URL, different verbs), so they go through `chainTask` so a state PUT is
110
- * still ordered relative to neighboring statements.
61
+ * The version-neutral launch lifecycle lives in BaseXAPILaunchAdapter; this
62
+ * class layers cmi5 specifics on top: fetch-token auth, LMS.LaunchData, the
63
+ * cmi5 context (Category/moveOn/masteryScore), launch-mode gating, and the
64
+ * Agent Profile GET.
111
65
  */
112
- export class CMI5Adapter implements PersistenceAdapter {
113
- #publisher: XAPIPublisher | null = null;
114
- #endpoint = '';
115
- #activityId = '';
116
- #actor: XAPIAgent | null = null;
117
- #registration: string | undefined;
118
- #authToken = '';
119
-
120
- // Stored internally for inclusion in statements
121
- #score: number | null = null;
122
- #durationSeconds = 0;
123
- #state: SavedState | null = null;
124
- #completedEmitted = false;
125
- #lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
126
- #terminated = false;
127
-
66
+ export class CMI5Adapter extends BaseXAPILaunchAdapter {
128
67
  // cmi5 §8 launch params. masteryScore (when present) overrides the
129
68
  // course's manifest passingScore for this launch — the LMS is the authority.
130
69
  #masteryScore: number | null = null;
131
70
 
132
- // cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
133
- // (§9.6.2) Publisher Activity and session id live there, and strict
134
- // LRSes validate every Defined Statement against it.
71
+ // cmi5 §10 LMS.LaunchData; `contextTemplate` (§9.6.2) is the base context
72
+ // strict LRSes validate every Defined Statement against.
135
73
  #launchData: CMI5LaunchData | null = null;
136
74
  /** cmi5 §10.2.2 — Browse/Review forbid every Defined Statement except Initialized/Terminated. */
137
75
  #launchMode: CMI5LaunchMode = 'Normal';
138
- /** cmi5 §10.2.6 — AU redirects here on `exit()`. */
139
- #returnURL: string | undefined;
140
76
 
141
77
  async init(): Promise<void> {
78
+ this.version = '1.0.3';
79
+ this.logName = 'cmi5';
142
80
  const params = new URLSearchParams(window.location.search);
143
81
  const fetchUrl = params.get('fetch');
144
- // Normalize endpoint to always have a trailing slash so URL concatenation is safe
145
- this.#endpoint = (params.get('endpoint') || '').replace(/\/?$/, '/');
82
+ this.endpoint = (params.get('endpoint') || '').replace(/\/?$/, '/');
146
83
  const reg = params.get('registration') || '';
147
84
  // xAPI requires `context.registration` to be a UUID; sending an empty
148
85
  // string makes LRSes 400. Omit when the LMS didn't provide one.
149
- this.#registration = reg ? reg : undefined;
150
- this.#activityId = params.get('activityId') || '';
86
+ this.registration = reg ? reg : undefined;
87
+ this.activityId = params.get('activityId') || '';
151
88
 
152
89
  const rawMastery = params.get('masteryScore');
153
90
  if (rawMastery !== null && rawMastery !== '') {
@@ -161,22 +98,7 @@ export class CMI5Adapter implements PersistenceAdapter {
161
98
  }
162
99
  }
163
100
 
164
- // Malformed actor JSON is a launch-time failure: an empty {} actor
165
- // would fail every Identified-Agent check downstream and produce
166
- // confusing 400s on every send. Fail loud here instead.
167
- const rawActor = params.get('actor') || '';
168
- try {
169
- const parsed = JSON.parse(rawActor);
170
- if (!parsed || typeof parsed !== 'object') {
171
- throw new Error('actor must be an object');
172
- }
173
- this.#actor = parsed as XAPIAgent;
174
- } catch (err) {
175
- throw new Error(
176
- `Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`,
177
- { cause: err },
178
- );
179
- }
101
+ this.parseActorParam(params.get('actor') || '');
180
102
 
181
103
  // The cmi5 fetch URL is single-use (§6.2): if it fails we can't retry,
182
104
  // and continuing with no token will 401-loop until auth is marked dead.
@@ -240,18 +162,16 @@ export class CMI5Adapter implements PersistenceAdapter {
240
162
  if (!token) {
241
163
  token = text.replace(/^auth-token=/, '').trim();
242
164
  }
243
- this.#authToken = token;
244
- if (!this.#authToken) {
165
+ this.authToken = token;
166
+ if (!this.authToken) {
245
167
  throw new Error(
246
168
  'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.',
247
169
  );
248
170
  }
249
171
 
250
- // cmi5 §10 — LaunchData is the only spec-defined channel for the
251
- // session id (§9.6.3.1) and Publisher Activity 9.6.2.3) the LRS
252
- // validates against, plus launchMode/returnURL/masteryScore (§10.2).
253
- // LaunchData values override the URL masteryScore parsed earlier
254
- // (§10.2.4 makes it authoritative).
172
+ // cmi5 §10 — LaunchData carries the session id (§9.6.3.1), Publisher
173
+ // Activity (§9.6.2.3), and launchMode/returnURL/masteryScore10.2); its
174
+ // masteryScore overrides the URL value parsed earlier (§10.2.4).
255
175
  this.#launchData = await this.#fetchLaunchData();
256
176
  const tmpl = this.#launchData?.contextTemplate ?? {};
257
177
  let sessionId: string | undefined;
@@ -270,7 +190,7 @@ export class CMI5Adapter implements PersistenceAdapter {
270
190
  typeof this.#launchData.returnURL === 'string' &&
271
191
  this.#launchData.returnURL
272
192
  ) {
273
- this.#returnURL = this.#launchData.returnURL;
193
+ this.returnURL = this.#launchData.returnURL;
274
194
  }
275
195
  const launchMastery = parseScaled01(this.#launchData.masteryScore);
276
196
  if (launchMastery !== null) {
@@ -283,58 +203,14 @@ export class CMI5Adapter implements PersistenceAdapter {
283
203
  // is legitimate (no prefs set); the GET itself is what's required.
284
204
  await this.#fetchLearnerPreferences();
285
205
 
286
- this.#publisher = new XAPIPublisher({
287
- endpoint: this.#endpoint,
288
- auth: this.#authToken,
289
- actor: this.#actor,
290
- activityId: this.#activityId,
291
- registration: this.#registration,
292
- sessionId,
293
- cmi5Mode: true,
294
- });
295
- await this.#publisher.init();
206
+ const publisher = this.createPublisher({ sessionId, cmi5Mode: true });
207
+ await publisher.init();
296
208
 
297
- // cmi5 §9.3.2 — queue Initialized before the resume State GET so a
298
- // slow LRS can't push it past the spec's "reasonable period". The
299
- // publisher queue keeps it ordered before any later Defined Statement.
300
- this.#publisher
301
- .sendStatement({
302
- verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
303
- context: this.#cmi5Context(),
304
- })
305
- .then(warnOnLRSReject('Initialized'))
306
- .catch((err) => {
307
- console.warn('Tessera cmi5: failed to send Initialized statement', err);
308
- });
209
+ // cmi5 §9.3.2 — queue Initialized before the resume State GET so a slow
210
+ // LRS can't push it past the spec's "reasonable period".
211
+ this.sendInitialized();
309
212
 
310
- // Retrieve saved state from xAPI State API. The State API is a different
311
- // URL than statements/, so it doesn't go through the publisher's send
312
- // path — but we still use the same auth/headers.
313
- try {
314
- const stateUrl = this.#buildStateUrl();
315
- const resp = await this.#xapiFetch(stateUrl, { method: 'GET' });
316
- if (resp.ok) {
317
- this.#state = await resp.json();
318
- } else if (resp.status !== 404) {
319
- console.warn(
320
- `Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`,
321
- );
322
- }
323
- } catch (err) {
324
- console.warn(
325
- `Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`,
326
- );
327
- this.#state = null;
328
- }
329
- }
330
-
331
- /**
332
- * Returns the underlying publisher so the xAPI client can fan author-
333
- * issued statements to the LMS-launched LRS via `endpoint: 'lms'`. Null
334
- * when init() hasn't run yet.
335
- */
336
- getPublisher(): XAPIPublisher | null {
337
- return this.#publisher;
213
+ await this.loadResumeState();
338
214
  }
339
215
 
340
216
  /**
@@ -352,222 +228,37 @@ export class CMI5Adapter implements PersistenceAdapter {
352
228
  return this.#launchMode;
353
229
  }
354
230
 
355
- getState(): SavedState | null {
356
- return this.#state;
357
- }
358
-
359
- saveState(state: SavedState): void {
360
- this.#state = state;
361
- if (!this.#publisher) return;
362
- // Chain the State PUT onto the publisher's queue so it lands before
363
- // Terminated. We can't use sendStatement here (different URL/verb).
364
- void this.#publisher.chainTask(async () => {
365
- try {
366
- const resp = await this.#xapiFetch(this.#buildStateUrl(), {
367
- method: 'PUT',
368
- headers: { 'Content-Type': 'application/json' },
369
- body: JSON.stringify(state),
370
- });
371
- if (!resp.ok) {
372
- console.warn(
373
- `Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`,
374
- );
375
- }
376
- } catch (err) {
377
- console.warn('Tessera: Failed to save CMI5 state', err);
378
- }
379
- });
380
- }
381
-
382
- setScore(score: number): void {
383
- // Clamped to [0, 100] so the /100 division yields a spec-legal
384
- // scaled value in [0, 1] (xAPI).
385
- if (!Number.isFinite(score)) {
386
- this.#score = null;
387
- return;
388
- }
389
- this.#score = Math.max(0, Math.min(100, score));
390
- }
391
-
392
- seedLifecycle(
393
- completion: 'incomplete' | 'complete',
394
- success: 'unknown' | 'passed' | 'failed',
395
- ): void {
396
- if (completion === 'complete') this.#completedEmitted = true;
397
- if (success === 'passed' || success === 'failed') {
398
- this.#lastSuccessEmitted = success;
399
- }
400
- }
401
-
402
- setCompletionStatus(status: 'incomplete' | 'complete'): void {
403
- if (status !== 'complete' || this.#completedEmitted || !this.#publisher)
404
- return;
405
- // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
406
- if (this.#launchMode !== 'Normal') return;
407
- this.#completedEmitted = true;
408
- // cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
409
- const result: Record<string, unknown> = {
410
- completion: true,
411
- duration: formatISO8601Duration(this.#durationSeconds),
412
- };
413
- this.#publisher
414
- .sendStatement({
415
- verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
416
- result,
417
- context: this.#cmi5Context({ moveOn: true }),
418
- })
419
- .then(warnOnLRSReject('Completed'))
420
- .catch((err) => {
421
- console.warn('Tessera cmi5: failed to send Completed statement', err);
422
- });
423
- }
424
-
425
- setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
426
- if (status === 'unknown' || !this.#publisher) return;
427
- if (status === this.#lastSuccessEmitted) return;
428
- // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
429
- if (this.#launchMode !== 'Normal') return;
430
- this.#lastSuccessEmitted = status;
431
-
432
- const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
433
- const verbName = status === 'passed' ? 'passed' : 'failed';
434
- const result: Record<string, unknown> = {
435
- success: status === 'passed',
436
- duration: formatISO8601Duration(this.#durationSeconds),
437
- };
438
- if (this.#score !== null) {
439
- const scaled = this.#score / 100;
440
- // cmi5 §9.3.4 / §9.3.5 — Passed-with-score requires scaled >=
441
- // masteryScore; Failed-with-score requires scaled < masteryScore.
442
- // The author asserted the verb, so on contradiction we keep the
443
- // verb and drop the score (and warn).
444
- if (this.#masteryScore !== null) {
445
- const violatesPassed =
446
- status === 'passed' && scaled < this.#masteryScore;
447
- const violatesFailed =
448
- status === 'failed' && scaled >= this.#masteryScore;
449
- if (violatesPassed || violatesFailed) {
450
- console.warn(
451
- `Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
452
- `${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
453
- `per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
454
- `Statement will be sent without a score.`,
455
- );
456
- } else {
457
- result.score = { scaled };
458
- }
459
- } else {
460
- result.score = { scaled };
461
- }
462
- }
463
- this.#publisher
464
- .sendStatement({
465
- verb: { id: verb, display: { 'en-US': verbName } },
466
- result,
467
- context: this.#cmi5Context({ moveOn: true, mastery: true }),
468
- })
469
- .then(warnOnLRSReject(verbName === 'passed' ? 'Passed' : 'Failed'))
470
- .catch((err) => {
471
- console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
472
- });
473
- }
474
-
475
- setDuration(seconds: number): void {
476
- this.#durationSeconds = seconds;
477
- }
478
-
479
- setExit(_mode: 'suspend' | 'normal'): void {
480
- // cmi5 has no analogue to SCORM cmi.exit; suspend semantics are carried
481
- // by *not* sending Completed/Terminated yet. No-op.
482
- }
483
-
484
- reportInteraction(
485
- questionId: string,
486
- interaction: Interaction,
487
- correct: boolean | null,
488
- ): void {
489
- if (!this.#publisher) return;
490
- const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
491
- const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
492
- const definition: Record<string, unknown> = {
493
- type: CMI_INTERACTION_TYPE,
494
- interactionType: interaction.type,
495
- };
496
- if (pattern !== null) {
497
- definition.correctResponsesPattern = [pattern];
498
- }
499
- const result: Record<string, unknown> = { response };
500
- if (correct !== null) {
501
- result.success = correct;
502
- }
503
- this.#publisher
504
- .sendStatement({
505
- verb: { id: VERBS.answered, display: { 'en-US': 'answered' } },
506
- object: {
507
- id: `${this.#activityId}#${questionId}`,
508
- objectType: 'Activity',
509
- definition,
510
- },
511
- result,
512
- })
513
- .then(warnOnLRSReject('Answered'))
514
- .catch((err) => {
515
- console.warn('Tessera cmi5: failed to send Answered statement', err);
516
- });
517
- }
518
-
519
- commit(): void {
520
- // No-op — xAPI calls are sent individually per statement.
521
- }
522
-
523
- terminate(): void {
524
- if (this.#terminated) return;
525
- this.#terminated = true;
526
- if (!this.#publisher) return;
527
- // Mark unloading so all subsequent (queued) requests use keepalive,
528
- // and so the XAPIClient stops accepting new author sends — Terminated
529
- // must be the last statement of the cmi5 session (§9.3.6).
530
- this.#publisher.markUnloading();
531
- const duration = formatISO8601Duration(this.#durationSeconds);
532
- // No Suspended — cmi5 doesn't define that verb (§9.3); the LMS
533
- // handles resume vs Abandoned itself when a new session opens
534
- // against an active registration (§9.3.6).
535
- // cmi5 §9.5.4.1 — Terminated MUST include result.duration.
536
- this.#publisher
537
- .sendStatement({
538
- verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
539
- result: { duration },
540
- context: this.#cmi5Context(),
541
- })
542
- .then(warnOnLRSReject('Terminated'))
543
- .catch((err) => {
544
- console.warn('Tessera cmi5: failed to send Terminated statement', err);
545
- });
231
+ /** cmi5 §10.2.2 Browse/Review forbid Completed/Passed/Failed. */
232
+ protected isDefinedStatementAllowed(): boolean {
233
+ return this.#launchMode === 'Normal';
546
234
  }
547
235
 
548
236
  /**
549
- * cmi5 §10.2.6explicit-Exit path. Terminate, wait for the
550
- * publisher queue to drain so Terminated lands first, then redirect
551
- * to `returnURL`. `terminate()` alone (called from pagehide) can't
552
- * redirect — the page is already unloading.
237
+ * cmi5 §9.3.4 / §9.3.5 Passed-with-score requires scaled >=
238
+ * masteryScore; Failed-with-score requires scaled < masteryScore. The
239
+ * author asserted the verb, so on contradiction keep the verb and drop
240
+ * the score (and warn).
553
241
  */
554
- async exit(): Promise<void> {
555
- this.terminate();
556
- if (this.#publisher) {
557
- // chainTask with a no-op task awaits the queue head.
558
- try {
559
- await this.#publisher.chainTask(async () => {});
560
- } catch {
561
- // never rejects today; don't let a refactor block redirect.
242
+ protected scoreForSuccess(status: 'passed' | 'failed'): number | null {
243
+ if (this.score === null) return null;
244
+ const scaled = this.score / 100;
245
+ if (this.#masteryScore !== null) {
246
+ const violatesPassed = status === 'passed' && scaled < this.#masteryScore;
247
+ const violatesFailed =
248
+ status === 'failed' && scaled >= this.#masteryScore;
249
+ if (violatesPassed || violatesFailed) {
250
+ console.warn(
251
+ `Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
252
+ `${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
253
+ `per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
254
+ `Statement will be sent without a score.`,
255
+ );
256
+ return null;
562
257
  }
563
258
  }
564
- if (this.#returnURL && typeof window !== 'undefined') {
565
- window.location.assign(this.#returnURL);
566
- }
259
+ return scaled;
567
260
  }
568
261
 
569
- // ---- Private helpers ----
570
-
571
262
  /**
572
263
  * Build the cmi5 context for a Defined Statement, starting from the
573
264
  * LMS contextTemplate (§9.6.2 — AU MUST NOT overwrite). Adds the
@@ -575,7 +266,7 @@ export class CMI5Adapter implements PersistenceAdapter {
575
266
  * Completed/Passed/Failed (§9.6.2.2), and the masteryScore extension
576
267
  * for Passed/Failed (§9.6.3.2).
577
268
  */
578
- #cmi5Context(
269
+ protected buildContext(
579
270
  opts: { moveOn?: boolean; mastery?: boolean } = {},
580
271
  ): Record<string, unknown> {
581
272
  const tmpl = this.#launchData?.contextTemplate ?? {};
@@ -619,35 +310,21 @@ export class CMI5Adapter implements PersistenceAdapter {
619
310
  return ctx;
620
311
  }
621
312
 
622
- #buildStateUrl(stateId: string = 'tessera-state'): string {
623
- const agentJson = JSON.stringify(this.#actor);
624
- const params = new URLSearchParams({
625
- activityId: this.#activityId,
626
- agent: agentJson,
627
- stateId,
628
- });
629
- // registration is optional per CMI5 spec — omit it when not provided
630
- if (this.#registration) {
631
- params.set('registration', this.#registration);
632
- }
633
- return `${this.#endpoint}activities/state?${params.toString()}`;
634
- }
635
-
636
313
  /** cmi5 §11 — Agent Profile URL. Scoped to agent only (no activity/registration). */
637
314
  #buildAgentProfileUrl(profileId: string): string {
638
- const agentJson = JSON.stringify(this.#actor);
315
+ const agentJson = JSON.stringify(this.actor);
639
316
  const params = new URLSearchParams({
640
317
  agent: agentJson,
641
318
  profileId,
642
319
  });
643
- return `${this.#endpoint}agents/profile?${params.toString()}`;
320
+ return `${this.endpoint}agents/profile?${params.toString()}`;
644
321
  }
645
322
 
646
323
  /** GET the cmi5 §10 `LMS.LaunchData` document. Null if absent — strict LRSes will then reject Defined Statements. */
647
324
  async #fetchLaunchData(): Promise<CMI5LaunchData | null> {
648
325
  try {
649
- const url = this.#buildStateUrl(LMS_LAUNCH_DATA_STATE_ID);
650
- const resp = await this.#xapiFetch(url, { method: 'GET' });
326
+ const url = this.buildStateUrl(LMS_LAUNCH_DATA_STATE_ID);
327
+ const resp = await this.xapiFetch(url, { method: 'GET' });
651
328
  if (resp.ok) {
652
329
  return (await resp.json()) as CMI5LaunchData;
653
330
  }
@@ -672,7 +349,7 @@ export class CMI5Adapter implements PersistenceAdapter {
672
349
  async #fetchLearnerPreferences(): Promise<void> {
673
350
  try {
674
351
  const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
675
- const resp = await this.#xapiFetch(url, { method: 'GET' });
352
+ const resp = await this.xapiFetch(url, { method: 'GET' });
676
353
  if (!resp.ok && resp.status !== 404) {
677
354
  console.warn(
678
355
  `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`,
@@ -684,24 +361,4 @@ export class CMI5Adapter implements PersistenceAdapter {
684
361
  );
685
362
  }
686
363
  }
687
-
688
- async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
689
- const headers = new Headers(options.headers);
690
- if (this.#authToken) {
691
- // Basic, not Bearer — cmi5 §6.2 specifies the LMS-issued token is
692
- // used as a Basic Authorization credential.
693
- headers.set('Authorization', `Basic ${this.#authToken}`);
694
- }
695
- headers.set('X-Experience-API-Version', X_API_VERSION);
696
-
697
- // Mirror the publisher: once the page is unloading, every State API
698
- // write needs keepalive or the browser will cancel it during teardown.
699
- // saveState is the suspend payload — losing it costs the resume.
700
- const keepalive = this.#publisher?.isUnloading() ?? false;
701
- return fetch(url, {
702
- ...options,
703
- headers,
704
- ...(keepalive ? { keepalive: true } : {}),
705
- });
706
- }
707
364
  }
@@ -36,3 +36,14 @@ export function hasCMI5LaunchParams(): boolean {
36
36
  params.get('actor')
37
37
  );
38
38
  }
39
+
40
+ /** Plain xAPI ("Tin Can") launch params on the URL. No fetch token required; `auth` is the Basic credential the LMS supplies in the launch link. */
41
+ export function hasXAPILaunchParams(): boolean {
42
+ const params = new URLSearchParams(window.location.search);
43
+ return !!(
44
+ params.get('endpoint') &&
45
+ params.get('auth') &&
46
+ params.get('actor') &&
47
+ params.get('activity_id')
48
+ );
49
+ }