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.
- package/README.md +4 -2
- package/bin/cli.js +1 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +8 -6
- package/framework/docs/FRAMEWORK_GUIDE.md +22 -21
- package/framework/docs/USER_GUIDE.md +9 -3
- package/framework/index.html +1 -1
- package/framework/js/core/event-bus.js +21 -6
- package/framework/js/drivers/lti-driver.js +50 -94
- package/framework/js/drivers/proxy-driver.js +8 -5
- package/framework/js/main.js +3 -15
- package/framework/js/utilities/access-control.js +11 -45
- package/framework/js/utilities/data-reporter.js +16 -3
- package/framework/version.json +26 -50
- package/lib/build-packaging.d.ts +4 -0
- package/lib/build-packaging.js +39 -2
- package/lib/cloud.js +1 -1
- package/lib/course-writer.js +97 -32
- package/lib/manifest/cmi5-manifest.js +19 -10
- package/lib/manifest/lti-tool-config.js +18 -5
- package/lib/manifest/scorm-12-manifest.js +11 -5
- package/lib/manifest/scorm-2004-manifest.js +16 -9
- package/lib/manifest/xml-utils.js +14 -0
- package/lib/preview-routes-api.js +16 -7
- package/lib/preview-server.js +49 -28
- package/lib/project-utils.js +34 -0
- package/lib/proxy-templates/proxy.html +7 -3
- package/lib/proxy-templates/scorm-bridge.js +15 -9
- package/lib/stub-player.js +19 -2
- package/lib/token.js +26 -29
- package/lib/upgrade.js +76 -2
- package/package.json +32 -25
- package/template/course/course-config.js +9 -7
- package/template/package.json +13 -4
- package/template/vite.config.js +80 -27
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Access Control
|
|
2
|
+
* Access Control compatibility helper for multi-tenant CDN hosting.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
35
|
-
if (!accessControl?.clients) {
|
|
17
|
+
if (!accessControl) {
|
|
36
18
|
return { valid: true, clientId: null, error: null };
|
|
37
19
|
}
|
|
38
20
|
|
|
39
|
-
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
package/framework/version.json
CHANGED
|
@@ -1,63 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
3
|
-
"name": "
|
|
4
|
-
"description": "
|
|
5
|
-
"released": "
|
|
6
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
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": "
|
|
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/
|
|
61
|
-
"issues": "https://github.com/
|
|
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
|
}
|
package/lib/build-packaging.d.ts
CHANGED
|
@@ -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
|
package/lib/build-packaging.js
CHANGED
|
@@ -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}
|
|
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('{{
|
|
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
|
|
7
|
+
* Zero external dependencies — uses Node 20.19+ built-in fetch, crypto, readline.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import crypto from 'crypto';
|
package/lib/course-writer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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')
|
|
239
|
-
|
|
240
|
-
|
|
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)
|
|
244
|
-
|
|
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)
|
|
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 :
|
|
253
|
-
return innerSpaces + keyStr + ': ' + serializeObject(
|
|
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
|
-
//
|
|
27
|
-
|
|
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="${
|
|
49
|
+
<course id="${escapedCourseId}">
|
|
40
50
|
<title>
|
|
41
|
-
<langstring lang="${
|
|
51
|
+
<langstring lang="${escapedLanguage}">${title}</langstring>
|
|
42
52
|
</title>
|
|
43
53
|
<description>
|
|
44
|
-
<langstring lang="${
|
|
54
|
+
<langstring lang="${escapedLanguage}">${description}</langstring>
|
|
45
55
|
</description>
|
|
46
56
|
</course>
|
|
47
57
|
|
|
48
|
-
<au id="${
|
|
58
|
+
<au id="${escapedAuId}" moveOn="Completed"${masteryAttribute} launchMethod="OwnWindow">
|
|
49
59
|
<title>
|
|
50
|
-
<langstring lang="${
|
|
60
|
+
<langstring lang="${escapedLanguage}">${title}</langstring>
|
|
51
61
|
</title>
|
|
52
62
|
<description>
|
|
53
|
-
<langstring lang="${
|
|
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
|
|
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':
|
|
51
|
+
'domain': parsedBaseUrl.hostname,
|
|
39
52
|
'description': description,
|
|
40
|
-
'target_link_uri': `${baseUrl}/
|
|
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}/
|
|
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="${
|
|
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>${
|
|
53
|
+
<title>${title}</title>
|
|
47
54
|
<item identifier="item-1" identifierref="res-1" isvisible="true">
|
|
48
|
-
<title>${
|
|
49
|
-
<adlcp:masteryscore>80</adlcp:masteryscore>
|
|
55
|
+
<title>${title}</title>${masteryScore}
|
|
50
56
|
</item>
|
|
51
57
|
</organization>
|
|
52
58
|
</organizations>
|