coursecode 0.1.53 → 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.
@@ -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
-
@@ -12,7 +12,21 @@
12
12
  * @returns {string} JSON string of tool configuration
13
13
  */
14
14
  export function generateLtiToolConfig(config, options = {}) {
15
- const baseUrl = options.externalUrl || config.externalUrl || 'https://your-course-host.example.com';
15
+ const configuredUrl = options.externalUrl || config.externalUrl;
16
+ if (!configuredUrl) {
17
+ throw new Error('LTI builds require externalUrl for a trusted server-side OIDC/AGS backend');
18
+ }
19
+
20
+ let parsedBaseUrl;
21
+ try {
22
+ parsedBaseUrl = new URL(configuredUrl);
23
+ } catch {
24
+ throw new Error(`Invalid LTI externalUrl: ${configuredUrl}`);
25
+ }
26
+ if (!['https:', 'http:'].includes(parsedBaseUrl.protocol)) {
27
+ throw new Error('LTI externalUrl must use https (or http for local development)');
28
+ }
29
+ const baseUrl = parsedBaseUrl.toString().replace(/\/$/, '');
16
30
  const title = config.title || 'CourseCode Course';
17
31
  const description = config.description || '';
18
32
 
@@ -24,7 +38,6 @@ export function generateLtiToolConfig(config, options = {}) {
24
38
  'grant_types': ['implicit', 'client_credentials'],
25
39
  'initiate_login_uri': `${baseUrl}/lti/login`,
26
40
  'redirect_uris': [
27
- `${baseUrl}/index.html`,
28
41
  `${baseUrl}/lti/launch`
29
42
  ],
30
43
  'client_name': title,
@@ -35,14 +48,14 @@ export function generateLtiToolConfig(config, options = {}) {
35
48
 
36
49
  // LTI-specific claims
37
50
  'https://purl.imsglobal.org/spec/lti-tool-configuration': {
38
- 'domain': new URL(baseUrl).hostname,
51
+ 'domain': parsedBaseUrl.hostname,
39
52
  'description': description,
40
- 'target_link_uri': `${baseUrl}/index.html`,
53
+ 'target_link_uri': `${baseUrl}/lti/launch`,
41
54
  'claims': ['iss', 'sub', 'name', 'given_name', 'family_name', 'email'],
42
55
  'messages': [
43
56
  {
44
57
  'type': 'LtiResourceLinkRequest',
45
- 'target_link_uri': `${baseUrl}/index.html`,
58
+ 'target_link_uri': `${baseUrl}/lti/launch`,
46
59
  'label': title
47
60
  }
48
61
  ]
@@ -8,6 +8,8 @@
8
8
  * - 4th Edition schema references
9
9
  */
10
10
 
11
+ import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
12
+
11
13
  /**
12
14
  * Generates the SCORM 1.2 manifest.
13
15
  * @param {Object} config - Course configuration
@@ -22,13 +24,18 @@ export function generateScorm12Manifest(config, files) {
22
24
  !f.startsWith('common/')
23
25
  );
24
26
 
25
- const fileEntries = resourceFiles.map(f => ` <file href="${f}"/>`).join('\n');
27
+ const fileEntries = resourceFiles.map(f => ` <file href="${escapeXmlAttribute(f)}"/>`).join('\n');
28
+ const version = escapeXmlAttribute(config.version);
29
+ const title = escapeXmlText(config.title);
30
+ const masteryScore = Number.isFinite(config.masteryScore)
31
+ ? `\n <adlcp:masteryscore>${config.masteryScore}</adlcp:masteryscore>`
32
+ : '';
26
33
 
27
34
  // SCORM 1.2 uses ADLCP 1.2 schema and doesn't include sequencing
28
35
  return `<?xml version="1.0" encoding="UTF-8"?>
29
36
  <!-- SCORM 1.2 manifest - GENERATED FILE - DO NOT EDIT MANUALLY -->
30
37
  <manifest identifier="${config.title.replace(/[^a-zA-Z0-9]/g, '-')}"
31
- version="${config.version}"
38
+ version="${version}"
32
39
  xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
33
40
  xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
34
41
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
@@ -43,10 +50,9 @@ export function generateScorm12Manifest(config, files) {
43
50
 
44
51
  <organizations default="org-1">
45
52
  <organization identifier="org-1">
46
- <title>${config.title}</title>
53
+ <title>${title}</title>
47
54
  <item identifier="item-1" identifierref="res-1" isvisible="true">
48
- <title>${config.title}</title>
49
- <adlcp:masteryscore>80</adlcp:masteryscore>
55
+ <title>${title}</title>${masteryScore}
50
56
  </item>
51
57
  </organization>
52
58
  </organizations>