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
@@ -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>
@@ -3,6 +3,8 @@
3
3
  * @description Generates imsmanifest.xml for SCORM 2004 4th Edition packages.
4
4
  */
5
5
 
6
+ import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
7
+
6
8
  /**
7
9
  * Generates the SCORM 2004 4th Edition manifest.
8
10
  * @param {Object} config - Course configuration
@@ -17,12 +19,17 @@ export function generateScorm2004Manifest(config, files) {
17
19
  !f.startsWith('common/')
18
20
  );
19
21
 
20
- const fileEntries = resourceFiles.map(f => ` <file href="${f}"/>`).join('\n');
22
+ const fileEntries = resourceFiles.map(f => ` <file href="${escapeXmlAttribute(f)}"/>`).join('\n');
23
+ const version = escapeXmlAttribute(config.version);
24
+ const language = escapeXmlAttribute(config.language);
25
+ const title = escapeXmlText(config.title);
26
+ const description = escapeXmlText(config.description);
27
+ const author = escapeXmlText(config.author);
21
28
 
22
29
  return `<?xml version="1.0" encoding="UTF-8"?>
23
30
  <!-- SCORM 2004 4th Edition manifest - GENERATED FILE - DO NOT EDIT MANUALLY -->
24
31
  <manifest identifier="${config.title.replace(/[^a-zA-Z0-9]/g, '-')}"
25
- version="${config.version}"
32
+ version="${version}"
26
33
  xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
27
34
  xmlns:imscp="http://www.imsglobal.org/xsd/imscp_v1p1"
28
35
  xmlns:imsss="http://www.imsglobal.org/xsd/imsss"
@@ -45,18 +52,18 @@ export function generateScorm2004Manifest(config, files) {
45
52
  <schemaversion>2004 4th Edition</schemaversion>
46
53
  <lom:lom>
47
54
  <lom:general>
48
- <lom:title><lom:string language="${config.language}">${config.title}</lom:string></lom:title>
49
- <lom:description><lom:string language="${config.language}">${config.description}</lom:string></lom:description>
50
- <lom:language>${config.language}</lom:language>
55
+ <lom:title><lom:string language="${language}">${title}</lom:string></lom:title>
56
+ <lom:description><lom:string language="${language}">${description}</lom:string></lom:description>
57
+ <lom:language>${escapeXmlText(config.language)}</lom:language>
51
58
  </lom:general>
52
59
  <lom:lifecycle>
53
- <lom:version><lom:string>${config.version}</lom:string></lom:version>
60
+ <lom:version><lom:string>${escapeXmlText(config.version)}</lom:string></lom:version>
54
61
  <lom:contribute>
55
62
  <lom:role>
56
63
  <lom:source><lom:string>LOMv1.0</lom:string></lom:source>
57
64
  <lom:value><lom:string>author</lom:string></lom:value>
58
65
  </lom:role>
59
- <lom:entity>${config.author}</lom:entity>
66
+ <lom:entity>${author}</lom:entity>
60
67
  </lom:contribute>
61
68
  </lom:lifecycle>
62
69
  <lom:technical>
@@ -67,9 +74,9 @@ export function generateScorm2004Manifest(config, files) {
67
74
 
68
75
  <organizations default="org-1">
69
76
  <organization identifier="org-1" adlseq:objectivesGlobalToSystem="false">
70
- <title>${config.title}</title>
77
+ <title>${title}</title>
71
78
  <item identifier="item-1" identifierref="res-1" isvisible="true">
72
- <title>${config.title}</title>
79
+ <title>${title}</title>
73
80
  <imsss:sequencing>
74
81
  <imsss:controlMode choiceExit="true" forwardOnly="false"/>
75
82
  <imsss:deliveryControls tracked="true" completionSetByContent="true" objectiveSetByContent="true"/>
@@ -0,0 +1,14 @@
1
+ /** Escape text inserted into XML element content. */
2
+ export function escapeXmlText(value) {
3
+ return String(value ?? '')
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;');
7
+ }
8
+
9
+ /** Escape text inserted into a double-quoted XML attribute. */
10
+ export function escapeXmlAttribute(value) {
11
+ return escapeXmlText(value)
12
+ .replace(/"/g, '&quot;')
13
+ .replace(/'/g, '&apos;');
14
+ }
@@ -13,6 +13,7 @@ import { generateContentHtml } from './stub-player/content-generator.js';
13
13
  import { parseCourse } from './course-parser.js';
14
14
  import { getComponentCatalog, getInteractionCatalog, getIconCatalog, getWorkflowStatus, getRefsStatus, buildCourse } from './authoring-api.js';
15
15
  import { getAllIcons, getAllSchemas } from './schema-extractor.js';
16
+ import { escapeHtml, resolveWithinRoot } from './project-utils.js';
16
17
 
17
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
19
 
@@ -657,12 +658,19 @@ export async function handleApiRoutes(ctx, req, res, url) {
657
658
  if (url === '/__stub-player/ref-preview') {
658
659
  const params = new URL(req.url, 'http://localhost').searchParams;
659
660
  const fileName = params.get('file');
660
- if (!fileName || fileName.includes('..')) {
661
+ if (!fileName) {
661
662
  res.writeHead(400, { 'Content-Type': 'text/plain' });
662
663
  res.end('Invalid file parameter');
663
664
  return true;
664
665
  }
665
- const filePath = path.join(paths.coursePath, 'references', 'converted', fileName);
666
+ let filePath;
667
+ try {
668
+ filePath = resolveWithinRoot(path.join(paths.coursePath, 'references', 'converted'), fileName);
669
+ } catch {
670
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
671
+ res.end('Forbidden');
672
+ return true;
673
+ }
666
674
  if (!fs.existsSync(filePath)) {
667
675
  res.writeHead(404, { 'Content-Type': 'text/plain' });
668
676
  res.end('File not found');
@@ -676,7 +684,7 @@ export async function handleApiRoutes(ctx, req, res, url) {
676
684
  <head>
677
685
  <meta charset="UTF-8">
678
686
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
679
- <title>${fileName} – Reference Preview</title>
687
+ <title>${escapeHtml(fileName)} – Reference Preview</title>
680
688
  <link rel="stylesheet" href="/__stub-player/styles.css">
681
689
  <style>
682
690
  body { padding: 40px; max-width: 900px; margin: 0 auto; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; background: var(--color-primary-deep, #0b1628); color: var(--color-gray-200, #d1d5db); }
@@ -686,7 +694,7 @@ export async function handleApiRoutes(ctx, req, res, url) {
686
694
  </style>
687
695
  </head>
688
696
  <body>
689
- <h1>${fileName}</h1>
697
+ <h1>${escapeHtml(fileName)}</h1>
690
698
  ${html}
691
699
  </body>
692
700
  </html>`);
@@ -696,13 +704,14 @@ export async function handleApiRoutes(ctx, req, res, url) {
696
704
  // Stub player static files
697
705
  if (url.startsWith('/__stub-player/')) {
698
706
  const relativePath = url.slice('/__stub-player/'.length);
699
- if (relativePath.includes('..')) {
707
+ let filePath;
708
+ try {
709
+ filePath = resolveWithinRoot(path.join(__dirname, 'stub-player'), relativePath);
710
+ } catch {
700
711
  res.writeHead(403);
701
712
  res.end('Forbidden');
702
713
  return true;
703
714
  }
704
-
705
- const filePath = path.join(__dirname, 'stub-player', relativePath);
706
715
  fs.stat(filePath, (err, stats) => {
707
716
  if (err || !stats.isFile()) {
708
717
  res.writeHead(404);
@@ -12,7 +12,8 @@
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
14
  import http from 'http';
15
- import { spawn, exec } from 'child_process';
15
+ import crypto from 'crypto';
16
+ import { spawn } from 'child_process';
16
17
  import { fileURLToPath } from 'url';
17
18
 
18
19
  import { generateStubPlayer } from './stub-player.js';
@@ -24,7 +25,7 @@ import { handleEditingRoutes } from './preview-routes-editing.js';
24
25
  import { handleLmsRoutes, createLmsStore } from './preview-routes-lms.js';
25
26
  import {
26
27
  validateProject, escapeHtml, getMimeType, serveFile,
27
- countSlides, findSlideById, collectSlideIds
28
+ countSlides, findSlideById, collectSlideIds, resolveWithinRoot
28
29
  } from './project-utils.js';
29
30
 
30
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -196,6 +197,15 @@ function getCourseTitle(coursePath) {
196
197
  }
197
198
  }
198
199
 
200
+ export function requiresPreviewMutationToken(method, url) {
201
+ return !['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase()) &&
202
+ url.startsWith('/__') && !url.startsWith('/__lms/');
203
+ }
204
+
205
+ export function hasValidPreviewMutationToken(headers, expectedToken) {
206
+ return Boolean(expectedToken) && headers?.['x-coursecode-preview-token'] === expectedToken;
207
+ }
208
+
199
209
 
200
210
 
201
211
  // ============================================================================
@@ -207,6 +217,8 @@ export async function previewServer(options = {}) {
207
217
  const paths = validateProject({ frameworkDev });
208
218
  const title = options.title || getCourseTitle(paths.coursePath);
209
219
  const previewPort = parseInt(options.port || '4173', 10);
220
+ const previewHost = options.host || '127.0.0.1';
221
+ const previewToken = crypto.randomBytes(32).toString('base64url');
210
222
  const distDir = path.join(process.cwd(), 'dist');
211
223
 
212
224
  console.log('\n🚀 Starting preview server...');
@@ -347,6 +359,7 @@ export async function previewServer(options = {}) {
347
359
  password: null,
348
360
  isLive: true,
349
361
  liveReload: true,
362
+ previewToken,
350
363
  courseContent,
351
364
  isDesktop: options.desktop || false
352
365
  });
@@ -374,6 +387,16 @@ export async function previewServer(options = {}) {
374
387
  const server = http.createServer(async (req, res) => {
375
388
  const url = req.url.split('?')[0];
376
389
 
390
+ // All source/file mutations require a per-process capability token.
391
+ // LMS state synchronization is intentionally exempt: it only touches
392
+ // the in-memory test store and is used directly by E2E helpers.
393
+ const isMutation = requiresPreviewMutationToken(req.method, url);
394
+ if (isMutation && !hasValidPreviewMutationToken(req.headers, previewToken)) {
395
+ res.writeHead(403, { 'Content-Type': 'application/json' });
396
+ res.end(JSON.stringify({ error: 'Invalid or missing preview mutation token' }));
397
+ return;
398
+ }
399
+
377
400
  // LMS routes (state sync + testing API)
378
401
  if (handleLmsRoutes(ctx, req, res, url)) return;
379
402
 
@@ -393,7 +416,14 @@ export async function previewServer(options = {}) {
393
416
  // Serve files from dist/ for /course/* requests
394
417
  if (url.startsWith('/course/')) {
395
418
  const relativePath = url.slice('/course/'.length) || 'index.html';
396
- const filePath = path.join(distDir, relativePath);
419
+ let filePath;
420
+ try {
421
+ filePath = resolveWithinRoot(distDir, relativePath);
422
+ } catch {
423
+ res.writeHead(403);
424
+ res.end('Forbidden');
425
+ return;
426
+ }
397
427
  serveFile(filePath, res);
398
428
  return;
399
429
  }
@@ -490,12 +520,24 @@ export async function previewServer(options = {}) {
490
520
 
491
521
  const contentNote = options.content !== false ? ' • Content viewer (📄 button in toolbar)\n' : '';
492
522
 
493
- const startListening = (retried = false) => {
494
- server.listen(previewPort, () => {
523
+ server.on('error', (err) => {
524
+ if (err.code === 'EADDRINUSE') {
525
+ console.error(`\n❌ Port ${previewPort} is already in use. Stop that process or choose another port with --port.`);
526
+ viteProcess.kill();
527
+ process.exitCode = 1;
528
+ return;
529
+ }
530
+ throw err;
531
+ });
532
+
533
+ const startListening = () => {
534
+ server.listen(previewPort, previewHost, () => {
535
+ const displayHost = previewHost === '127.0.0.1' ? 'localhost' : previewHost;
495
536
  console.log(`
496
537
  ✅ Preview server running!
497
538
 
498
- 🎯 Open: http://localhost:${previewPort}
539
+ 🎯 Open: http://${displayHost}:${previewPort}
540
+ 🔒 Bound to: ${previewHost}
499
541
 
500
542
  Features:
501
543
  • Live reload - browser updates automatically on rebuild
@@ -511,28 +553,6 @@ ${contentNote}
511
553
  Press Ctrl+C to stop
512
554
  `);
513
555
  });
514
-
515
- server.on('error', (err) => {
516
- if (err.code === 'EADDRINUSE' && !retried) {
517
- console.warn(`\n⚠️ STALE PROCESS DETECTED — port ${previewPort} is already in use.`);
518
- console.warn(' Killing stale process and retrying...');
519
- exec(`lsof -ti :${previewPort}`, (_, stdout) => {
520
- const pids = (stdout || '').trim();
521
- if (pids) console.warn(` Killed PID(s): ${pids.split('\n').join(', ')}`);
522
- exec(`lsof -ti :${previewPort} | xargs kill -9 2>/dev/null`, () => {
523
- setTimeout(() => {
524
- server.close();
525
- startListening(true);
526
- }, 500);
527
- });
528
- });
529
- } else if (err.code === 'EADDRINUSE') {
530
- console.error(`\n❌ Port ${previewPort} is still in use after retry. Kill it manually:\n lsof -ti :${previewPort} | xargs kill -9`);
531
- process.exit(1);
532
- } else {
533
- throw err;
534
- }
535
- });
536
556
  };
537
557
 
538
558
  startListening();
@@ -563,6 +583,7 @@ if (process.argv[1] && process.argv[1].endsWith('preview-server.js')) {
563
583
  const options = {
564
584
  frameworkDev: args.includes('--framework-dev'),
565
585
  port: args.find(a => a.startsWith('--port='))?.split('=')[1] || '4173',
586
+ host: args.find(a => a.startsWith('--host='))?.split('=')[1] || '127.0.0.1',
566
587
  format: process.env.LMS_FORMAT || null
567
588
  };
568
589
  previewServer(options);
@@ -178,6 +178,40 @@ export function getMimeType(filePath) {
178
178
  return mimeTypes[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
179
179
  }
180
180
 
181
+ /**
182
+ * Resolve a user-controlled relative path while guaranteeing that the result
183
+ * remains inside the supplied root directory.
184
+ *
185
+ * @param {string} rootDir - Absolute directory that contains all allowed files
186
+ * @param {string} relativePath - URL/path fragment supplied by a caller
187
+ * @returns {string} Safe absolute path
188
+ * @throws {Error} If the path is malformed or escapes rootDir
189
+ */
190
+ export function resolveWithinRoot(rootDir, relativePath) {
191
+ if (typeof rootDir !== 'string' || typeof relativePath !== 'string') {
192
+ throw new Error('Root directory and relative path must be strings');
193
+ }
194
+
195
+ let decodedPath;
196
+ try {
197
+ decodedPath = decodeURIComponent(relativePath);
198
+ } catch {
199
+ throw new Error('Invalid URL encoding in path');
200
+ }
201
+
202
+ if (decodedPath.includes('\0')) {
203
+ throw new Error('Path contains a null byte');
204
+ }
205
+
206
+ const root = path.resolve(rootDir);
207
+ const resolved = path.resolve(root, decodedPath.replace(/^[/\\]+/, ''));
208
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
209
+ throw new Error('Path escapes the allowed root directory');
210
+ }
211
+
212
+ return resolved;
213
+ }
214
+
181
215
  // =============================================================================
182
216
  // FILE SERVING
183
217
  // =============================================================================
@@ -44,11 +44,15 @@
44
44
  </div>
45
45
 
46
46
  <iframe id="course" style="display:none"></iframe>
47
-
47
+
48
+ <script src="pipwerks.js"></script>
49
+ <script>
50
+ // Injected as a JSON string by the build system before the bridge loads.
51
+ window.COURSECODE_PROXY_CONFIG = { courseUrl: {{EXTERNAL_URL_JSON}} };
52
+ </script>
48
53
  <script src="scorm-bridge.js"></script>
49
54
  <script>
50
- // Course URL is injected at build time
51
- const COURSE_URL = '{{EXTERNAL_URL}}';
55
+ const COURSE_URL = window.COURSECODE_PROXY_CONFIG.courseUrl;
52
56
 
53
57
  const iframe = document.getElementById('course');
54
58
  const loading = document.getElementById('loading');
@@ -22,16 +22,17 @@
22
22
  const iframe = document.getElementById('course');
23
23
  let initialized = false;
24
24
 
25
- // Derive expected course origin from build-time URL for postMessage validation
26
- // COURSE_URL is injected by the build system (e.g., 'https://cdn.example.com/my-course')
27
- let courseOrigin = '*'; // Fallback if URL parsing fails
25
+ // Derive the exact expected course origin from build-time configuration.
26
+ // Fail closed: wildcard origins would allow unrelated frames to operate the LMS API.
27
+ let courseOrigin;
28
28
  try {
29
- if (typeof COURSE_URL === 'string' && COURSE_URL && !COURSE_URL.startsWith('{{')) {
30
- courseOrigin = new URL(COURSE_URL).origin;
31
- console.log('SCORM Bridge: Expected course origin:', courseOrigin);
32
- }
29
+ const courseUrl = window.COURSECODE_PROXY_CONFIG?.courseUrl;
30
+ if (!courseUrl) throw new Error('Missing proxy course URL');
31
+ courseOrigin = new URL(courseUrl).origin;
32
+ console.log('SCORM Bridge: Expected course origin:', courseOrigin);
33
33
  } catch (e) {
34
- console.warn('SCORM Bridge: Could not parse COURSE_URL for origin validation:', e.message);
34
+ console.error('SCORM Bridge: Invalid course URL; bridge disabled:', e.message);
35
+ return;
35
36
  }
36
37
 
37
38
  // Disable pipwerks auto-handling - we manage status ourselves
@@ -49,8 +50,13 @@
49
50
  return;
50
51
  }
51
52
 
53
+ if (!iframe || event.source !== iframe.contentWindow) {
54
+ console.warn('SCORM Bridge: Rejected message not sent by the course iframe');
55
+ return;
56
+ }
57
+
52
58
  // Validate origin when we have a known course origin
53
- if (courseOrigin !== '*' && event.origin !== courseOrigin) {
59
+ if (event.origin !== courseOrigin) {
54
60
  console.warn('SCORM Bridge: Rejected message from unexpected origin:', event.origin);
55
61
  return;
56
62
  }
@@ -58,7 +58,7 @@ export { escapeHtml };
58
58
  * @returns {string} - Complete HTML for the player page
59
59
  */
60
60
  export function generateStubPlayer(config) {
61
- const { title, launchUrl, storageKey, passwordHash, isLive, liveReload, courseContent, startSlide, isDesktop, showHeader = true, skipGating = null, moduleBasePath = '/__stub-player' } = config;
61
+ const { title, launchUrl, storageKey, passwordHash, isLive, liveReload, courseContent, startSlide, isDesktop, showHeader = true, skipGating = null, moduleBasePath = '/__stub-player', previewToken = null } = config;
62
62
  const hasPassword = !!passwordHash;
63
63
  const hasContent = !!courseContent;
64
64
  const headerVisible = showHeader !== false;
@@ -115,8 +115,25 @@ ${stubPlayerStyles}
115
115
  showHeader: ${headerVisible},
116
116
  skipGating: ${typeof skipGating === 'boolean' ? skipGating : 'null'},
117
117
  isDesktop: ${isDesktop || false},
118
- isCI: ${!!process.env.CI}
118
+ isCI: ${!!process.env.CI},
119
+ previewToken: ${previewToken ? JSON.stringify(previewToken) : 'null'}
119
120
  };
121
+
122
+ // Protect source-mutating preview endpoints from cross-site requests. The
123
+ // token is random for each preview process and is never used by exports.
124
+ if (window.STUB_CONFIG.previewToken) {
125
+ const nativeFetch = window.fetch.bind(window);
126
+ window.fetch = function(input, init = {}) {
127
+ const requestUrl = typeof input === 'string' ? input : input?.url;
128
+ const requestMethod = String(init.method || input?.method || 'GET').toUpperCase();
129
+ if (requestUrl?.startsWith('/__') && !['GET', 'HEAD', 'OPTIONS'].includes(requestMethod)) {
130
+ const headers = new Headers(init.headers || input?.headers || {});
131
+ headers.set('X-CourseCode-Preview-Token', window.STUB_CONFIG.previewToken);
132
+ init = { ...init, headers };
133
+ }
134
+ return nativeFetch(input, init);
135
+ };
136
+ }
120
137
  </script>
121
138
  <script type="module" src="${moduleBasePath}/${isLive ? 'app' : 'app-viewer'}.js"></script>
122
139
 
package/lib/token.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Usage:
5
5
  * coursecode token # Generate random token
6
- * coursecode token --add client-name # Add client to course-config
6
+ * coursecode token --add client-name # Add client to gitignored access file
7
7
  */
8
8
 
9
9
  import crypto from 'crypto';
@@ -25,51 +25,48 @@ export function generateToken(length = 24) {
25
25
  export async function token(options = {}) {
26
26
  const newToken = generateToken();
27
27
 
28
- // If --add specified, add to course-config
28
+ // If --add specified, add to the non-published access file. Credentials
29
+ // must never live in course-config.js because that module is bundled into
30
+ // learner-facing JavaScript.
29
31
  if (options.add) {
30
32
  const clientId = options.add;
31
- const courseDir = path.join(process.cwd(), 'course');
32
- const configPath = path.join(courseDir, 'course-config.js');
33
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(clientId)) {
34
+ throw new Error('Client ID must use letters, numbers, dots, underscores, or hyphens');
35
+ }
36
+
37
+ const configPath = path.join(process.cwd(), 'course', 'course-config.js');
33
38
 
34
39
  if (!fs.existsSync(configPath)) {
35
40
  console.error('\n❌ No course-config.js found. Run from a CourseCode project directory.\n');
36
41
  process.exit(1);
37
42
  }
38
43
 
39
- let content = fs.readFileSync(configPath, 'utf-8');
44
+ const accessDir = path.join(process.cwd(), '.coursecode');
45
+ const accessPath = path.join(accessDir, 'access-control.json');
46
+ fs.mkdirSync(accessDir, { recursive: true });
40
47
 
41
- // Check if accessControl already exists
42
- if (content.includes('accessControl:')) {
43
- // Add to existing clients object
44
- const clientsMatch = content.match(/accessControl:\s*\{[\s\S]*?clients:\s*\{/);
45
- if (clientsMatch) {
46
- const insertPos = clientsMatch.index + clientsMatch[0].length;
47
- const newClient = `\n '${clientId}': { token: '${newToken}' },`;
48
- content = content.slice(0, insertPos) + newClient + content.slice(insertPos);
48
+ let accessConfig = { clients: {} };
49
+ if (fs.existsSync(accessPath)) {
50
+ try {
51
+ accessConfig = JSON.parse(fs.readFileSync(accessPath, 'utf-8'));
52
+ } catch (error) {
53
+ throw new Error(`Cannot parse ${accessPath}: ${error.message}`);
49
54
  }
50
- } else {
51
- // Add accessControl section before closing brace
52
- const accessControlBlock = `
53
- accessControl: {
54
- enabled: true,
55
- clients: {
56
- '${clientId}': { token: '${newToken}' }
57
55
  }
58
- },`;
59
- // Find the last closing brace of courseConfig
60
- const lastBrace = content.lastIndexOf('};');
61
- if (lastBrace !== -1) {
62
- content = content.slice(0, lastBrace) + accessControlBlock + '\n' + content.slice(lastBrace);
63
- }
56
+ if (!accessConfig.clients || typeof accessConfig.clients !== 'object') {
57
+ accessConfig.clients = {};
64
58
  }
65
-
66
- fs.writeFileSync(configPath, content, 'utf-8');
59
+ accessConfig.clients[clientId] = { token: newToken };
60
+ fs.writeFileSync(accessPath, JSON.stringify(accessConfig, null, 2) + '\n', { mode: 0o600 });
61
+ try { fs.chmodSync(accessPath, 0o600); } catch { /* Windows/filesystem may not support chmod */ }
67
62
 
68
63
  console.log(`
69
- ✅ Added client '${clientId}' to accessControl
64
+ ✅ Added client '${clientId}' to .coursecode/access-control.json
70
65
 
71
66
  Token: ${newToken}
72
67
 
68
+ Keep accessControl.enforcement = 'server' in course-config.js.
69
+ Your CDN/backend must validate this token before serving course files.
73
70
  Build will generate: ${clientId}_proxy.zip
74
71
  `);
75
72
  } else {