coursecode 0.1.53 → 0.1.55

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 (38) hide show
  1. package/README.md +6 -3
  2. package/bin/cli.js +14 -1
  3. package/framework/docs/COURSE_AUTHORING_GUIDE.md +13 -7
  4. package/framework/docs/FRAMEWORK_GUIDE.md +23 -22
  5. package/framework/docs/USER_GUIDE.md +9 -3
  6. package/framework/index.html +1 -1
  7. package/framework/js/core/event-bus.js +21 -6
  8. package/framework/js/drivers/lti-driver.js +50 -94
  9. package/framework/js/drivers/proxy-driver.js +8 -5
  10. package/framework/js/main.js +3 -15
  11. package/framework/js/utilities/access-control.js +11 -45
  12. package/framework/js/utilities/data-reporter.js +16 -3
  13. package/framework/version.json +26 -50
  14. package/lib/build-packaging.d.ts +4 -0
  15. package/lib/build-packaging.js +39 -2
  16. package/lib/cloud.js +1 -1
  17. package/lib/course-writer.js +97 -32
  18. package/lib/create.js +71 -18
  19. package/lib/manifest/cmi5-manifest.js +19 -10
  20. package/lib/manifest/lti-tool-config.js +18 -5
  21. package/lib/manifest/scorm-12-manifest.js +11 -5
  22. package/lib/manifest/scorm-2004-manifest.js +16 -9
  23. package/lib/manifest/xml-utils.js +14 -0
  24. package/lib/preview-routes-api.js +16 -7
  25. package/lib/preview-server.js +49 -28
  26. package/lib/project-utils.js +34 -0
  27. package/lib/proxy-templates/proxy.html +7 -3
  28. package/lib/proxy-templates/scorm-bridge.js +15 -9
  29. package/lib/scaffold.js +33 -4
  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 +33 -26
  34. package/template/course/course-config.js +9 -7
  35. package/template/gitattributes +44 -0
  36. package/template/gitignore +38 -0
  37. package/template/package.json +13 -4
  38. package/template/vite.config.js +90 -27
package/lib/create.js CHANGED
@@ -77,6 +77,42 @@ function copyDir(src, dest, options = {}) {
77
77
  }
78
78
  }
79
79
 
80
+ function mergeProjectControlFile(sourcePath, destinationPath) {
81
+ const source = fs.readFileSync(sourcePath, 'utf-8').trimEnd();
82
+ if (!fs.existsSync(destinationPath)) {
83
+ fs.writeFileSync(destinationPath, `${source}\n`, 'utf-8');
84
+ return;
85
+ }
86
+
87
+ const current = fs.readFileSync(destinationPath, 'utf-8');
88
+ const existingLines = new Set(current.split(/\r?\n/).map(line => line.trim()));
89
+ const missingRules = source
90
+ .split(/\r?\n/)
91
+ .filter(line => line.trim() && !line.trim().startsWith('#') && !existingLines.has(line.trim()));
92
+
93
+ if (missingRules.length === 0) return;
94
+
95
+ const separator = current.endsWith('\n') ? '\n' : '\n\n';
96
+ fs.appendFileSync(
97
+ destinationPath,
98
+ `${separator}# CourseCode project defaults\n${missingRules.join('\n')}\n`,
99
+ 'utf-8'
100
+ );
101
+ }
102
+
103
+ function assertCurrentDirectoryCanBeInitialized(targetDir, templateDir) {
104
+ const managedPaths = fs.readdirSync(templateDir)
105
+ .filter(name => !['.DS_Store', 'gitignore', 'gitattributes'].includes(name))
106
+ .concat(['framework', 'schemas', 'lib', '.coursecoderc.json']);
107
+ const conflicts = managedPaths.filter(name => fs.existsSync(path.join(targetDir, name)));
108
+
109
+ if (conflicts.length > 0) {
110
+ throw new Error(
111
+ `Cannot initialize the current directory because CourseCode-managed paths already exist: ${conflicts.join(', ')}`
112
+ );
113
+ }
114
+ }
115
+
80
116
  /**
81
117
  * Run npm install in directory
82
118
  */
@@ -123,7 +159,10 @@ function gitInit(cwd) {
123
159
  }
124
160
 
125
161
  export async function create(name, options = {}) {
126
- const displayName = String(name || '').trim();
162
+ const requestedName = String(name || '').trim();
163
+ const currentDirectory = options.currentDirectory === true || requestedName === '.';
164
+ const inferredName = path.basename(process.cwd()).replace(/[-_]+/g, ' ');
165
+ const displayName = currentDirectory && requestedName === '.' ? inferredName : requestedName;
127
166
  const directoryName = toProjectDirectoryName(displayName);
128
167
 
129
168
  if (!displayName) {
@@ -136,16 +175,18 @@ export async function create(name, options = {}) {
136
175
  process.exit(1);
137
176
  }
138
177
 
139
- const targetDir = path.resolve(process.cwd(), directoryName);
178
+ const targetDir = currentDirectory ? path.resolve(process.cwd()) : path.resolve(process.cwd(), directoryName);
179
+ const templateDir = path.join(PACKAGE_ROOT, 'template');
140
180
 
141
- // Check if directory already exists
142
- if (fs.existsSync(targetDir)) {
181
+ if (currentDirectory) {
182
+ assertCurrentDirectoryCanBeInitialized(targetDir, templateDir);
183
+ } else if (fs.existsSync(targetDir)) {
143
184
  console.error(`\nāŒ Directory "${directoryName}" already exists.\n`);
144
185
  process.exit(1);
145
186
  }
146
187
 
147
188
  console.log(`\nšŸš€ Creating CourseCode project: ${displayName}\n`);
148
- if (directoryName !== displayName) {
189
+ if (!currentDirectory && directoryName !== displayName) {
149
190
  console.log(` Project folder: ${directoryName}`);
150
191
  }
151
192
 
@@ -153,11 +194,12 @@ export async function create(name, options = {}) {
153
194
  fs.mkdirSync(targetDir, { recursive: true });
154
195
 
155
196
  // Copy template files (course directory and vite config)
156
- const templateDir = path.join(PACKAGE_ROOT, 'template');
157
197
  console.log(' Copying template files...');
158
198
  copyDir(templateDir, targetDir, {
159
- exclude: [/^\.DS_Store$/, /^node_modules$/]
199
+ exclude: [/^\.DS_Store$/, /^node_modules$/, /^gitignore$/, /^gitattributes$/]
160
200
  });
201
+ mergeProjectControlFile(path.join(templateDir, 'gitignore'), path.join(targetDir, '.gitignore'));
202
+ mergeProjectControlFile(path.join(templateDir, 'gitattributes'), path.join(targetDir, '.gitattributes'));
161
203
 
162
204
  // Copy framework
163
205
  const frameworkSrc = path.join(PACKAGE_ROOT, 'framework');
@@ -189,14 +231,19 @@ export async function create(name, options = {}) {
189
231
  console.log(' Copying proxy packaging templates...');
190
232
  copyDir(proxyTemplatesSrc, proxyTemplatesDest);
191
233
 
192
- // Read and customize package.json
234
+ // Read and customize package.json. Pin the authoring/build CLI to the
235
+ // version that created the project so an isolated checkout can build.
236
+ const frameworkPkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
193
237
  const pkgPath = path.join(targetDir, 'package.json');
194
238
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
195
239
  pkg.name = directoryName;
240
+ pkg.devDependencies = {
241
+ ...pkg.devDependencies,
242
+ coursecode: `^${frameworkPkg.version}`
243
+ };
196
244
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
197
245
 
198
246
  // Create .coursecoderc.json to track framework version
199
- const frameworkPkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
200
247
  const coursecoderc = {
201
248
  frameworkVersion: frameworkPkg.version,
202
249
  createdAt: new Date().toISOString(),
@@ -212,7 +259,7 @@ export async function create(name, options = {}) {
212
259
  // If --blank, remove example files and reset config
213
260
  if (options.blank) {
214
261
  const { clean } = await import('./scaffold.js');
215
- clean({ basePath: targetDir });
262
+ clean({ basePath: targetDir, blank: true });
216
263
  }
217
264
 
218
265
  writeCourseTitle(targetDir, displayName);
@@ -229,12 +276,16 @@ export async function create(name, options = {}) {
229
276
  }
230
277
 
231
278
  // Initialize git repository
232
- console.log('\n Initializing git repository...');
233
- try {
234
- await gitInit(targetDir);
235
- console.log(' āœ… Git repository initialized');
236
- } catch (_error) {
237
- console.warn(' āš ļø Git init failed. You can run "git init" manually.');
279
+ if (fs.existsSync(path.join(targetDir, '.git'))) {
280
+ console.log('\n āœ… Existing Git repository preserved');
281
+ } else {
282
+ console.log('\n Initializing git repository...');
283
+ try {
284
+ await gitInit(targetDir);
285
+ console.log(' āœ… Git repository initialized');
286
+ } catch (_error) {
287
+ console.warn(' āš ļø Git init failed. You can run "git init" manually.');
288
+ }
238
289
  }
239
290
 
240
291
  // Print success message
@@ -287,12 +338,14 @@ export async function create(name, options = {}) {
287
338
  console.log(` cd ${directoryName} && coursecode dev\n`);
288
339
  });
289
340
  } else {
290
- console.log(`\n To start developing:\n\n cd ${directoryName}\n coursecode dev\n`);
341
+ const changeDirectory = currentDirectory ? '' : `cd ${directoryName}\n `;
342
+ console.log(`\n To start developing:\n\n ${changeDirectory}coursecode dev\n`);
291
343
  }
292
344
 
293
345
  return {
294
346
  displayName,
295
347
  directoryName,
296
- targetDir
348
+ targetDir,
349
+ currentDirectory
297
350
  };
298
351
  }
@@ -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>
@@ -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
  // =============================================================================