coursecode 0.1.52 → 0.1.54

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 (36) hide show
  1. package/README.md +11 -6
  2. package/bin/cli.js +1 -0
  3. package/framework/css/components/loading.css +11 -3
  4. package/framework/docs/COURSE_AUTHORING_GUIDE.md +8 -6
  5. package/framework/docs/FRAMEWORK_GUIDE.md +22 -21
  6. package/framework/docs/USER_GUIDE.md +9 -3
  7. package/framework/index.html +2 -2
  8. package/framework/js/app/AppUI.js +2 -1
  9. package/framework/js/core/event-bus.js +21 -6
  10. package/framework/js/drivers/lti-driver.js +50 -94
  11. package/framework/js/drivers/proxy-driver.js +8 -5
  12. package/framework/js/main.js +7 -18
  13. package/framework/js/utilities/access-control.js +11 -45
  14. package/framework/js/utilities/data-reporter.js +16 -3
  15. package/framework/version.json +26 -50
  16. package/lib/build-packaging.d.ts +4 -0
  17. package/lib/build-packaging.js +39 -2
  18. package/lib/cloud.js +1 -1
  19. package/lib/course-writer.js +97 -32
  20. package/lib/manifest/cmi5-manifest.js +19 -10
  21. package/lib/manifest/lti-tool-config.js +18 -5
  22. package/lib/manifest/scorm-12-manifest.js +11 -5
  23. package/lib/manifest/scorm-2004-manifest.js +16 -9
  24. package/lib/manifest/xml-utils.js +14 -0
  25. package/lib/preview-routes-api.js +16 -7
  26. package/lib/preview-server.js +49 -28
  27. package/lib/project-utils.js +34 -0
  28. package/lib/proxy-templates/proxy.html +7 -3
  29. package/lib/proxy-templates/scorm-bridge.js +15 -9
  30. package/lib/stub-player.js +19 -2
  31. package/lib/token.js +26 -29
  32. package/lib/upgrade.js +76 -2
  33. package/package.json +32 -25
  34. package/template/course/course-config.js +9 -7
  35. package/template/package.json +13 -4
  36. package/template/vite.config.js +80 -27
@@ -28,7 +28,7 @@ export class ProxyDriver {
28
28
 
29
29
  // Origin of the parent proxy frame (set during initialize)
30
30
  // Used for postMessage origin validation
31
- this._parentOrigin = '*'; // Fallback — overridden with real origin when available
31
+ this._parentOrigin = null;
32
32
 
33
33
  // Pre-fetched cache populated during initialize()
34
34
  this._cache = {
@@ -94,11 +94,14 @@ export class ProxyDriver {
94
94
  const referrerUrl = new URL(document.referrer);
95
95
  this._parentOrigin = referrerUrl.origin;
96
96
  logger.debug('ProxyDriver: Parent origin from referrer:', this._parentOrigin);
97
- } else {
98
- logger.warn('ProxyDriver: No document.referrer available — using wildcard origin. Some LMS strip the Referer header.');
97
+ } else if (window.location.ancestorOrigins?.[0]) {
98
+ this._parentOrigin = new URL(window.location.ancestorOrigins[0]).origin;
99
99
  }
100
100
  } catch {
101
- logger.warn('ProxyDriver: Could not parse document.referrer — using wildcard origin');
101
+ this._parentOrigin = null;
102
+ }
103
+ if (!this._parentOrigin) {
104
+ throw new Error('ProxyDriver: Cannot determine parent origin; refusing insecure wildcard postMessage transport');
102
105
  }
103
106
 
104
107
  try {
@@ -419,7 +422,7 @@ export class ProxyDriver {
419
422
  }
420
423
 
421
424
  // Validate origin when we have a known parent origin
422
- if (this._parentOrigin !== '*' && event.origin !== this._parentOrigin) {
425
+ if (event.source !== window.parent || event.origin !== this._parentOrigin) {
423
426
  logger.warn('ProxyDriver: Rejected message from unexpected origin:', event.origin);
424
427
  return;
425
428
  }
@@ -219,6 +219,8 @@ window.addEventListener('click', (event) => {
219
219
  });
220
220
 
221
221
  function reportInitializationError(error) {
222
+ document.documentElement.removeAttribute('data-course-loading');
223
+
222
224
  // Store error in AppState if initialized (defensive - AppState might not be initialized yet)
223
225
  try {
224
226
  if (AppState.isInitialized()) {
@@ -346,10 +348,8 @@ function applyThemeVariants() {
346
348
  // Apply course layout from config (before theme variants)
347
349
  // Layouts: 'article' (default), 'traditional', 'focused', 'presentation', 'canvas'
348
350
  const layout = courseConfig.layout || 'article';
349
- if (!html.hasAttribute('data-layout')) {
350
- html.setAttribute('data-layout', layout);
351
- logger.debug(`[Layout] Applied data-layout="${layout}" from course config`);
352
- }
351
+ html.setAttribute('data-layout', layout);
352
+ logger.debug(`[Layout] Applied data-layout="${layout}" from course config`);
353
353
 
354
354
  // Apply sidebar enabled state from config
355
355
  // For 'traditional' layout, sidebar is always enabled
@@ -422,20 +422,9 @@ async function initializeCourseApplication() {
422
422
  // 0c. Initialize course channel (if configured) - pub/sub transport for course-to-course comms
423
423
  initCourseChannel(courseConfig);
424
424
 
425
- // 0d. Validate access control (for external hosting / multi-tenant CDN)
426
- // This MUST run early before any LMS initialization to reject unauthorized clients
427
- if (courseConfig.accessControl?.clients) {
428
- const { validateAccess, showUnauthorizedScreen } = await import('./utilities/access-control.js');
429
- const accessResult = validateAccess();
430
- if (!accessResult.valid) {
431
- logger.warn('[AccessControl] Access denied:', accessResult.error);
432
- showUnauthorizedScreen(accessResult.error);
433
- return; // Halt initialization
434
- }
435
- if (accessResult.clientId) {
436
- logger.debug(`[AccessControl] Client authorized: ${accessResult.clientId}`);
437
- }
438
- }
425
+ // External-hosting authorization is enforced before delivery by the
426
+ // configured CDN/backend. Browser-side token comparison is not a
427
+ // security boundary and is intentionally not performed here.
439
428
 
440
429
  // 0e. Initialize breakpoint manager (must be early - before components render)
441
430
  // Applies responsive .bp-* classes to <html> based on viewport width
@@ -1,29 +1,12 @@
1
1
  /**
2
- * Access Control - validates client tokens for multi-tenant CDN hosting
2
+ * Access Control compatibility helper for multi-tenant CDN hosting.
3
3
  *
4
- * Checks URL params (clientId, token) against course config.
5
- * Used by external hosting modes (scorm*-proxy, cmi5-remote).
4
+ * Real authorization must happen at the CDN/backend before course files are
5
+ * served. This module deliberately refuses the legacy browser-token design.
6
6
  */
7
7
 
8
8
  import { courseConfig } from '../../../course/course-config.js';
9
9
 
10
- /**
11
- * Constant-time string comparison to prevent timing side-channel attacks.
12
- * Returns false for mismatched lengths without leaking which byte differs.
13
- * @param {string} a
14
- * @param {string} b
15
- * @returns {boolean}
16
- */
17
- function timingSafeEqual(a, b) {
18
- if (typeof a !== 'string' || typeof b !== 'string') return false;
19
- if (a.length !== b.length) return false;
20
- let mismatch = 0;
21
- for (let i = 0; i < a.length; i++) {
22
- mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
23
- }
24
- return mismatch === 0;
25
- }
26
-
27
10
  /**
28
11
  * Validate access based on URL token
29
12
  * @returns {{ valid: boolean, clientId: string | null, error: string | null }}
@@ -31,45 +14,28 @@ function timingSafeEqual(a, b) {
31
14
  export function validateAccess() {
32
15
  const accessControl = courseConfig.accessControl;
33
16
 
34
- // If no access control clients configured, allow all
35
- if (!accessControl?.clients) {
17
+ if (!accessControl) {
36
18
  return { valid: true, clientId: null, error: null };
37
19
  }
38
20
 
39
- const params = new URLSearchParams(window.location.search);
40
- const clientId = params.get('clientId');
41
- const token = params.get('token');
42
-
43
- // Missing credentials
44
- if (!clientId || !token) {
21
+ if (accessControl.clients) {
45
22
  return {
46
23
  valid: false,
47
24
  clientId: null,
48
- error: 'Missing clientId or token'
25
+ error: 'Legacy browser-side accessControl.clients is insecure and unsupported. Move credentials to .coursecode/access-control.json.'
49
26
  };
50
27
  }
51
28
 
52
- // Unknown client
53
- const client = accessControl.clients?.[clientId];
54
- if (!client) {
29
+ if (accessControl.enforcement !== 'server') {
55
30
  return {
56
31
  valid: false,
57
- clientId,
58
- error: `Unknown client: ${clientId}`
59
- };
60
- }
61
-
62
- // Invalid token (constant-time comparison)
63
- if (!timingSafeEqual(client.token, token)) {
64
- return {
65
- valid: false,
66
- clientId,
67
- error: 'Invalid token'
32
+ clientId: null,
33
+ error: "External access control must use enforcement: 'server'"
68
34
  };
69
35
  }
70
36
 
71
- // Success
72
- return { valid: true, clientId, error: null };
37
+ // Reaching this JavaScript means the server/CDN already authorized delivery.
38
+ return { valid: true, clientId: null, error: null };
73
39
  }
74
40
 
75
41
  /**
@@ -40,6 +40,16 @@ let _courseId = null;
40
40
 
41
41
  const DEFAULT_BATCH_SIZE = 10;
42
42
  const DEFAULT_FLUSH_INTERVAL = 30000; // 30 seconds
43
+ const INITIAL_RETRY_DELAY = 1000;
44
+ const MAX_RETRY_DELAY = 30000;
45
+ let retryDelay = INITIAL_RETRY_DELAY;
46
+
47
+ function scheduleFlush(delay) {
48
+ if (flushTimer) return;
49
+ flushTimer = setTimeout(() => {
50
+ flush();
51
+ }, delay);
52
+ }
43
53
 
44
54
  /**
45
55
  * Queue a record for batched sending
@@ -69,9 +79,7 @@ function queueRecord(type, data) {
69
79
  // Schedule flush on timer if not already scheduled
70
80
  if (!flushTimer) {
71
81
  const interval = _config.flushInterval || DEFAULT_FLUSH_INTERVAL;
72
- flushTimer = setTimeout(() => {
73
- flush();
74
- }, interval);
82
+ scheduleFlush(interval);
75
83
  }
76
84
  }
77
85
 
@@ -102,16 +110,21 @@ async function flush() {
102
110
  });
103
111
 
104
112
  if (response.ok) {
113
+ retryDelay = INITIAL_RETRY_DELAY;
105
114
  logger.debug(`[DataReporter] Batch sent successfully (${records.length} records)`);
106
115
  } else {
107
116
  logger.warn(`[DataReporter] Failed to send batch: ${response.status}`);
108
117
  // Re-queue failed records at front of batch
109
118
  batch.unshift(...records);
119
+ scheduleFlush(retryDelay);
120
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
110
121
  }
111
122
  } catch (e) {
112
123
  logger.warn(`[DataReporter] Network error sending batch: ${e.message}`);
113
124
  // Re-queue failed records
114
125
  batch.unshift(...records);
126
+ scheduleFlush(retryDelay);
127
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
115
128
  }
116
129
  }
117
130
 
@@ -1,63 +1,39 @@
1
1
  {
2
- "version": "0.1.0",
3
- "name": "SCORM 2004 4th Edition Framework",
4
- "description": "Production-ready SCORM framework with modular architecture and advanced features",
5
- "released": "2025-11-07",
6
- "scorm_version": "SCORM 2004 4th Edition",
2
+ "version": "0.1.54",
3
+ "name": "CourseCode Framework",
4
+ "description": "Multi-format course authoring and learner runtime framework",
5
+ "released": "2026-07-10",
6
+ "formats": [
7
+ "SCORM 2004 4th Edition",
8
+ "SCORM 1.2",
9
+ "cmi5",
10
+ "LTI 1.3"
11
+ ],
7
12
  "compatibility": {
8
- "minimum_course_version": "0.1.0",
13
+ "node": ">=20.19.0",
14
+ "learner_browsers": {
15
+ "chrome": ">=111",
16
+ "edge": ">=111",
17
+ "firefox": ">=114",
18
+ "safari": ">=16.4"
19
+ },
9
20
  "breaking_changes": [
10
- "StateManager now owns all SCORM persistence; PersistenceManager removed"
21
+ "Internet Explorer and non-ESM learner webviews are no longer supported",
22
+ "LTI 1.3 production launches require a trusted server backend",
23
+ "External-hosting access control requires server-side enforcement"
11
24
  ]
12
25
  },
13
- "components": {
14
- "core": [
15
- "pipwerks.js",
16
- "runtime.js",
17
- "event-bus.js",
18
- "course-app.js",
19
- "accessibility-enhancements.js"
20
- ],
21
- "managers": [
22
- "state-manager.js",
23
- "navigation-manager.js",
24
- "objective-manager.js",
25
- "interaction-manager.js",
26
- "accessibility-manager.js",
27
- "assessment-manager.js"
28
- ],
29
- "utilities": [
30
- "utilities.js",
31
- "feedback-system.js"
32
- ],
33
- "components": [
34
- "ui-components/index.js",
35
- "ui-components/modal.js",
36
- "ui-components/dropdown.js",
37
- "ui-components/notifications.js",
38
- "ui-components/tabs.js",
39
- "interactions/"
40
- ]
26
+ "build": {
27
+ "vite": "8.1",
28
+ "javascript": "ES modules"
41
29
  },
42
- "features": [
43
- "SCORM 2004 4th Edition compliant",
44
- "Sequential assessment navigator",
45
- "Interactive question types (multiple-choice, fill-in, numeric, hotspot, drag-drop, true-false)",
46
- "SCORM-based state persistence",
47
- "Accessibility features (WCAG 2.1 AA)",
48
- "Adaptive feedback system",
49
- "Course gating and navigation rules",
50
- "Time tracking and analytics",
51
- "Offline mode support",
52
- "Assessment security features"
53
- ],
54
30
  "upgrade": {
55
- "instructions": "See UPGRADE_GUIDE.md for detailed upgrade instructions",
31
+ "instructions": "Run coursecode upgrade --configs, then npm install",
56
32
  "backup_recommended": true,
57
33
  "test_before_deployment": true
58
34
  },
59
35
  "support": {
60
- "documentation": "https://github.com/svincent/scorm-template",
61
- "issues": "https://github.com/svincent/scorm-template/issues"
36
+ "documentation": "https://github.com/course-code-framework/coursecode/blob/main/framework/docs/USER_GUIDE.md",
37
+ "issues": "https://github.com/course-code-framework/coursecode/issues"
62
38
  }
63
39
  }
@@ -2,6 +2,10 @@ export function stampFormat(html: string, format: string): string
2
2
  export function stampFormatInHtml(htmlPath: string, format: string): void
3
3
 
4
4
  export function validateExternalHostingConfig(config: Record<string, unknown>): void
5
+ export function loadExternalAccessConfig(rootDir: string, courseConfig: Record<string, any>): {
6
+ enforcement: string | null
7
+ clients: Record<string, { token: string }>
8
+ }
5
9
 
6
10
  export function createStandardPackage(options: {
7
11
  rootDir: string
@@ -17,6 +17,7 @@ import { generateManifest } from './manifest/manifest-factory.js';
17
17
  const __filename = fileURLToPath(import.meta.url);
18
18
  const __dirname = path.dirname(__filename);
19
19
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
20
+ const DEFAULT_ACCESS_FILE = path.join('.coursecode', 'access-control.json');
20
21
 
21
22
  function sanitizeTitle(title) {
22
23
  return String(title || 'course')
@@ -55,9 +56,45 @@ export function validateExternalHostingConfig(config) {
55
56
  throw new Error(`${config.lmsFormat} format requires 'externalUrl' in course-config.js`);
56
57
  }
57
58
 
59
+ if (config.accessControl?.enforcement !== 'server') {
60
+ throw new Error(`${config.lmsFormat} requires accessControl.enforcement = 'server'. Browser-only token checks are not secure.`);
61
+ }
62
+
58
63
  if (!config.accessControl?.clients || Object.keys(config.accessControl.clients).length === 0) {
59
- throw new Error(`${config.lmsFormat} format requires 'accessControl.clients' in course-config.js. Use 'coursecode token --add <client>' to add clients.`);
64
+ throw new Error(`${config.lmsFormat} requires client credentials in .coursecode/access-control.json. Run: coursecode token --add <client>`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Load external-hosting credentials from a non-published, gitignored file.
70
+ * Secrets in course-config.js are rejected because that module is bundled into
71
+ * learner-facing JavaScript.
72
+ */
73
+ export function loadExternalAccessConfig(rootDir, courseConfig) {
74
+ if (courseConfig.accessControl?.clients) {
75
+ throw new Error(
76
+ 'accessControl.clients must not be stored in course/course-config.js because it is bundled for learners. ' +
77
+ 'Move credentials with: coursecode token --add <client>'
78
+ );
79
+ }
80
+
81
+ const accessFile = process.env.COURSECODE_ACCESS_FILE
82
+ ? path.resolve(rootDir, process.env.COURSECODE_ACCESS_FILE)
83
+ : path.join(rootDir, DEFAULT_ACCESS_FILE);
84
+
85
+ let secrets = {};
86
+ if (fs.existsSync(accessFile)) {
87
+ try {
88
+ secrets = JSON.parse(fs.readFileSync(accessFile, 'utf-8'));
89
+ } catch (error) {
90
+ throw new Error(`Invalid external access file ${accessFile}: ${error.message}`);
91
+ }
60
92
  }
93
+
94
+ return {
95
+ enforcement: courseConfig.accessControl?.enforcement || null,
96
+ clients: secrets.clients || {}
97
+ };
61
98
  }
62
99
 
63
100
  /**
@@ -126,7 +163,7 @@ export async function createProxyPackage({ rootDir, config, clientId = null, tok
126
163
  const externalUrl = withClientCredentials(config.externalUrl, clientId, token);
127
164
 
128
165
  let proxyHtml = fs.readFileSync(path.join(templatesDir, 'proxy.html'), 'utf-8');
129
- proxyHtml = proxyHtml.replace('{{EXTERNAL_URL}}', externalUrl);
166
+ proxyHtml = proxyHtml.replace('{{EXTERNAL_URL_JSON}}', JSON.stringify(externalUrl));
130
167
  fs.writeFileSync(path.join(proxyDir, 'proxy.html'), proxyHtml);
131
168
 
132
169
  fs.copyFileSync(path.join(templatesDir, 'scorm-bridge.js'), path.join(proxyDir, 'scorm-bridge.js'));
package/lib/cloud.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Implements the CLI → Cloud integration spec:
5
5
  * login, logout, whoami, courses, deploy, status
6
6
  *
7
- * Zero external dependencies — uses Node 18+ built-in fetch, crypto, readline.
7
+ * Zero external dependencies — uses Node 20.19+ built-in fetch, crypto, readline.
8
8
  */
9
9
 
10
10
  import crypto from 'crypto';
@@ -14,6 +14,7 @@
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { pathToFileURL } from 'url';
17
+ import { parse } from 'acorn';
17
18
 
18
19
  // =============================================================================
19
20
  // PUBLIC API
@@ -22,6 +23,7 @@ import { pathToFileURL } from 'url';
22
23
  // In-memory write mutex to prevent concurrent writes from clobbering each other.
23
24
  // Each write() call chains onto the previous one, ensuring serial execution.
24
25
  let _writeQueue = Promise.resolve();
26
+ let _configImportVersion = 0;
25
27
 
26
28
  /**
27
29
  * Unified write dispatcher
@@ -175,7 +177,9 @@ async function renameObjective(coursePath, oldId, newId) {
175
177
 
176
178
  async function loadConfig(coursePath) {
177
179
  const configFilePath = path.join(coursePath, 'course-config.js');
178
- const configUrl = pathToFileURL(configFilePath).href + `?t=${Date.now()}`;
180
+ // Date.now() alone can collide across queued writes in the same
181
+ // millisecond, causing Node to return a stale ESM module instance.
182
+ const configUrl = pathToFileURL(configFilePath).href + `?v=${Date.now()}-${++_configImportVersion}`;
179
183
  const configModule = await import(configUrl);
180
184
  return configModule.courseConfig || configModule.default;
181
185
  }
@@ -183,13 +187,60 @@ async function loadConfig(coursePath) {
183
187
  async function saveConfig(coursePath, config) {
184
188
  const configFilePath = path.join(coursePath, 'course-config.js');
185
189
  const originalSource = fs.readFileSync(configFilePath, 'utf-8');
186
- const headerComments = extractHeaderComments(originalSource);
187
- const serialized = 'export const courseConfig = ' + serializeObject(config) + ';\n';
188
- fs.writeFileSync(configFilePath, headerComments + '\n\n' + serialized, 'utf-8');
190
+ const ast = parse(originalSource, {
191
+ ecmaVersion: 'latest',
192
+ sourceType: 'module',
193
+ allowHashBang: true
194
+ });
195
+
196
+ let configInitializer = null;
197
+ for (const statement of ast.body) {
198
+ if (statement.type !== 'ExportNamedDeclaration' || statement.declaration?.type !== 'VariableDeclaration') continue;
199
+ const declarator = statement.declaration.declarations.find(item => item.id?.name === 'courseConfig');
200
+ if (declarator) {
201
+ configInitializer = declarator.init;
202
+ break;
203
+ }
204
+ }
205
+
206
+ if (!configInitializer || configInitializer.type !== 'ObjectExpression') {
207
+ throw new Error('course-config.js must export courseConfig as an object literal for visual editing');
208
+ }
209
+
210
+ const functionSources = new Map();
211
+ collectFunctionSources(configInitializer, config, originalSource, [], functionSources);
212
+ const serialized = serializeObject(config, 0, new WeakSet(), [], functionSources);
213
+ const updatedSource = originalSource.slice(0, configInitializer.start) +
214
+ serialized + originalSource.slice(configInitializer.end);
215
+ fs.writeFileSync(configFilePath, updatedSource, 'utf-8');
216
+ }
217
+
218
+ function collectFunctionSources(node, value, source, valuePath, output) {
219
+ if (!node || value === null || value === undefined) return;
220
+ if (typeof value === 'function') {
221
+ output.set(valuePath.join('.'), source.slice(node.start, node.end));
222
+ return;
223
+ }
224
+
225
+ if (node.type === 'ObjectExpression' && typeof value === 'object' && !Array.isArray(value)) {
226
+ for (const property of node.properties) {
227
+ if (property.type !== 'Property' || property.computed) continue;
228
+ const key = property.key.type === 'Identifier' ? property.key.name : String(property.key.value);
229
+ collectFunctionSources(property.value, value[key], source, [...valuePath, key], output);
230
+ }
231
+ } else if (node.type === 'ArrayExpression' && Array.isArray(value)) {
232
+ node.elements.forEach((element, index) => {
233
+ collectFunctionSources(element, value[index], source, [...valuePath, String(index)], output);
234
+ });
235
+ }
189
236
  }
190
237
 
191
238
  function setNestedProperty(obj, propPath, value) {
192
239
  const parts = propPath.split('.');
240
+ const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
241
+ if (parts.some(part => !part || forbidden.has(part))) {
242
+ throw new Error('Invalid configuration property path');
243
+ }
193
244
  let current = obj;
194
245
  for (let i = 0; i < parts.length - 1; i++) {
195
246
  if (current[parts[i]] === undefined) {
@@ -211,48 +262,62 @@ function findStructureItem(structure, id) {
211
262
  return null;
212
263
  }
213
264
 
214
- function extractHeaderComments(source) {
215
- const lines = source.split('\n');
216
- const commentLines = [];
217
- for (const line of lines) {
218
- const trimmed = line.trim();
219
- if (trimmed.startsWith('/**') || trimmed.startsWith('*') || trimmed.startsWith('*/') || trimmed === '') {
220
- commentLines.push(line);
221
- } else {
222
- break;
223
- }
224
- }
225
- while (commentLines.length > 0 && commentLines[commentLines.length - 1].trim() === '') {
226
- commentLines.pop();
227
- }
228
- return commentLines.join('\n');
229
- }
230
-
231
- function serializeObject(obj, indent = 0) {
265
+ function serializeObject(obj, indent = 0, ancestors = new WeakSet(), valuePath = [], functionSources = new Map()) {
232
266
  const spaces = ' '.repeat(indent);
233
267
  const innerSpaces = ' '.repeat(indent + 1);
234
268
 
235
269
  if (obj === null) return 'null';
236
270
  if (obj === undefined) return 'undefined';
237
271
  if (typeof obj === 'boolean') return String(obj);
238
- if (typeof obj === 'number') return String(obj);
239
- if (typeof obj === 'string') return `'${obj.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
240
- if (typeof obj === 'function') return obj.toString();
272
+ if (typeof obj === 'number') {
273
+ if (!Number.isFinite(obj)) throw new Error('courseConfig cannot contain NaN or Infinity');
274
+ return String(obj);
275
+ }
276
+ if (typeof obj === 'string') return JSON.stringify(obj).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
277
+ if (typeof obj === 'function') return functionSources.get(valuePath.join('.')) || obj.toString();
278
+ if (typeof obj !== 'object') throw new Error(`Unsupported courseConfig value type: ${typeof obj}`);
279
+
280
+ if (ancestors.has(obj)) throw new Error('courseConfig cannot contain circular references');
281
+ ancestors.add(obj);
241
282
 
242
283
  if (Array.isArray(obj)) {
243
- if (obj.length === 0) return '[]';
244
- const items = obj.map(item => innerSpaces + serializeObject(item, indent + 1));
284
+ if (obj.length === 0) {
285
+ ancestors.delete(obj);
286
+ return '[]';
287
+ }
288
+ const items = obj.map((item, index) => innerSpaces + serializeObject(
289
+ item,
290
+ indent + 1,
291
+ ancestors,
292
+ [...valuePath, String(index)],
293
+ functionSources
294
+ ));
295
+ ancestors.delete(obj);
245
296
  return '[\n' + items.join(',\n') + '\n' + spaces + ']';
246
297
  }
298
+
299
+ const prototype = Object.getPrototypeOf(obj);
300
+ if (prototype !== Object.prototype && prototype !== null) {
301
+ ancestors.delete(obj);
302
+ throw new Error(`Unsupported courseConfig object type: ${obj.constructor?.name || 'unknown'}`);
303
+ }
247
304
 
248
305
  const entries = Object.entries(obj);
249
- if (entries.length === 0) return '{}';
306
+ if (entries.length === 0) {
307
+ ancestors.delete(obj);
308
+ return '{}';
309
+ }
250
310
 
251
311
  const props = entries.map(([key, val]) => {
252
- const keyStr = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`;
253
- return innerSpaces + keyStr + ': ' + serializeObject(val, indent + 1);
312
+ const keyStr = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
313
+ return innerSpaces + keyStr + ': ' + serializeObject(
314
+ val,
315
+ indent + 1,
316
+ ancestors,
317
+ [...valuePath, key],
318
+ functionSources
319
+ );
254
320
  });
255
-
321
+ ancestors.delete(obj);
256
322
  return '{\n' + props.join(',\n') + '\n' + spaces + '}';
257
323
  }
258
-
@@ -10,6 +10,8 @@
10
10
  * For cmi5-remote format, the AU URL is absolute (pointing to CDN).
11
11
  */
12
12
 
13
+ import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
14
+
13
15
  /**
14
16
  * Generates the cmi5 course structure XML.
15
17
  * @param {Object} config - Course configuration
@@ -23,39 +25,46 @@ export function generateCmi5Manifest(config, _files, options = {}) {
23
25
  const courseId = config.identifier ||
24
26
  `urn:coursecode:${config.title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
25
27
 
26
- // Calculate mastery score from passing percentage (convert percentage to 0-1 scale)
27
- const masteryScore = config.passingScore ? (config.passingScore / 100).toFixed(2) : '0.8';
28
+ // Package-level mastery is optional. Do not invent a value from one
29
+ // assessment because a course can contain multiple thresholds.
30
+ const masteryAttribute = Number.isFinite(config.masteryScore)
31
+ ? ` masteryScore="${(config.masteryScore / 100).toFixed(2)}"`
32
+ : '';
28
33
 
29
34
  // AU identifier - derive from course ID
30
35
  const auId = `${courseId}/au/1`;
31
36
 
32
37
  // URL: absolute for cmi5-remote (use as-is), relative for standard cmi5
33
38
  const auUrl = options.externalUrl || 'index.html';
39
+ const escapedCourseId = escapeXmlAttribute(courseId);
40
+ const escapedAuId = escapeXmlAttribute(auId);
41
+ const escapedLanguage = escapeXmlAttribute(config.language);
42
+ const title = escapeXmlText(config.title);
43
+ const description = escapeXmlText(config.description);
34
44
 
35
45
  return `<?xml version="1.0" encoding="UTF-8"?>
36
46
  <!-- cmi5 Course Structure - GENERATED FILE - DO NOT EDIT MANUALLY -->
37
47
  <courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd">
38
48
 
39
- <course id="${courseId}">
49
+ <course id="${escapedCourseId}">
40
50
  <title>
41
- <langstring lang="${config.language}">${config.title}</langstring>
51
+ <langstring lang="${escapedLanguage}">${title}</langstring>
42
52
  </title>
43
53
  <description>
44
- <langstring lang="${config.language}">${config.description}</langstring>
54
+ <langstring lang="${escapedLanguage}">${description}</langstring>
45
55
  </description>
46
56
  </course>
47
57
 
48
- <au id="${auId}" moveOn="Completed" masteryScore="${masteryScore}" launchMethod="OwnWindow">
58
+ <au id="${escapedAuId}" moveOn="Completed"${masteryAttribute} launchMethod="OwnWindow">
49
59
  <title>
50
- <langstring lang="${config.language}">${config.title}</langstring>
60
+ <langstring lang="${escapedLanguage}">${title}</langstring>
51
61
  </title>
52
62
  <description>
53
- <langstring lang="${config.language}">${config.description}</langstring>
63
+ <langstring lang="${escapedLanguage}">${description}</langstring>
54
64
  </description>
55
- <url>${auUrl}</url>
65
+ <url>${escapeXmlText(auUrl)}</url>
56
66
  </au>
57
67
 
58
68
  </courseStructure>
59
69
  `;
60
70
  }
61
-