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/README.md CHANGED
@@ -23,7 +23,7 @@ Bring your own PDFs, Word docs, or other reference documents, use AI to accelera
23
23
  - **MCP integration**: Works with Claude Code, Codex, Cursor, CourseCode Desktop, and any MCP-capable AI tool — previews, screenshots, linting, and testing without manual file sharing
24
24
  - **No coding required to start**: Describe what you want and let AI help build slides, interactions, and structure
25
25
  - **Course modernization**: Use existing SCORM courses, PowerPoints, PDFs, Word docs, scripts, and SME notes as source material for upgraded courses
26
- - **Full LMS integration**: SCORM 1.2, SCORM 2004, cmi5, and LTI with complete tracking records
26
+ - **LMS integration**: SCORM 1.2, SCORM 2004, and cmi5 packaging, plus an LTI 1.3 browser contract for trusted server-backed launches
27
27
  - **AI-assisted authoring workflow**: Structured guides and MCP tools for faster course development
28
28
  - **Rich UI components**: Images, video, accordions, tabs, and custom sandboxed HTML/JS embeds
29
29
  - **Rich interactions**: Multiple choice, drag-drop, fill-in-the-blank, matching, sequencing, and more
@@ -44,12 +44,14 @@ Bring your own PDFs, Word docs, or other reference documents, use AI to accelera
44
44
 
45
45
  ### Required
46
46
 
47
- Install [Node.js](https://nodejs.org/) (v18 or later), then run:
47
+ Install [Node.js](https://nodejs.org/) (v20.19 or later), then run:
48
48
 
49
49
  ```bash
50
50
  npm install -g coursecode
51
51
  ```
52
52
 
53
+ Learner courses support Chrome 111+, Edge 111+, Firefox 114+, and Safari 16.4+. SCORM compatibility describes LMS communication and packaging; it does not imply Internet Explorer support.
54
+
53
55
  ### Recommended
54
56
 
55
57
  - A code or text editor — [VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.com/), or similar
@@ -233,7 +235,8 @@ coursecode preview --export
233
235
 
234
236
  | Command | Description |
235
237
  |---------|-------------|
236
- | `coursecode create <name>` | Create a new course project |
238
+ | `coursecode create <name>` | Create a new course project; use `.` to initialize the current directory |
239
+ | `coursecode init [name]` | Initialize the current directory, optionally with a course title |
237
240
  | `coursecode preview` | Preview your course locally |
238
241
  | `coursecode convert` | Convert PDFs, Word, PowerPoint to markdown |
239
242
  | `coursecode mcp` | Start the MCP server for AI integration |
package/bin/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Commands:
7
7
  * coursecode create <name> - Create a new course project
8
+ * coursecode init [name] - Initialize the current directory
8
9
  * coursecode dev - Start development server
9
10
  * coursecode build - Build course package
10
11
  * coursecode upgrade - Upgrade framework in current project
@@ -79,7 +80,7 @@ program
79
80
  // Create command
80
81
  program
81
82
  .command('create <name>')
82
- .description('Create a new course project')
83
+ .description('Create a new course project (use "." for the current directory)')
83
84
  .option('--blank', 'Create without example slides (clean starter)')
84
85
  .option('--no-install', 'Skip npm install')
85
86
  .option('--start', 'Auto-start dev server after creation (skip prompt)')
@@ -88,6 +89,17 @@ program
88
89
  await create(name, options);
89
90
  });
90
91
 
92
+ program
93
+ .command('init [name]')
94
+ .description('Initialize the current directory as a CourseCode project')
95
+ .option('--blank', 'Create without example content (clean starter)')
96
+ .option('--no-install', 'Skip npm install')
97
+ .option('--start', 'Auto-start dev server after creation (skip prompt)')
98
+ .action(async (name, options) => {
99
+ const { create } = await import('../lib/create.js');
100
+ await create(name || path.basename(process.cwd()), { ...options, currentDirectory: true });
101
+ });
102
+
91
103
  // Dev command
92
104
  program
93
105
  .command('dev')
@@ -210,6 +222,7 @@ program
210
222
  .option('-p, --password <password>', 'Require password to access preview (export only)')
211
223
  .option('--title <title>', 'Custom title (default: course title)')
212
224
  .option('--port <port>', 'Preview server port (default: 4173)', '4173')
225
+ .option('--host <host>', 'Preview bind address (default: 127.0.0.1)', '127.0.0.1')
213
226
  .option('--skip-build', 'Skip build step for export (use existing dist folder)')
214
227
  .option('--nojekyll', 'Add .nojekyll file (required for GitHub Pages)')
215
228
  .option('--no-content', 'Exclude course content viewer from preview toolbar')
@@ -16,6 +16,8 @@
16
16
  |---------|-------------|
17
17
  | `coursecode create <name>` | Create a new course project (includes example slides) |
18
18
  | `coursecode create <name> --blank` | Create a blank project (no example content) |
19
+ | `coursecode create .` | Initialize the current directory using its folder name as the course title |
20
+ | `coursecode init [name]` | Initialize the current directory, optionally with a specific course title |
19
21
  | `coursecode clean` | Remove example files and reset config to minimal starter |
20
22
  | `coursecode new slide <id>` | Create a new slide file in `course/slides/` |
21
23
  | `coursecode new assessment <id>` | Create a new assessment file in `course/slides/` |
@@ -35,6 +37,8 @@
35
37
  | `coursecode test-data` | Test data reporting webhook configuration |
36
38
  | `coursecode mcp` | Start MCP server for AI agent integration |
37
39
 
40
+ When upgrading a project created before the Vite 8/browser-policy migration, run `coursecode upgrade --configs` and then `npm install`. The command backs up customized Vite/ESLint files, installs the managed Vite 8 dependency ranges, and removes the retired IE11 legacy plugin. Review the `.backup` files before deleting them.
41
+
38
42
  **Format options** (for local `dev`, `build`, `preview` — not needed for cloud-deployed courses):
39
43
  ```bash
40
44
  coursecode build --format scorm1.2 # Build for SCORM 1.2 LMS
@@ -105,10 +109,13 @@ coursecode preview # Open http://localhost:4173
105
109
  | Option | Description |
106
110
  |--------|-------------|
107
111
  | `--port <port>` | Preview server port (default: 4173) |
112
+ | `--host <host>` | Bind address (default: `127.0.0.1`; use another address only for deliberate LAN access) |
108
113
  | `--title <title>` | Custom browser title |
109
114
  | `--no-content` | Disable course content viewer |
110
115
  | `-f, --format <format>` | LMS format: `scorm2004`, `scorm1.2`, `cmi5`, or `lti` |
111
116
 
117
+ Live preview includes source-editing endpoints, so it binds to loopback by default and protects mutations with a per-process token. Treat `--host 0.0.0.0` as deliberate developer-machine access, not as a deployment mode.
118
+
112
119
  ### Static Export
113
120
 
114
121
  Generate a self-contained folder for stakeholder sharing (Netlify, GitHub Pages, etc.):
@@ -248,7 +255,7 @@ coursecode export-content --format json -o content.json
248
255
  3. **Create slide** in `course/slides/intro.js`:
249
256
  ```javascript
250
257
  export const slide = {
251
- render(root, context) {
258
+ render(_root, _context) {
252
259
  const container = document.createElement('div');
253
260
  container.innerHTML = `<h1>Welcome</h1><p>Content here</p>`;
254
261
  return container; // Must return a DOM element
@@ -256,6 +263,8 @@ coursecode export-content --format json -o content.json
256
263
  };
257
264
  ```
258
265
 
266
+ The first render argument is reserved and currently receives `null`. Keep it as `_root` for API compatibility. Create and return a new root `HTMLElement`; use the second argument only when the slide needs navigation or assessment context.
267
+
259
268
  > **⚠️ No import statements.** Components, interactions, CSS classes, and icons are all globally available at runtime. The only valid `import` is for local assets (images, SVGs). Access framework APIs via `const { createXxxQuestion } = CourseCode;` (destructure from global, **not** an import).
260
269
 
261
270
  4. **Add styles** to `course/theme.css` (only for custom branding - use framework utility classes first, see CSS Quick Reference section below)
@@ -283,17 +292,14 @@ Deploy courses to a CDN for instant updates without re-uploading to the LMS.
283
292
  format: 'scorm1.2-proxy',
284
293
  externalUrl: 'https://cdn.example.com/my-course',
285
294
  accessControl: {
286
- clients: {
287
- 'acme-corp': { token: 'abc123' },
288
- 'globex': { token: 'def456' }
289
- }
295
+ enforcement: 'server'
290
296
  }
291
297
  ```
292
298
 
293
299
  **Generate tokens:**
294
300
  ```bash
295
301
  coursecode token # Generate random token
296
- coursecode token --add acme-corp # Add client with token to config
302
+ coursecode token --add acme-corp # Store token in .coursecode/access-control.json
297
303
  ```
298
304
 
299
305
  **Build:**
@@ -302,7 +308,7 @@ coursecode build # Creates dist/ + *_<client>_proxy.zip per client
302
308
  ```
303
309
 
304
310
  **Deploy:**
305
- 1. Upload `dist/` to CDN (GitHub Pages, Vercel, Netlify, etc.)
311
+ 1. Upload `dist/` behind a CDN/backend that validates the generated client credentials before serving any file
306
312
  2. Upload client-specific proxy ZIP to each client's LMS
307
313
  3. Future updates: just redeploy to CDN—no LMS re-upload needed
308
314
 
@@ -37,18 +37,20 @@ npm run preview
37
37
  # - Content viewer for course review
38
38
  ```
39
39
 
40
- ### Dependency Upgrade Policy
40
+ ### Build and Browser Compatibility Policy
41
41
 
42
- The framework intentionally stays on **Vite 7** for now:
42
+ CourseCode uses Vite 8 and emits one standards-based ESM build. The supported learner-browser floor is intentionally fixed in both Vite configurations:
43
43
 
44
- - `vite@7.2.x`, `@vitejs/plugin-legacy@7.x`, and `vite-plugin-static-copy@3.x` are the compatible set used by the framework and course template.
45
- - SCORM itself does **not** require ES5 JavaScript. SCORM defines LMS runtime/API communication and package metadata, not the learner browser's ECMAScript level.
46
- - The legacy bundle path is still kept as compatibility insurance for unknown or older LMS launch environments, especially SCORM 1.2/2004 deployments that may use old embedded browsers or enterprise browser policies.
47
- - Vite 8 currently breaks that legacy path because Rolldown does not support the `system` output format used by `@vitejs/plugin-legacy` for SystemJS/ES5 fallback chunks.
48
- - Vite 7.3 has also shown a generated-course packaging regression with the legacy/post-build flow, where package validation can run before `dist/index.html` has been moved into place. Keep the template and framework dev dependency on `7.2.x` until that flow is reworked or verified against a newer Vite 7 patch.
49
- - Do not upgrade Vite, `@vitejs/plugin-legacy`, or `vite-plugin-static-copy` to their Vite 8 lines until CourseCode explicitly drops legacy browser fallback support or adds a separate verified legacy build pipeline.
44
+ - Chrome 111+
45
+ - Edge 111+
46
+ - Firefox 114+
47
+ - Safari 16.4+
50
48
 
51
- Other dependencies can be upgraded normally when their Node engine requirements and tests fit the framework. Keep `marked` on `17.x` while the public package requirement remains Node 18+, because `marked@18` requires Node 20+.
49
+ SCORM 1.2 and SCORM 2004 define the LMS runtime/API contract; they do not require ES3, ES5, Internet Explorer, or a SystemJS bundle. CourseCode no longer generates an IE11 fallback with `@vitejs/plugin-legacy`. The framework's CSS design system depends heavily on custom properties, Grid, `color-mix()`, and other modern platform features that JavaScript transpilation cannot make reliable in IE11.
50
+
51
+ Treat an older embedded LMS browser as a separate, explicitly tested customer requirement. Do not re-enable Babel/SystemJS as a general compatibility precaution: a legacy JavaScript bundle alone is not evidence that the complete course UI works in that engine.
52
+
53
+ The framework and generated projects require **Node 20.19+**. Vite, `vite-plugin-static-copy`, and their lockfiles must be upgraded together and verified with a freshly generated project plus every LMS-format build.
52
54
 
53
55
  ### LMS Compatibility Warnings
54
56
  The preview server (stub player) includes an advanced diagnostic system that monitors API usage and data limits to detect potential issues before deployment to a real LMS.
@@ -124,7 +126,6 @@ dist/assets/
124
126
  cmi5-driver.js ← Lazy chunk, only fetched if format = 'cmi5'
125
127
  lti-driver.js ← Lazy chunk, only fetched if format = 'lti'
126
128
  proxy-driver.js ← Lazy chunk, only fetched if format = '*-proxy'
127
- jose.js ← Lazy chunk, only fetched by lti-driver.js
128
129
  ```
129
130
 
130
131
  CourseCode delivery serves static assets by file extension, not stored upload metadata. Supported course asset types include HTML, JS, CSS, JSON/XML, common images/fonts/audio/video, PDF, `csv`, `vtt`, `wasm`, `gltf/glb`, and source maps; unknown extensions are served as `application/octet-stream`.
@@ -204,19 +205,14 @@ For hot-fixable courses, the framework supports **external hosting**: course con
204
205
  export const courseConfig = {
205
206
  format: 'scorm1.2-proxy',
206
207
  externalUrl: 'https://cdn.example.com/my-course', // Required
207
- accessControl: { // Required for proxy/remote formats
208
- clients: {
209
- 'acme-corp': { token: 'abc123' },
210
- 'globex': { token: 'def456' }
211
- }
212
- }
208
+ accessControl: { enforcement: 'server' }
213
209
  };
214
210
  ```
215
211
 
216
212
  **Generate tokens:**
217
213
  ```bash
218
214
  coursecode token # Generate random token
219
- coursecode token --add acme-corp # Add client to config
215
+ coursecode token --add acme-corp # Store client in .coursecode/access-control.json
220
216
  ```
221
217
 
222
218
  **Build outputs:**
@@ -224,9 +220,10 @@ coursecode token --add acme-corp # Add client to config
224
220
  - `*_acme-corp_proxy.zip`, `*_globex_proxy.zip` — One package per client
225
221
 
226
222
  **Access Control:**
227
- - Tokens are injected into URLs: `https://cdn.example.com/my-course?clientId=acme-corp&token=abc123`
228
- - Runtime validation in `access-control.js` blocks unauthorized access
229
- - To disable a client: remove from config redeploy CDN
223
+ - Tokens are stored in the gitignored `.coursecode/access-control.json`, never in learner-facing course source.
224
+ - Tokens are injected into client package URLs: `https://cdn.example.com/my-course?clientId=acme-corp&token=...`.
225
+ - The CDN/backend **must validate credentials before serving `index.html` or assets**. Browser-side checks are not access control.
226
+ - To disable a client, remove it from the access file and revoke it at the delivery layer.
230
227
 
231
228
  **Architecture:**
232
229
  - **Proxy formats**: LMS loads a lightweight proxy (proxy.html + bridge) that iframes the CDN-hosted course. The bridge relays `postMessage` calls to the LMS SCORM API via pipwerks.
@@ -236,11 +233,15 @@ coursecode token --add acme-corp # Add client to config
236
233
  | File | Purpose |
237
234
  |------|---------|
238
235
  | `framework/js/drivers/proxy-driver.js` | Course-side postMessage LMSDriver |
239
- | `framework/js/utilities/access-control.js` | Token validation + unauthorized screen |
236
+ | `.coursecode/access-control.json` | Gitignored build-time client credentials |
240
237
  | `lib/proxy-templates/proxy.html` | Proxy package entry point |
241
238
  | `lib/proxy-templates/scorm-bridge.js` | postMessage ↔ pipwerks bridge |
242
239
  | `lib/token.js` | CLI token generator |
243
240
 
241
+ ### LTI Trust Boundary
242
+
243
+ LTI 1.3 requires a trusted server backend. The backend performs OIDC state/nonce, issuer, audience, signature, and deployment validation; holds the tool private key; exchanges OAuth client credentials; persists learner state; and proxies AGS score writes. The browser driver only consumes server-validated launch claims and calls same-origin state/AGS proxy endpoints. Direct browser `id_token` processing is intentionally rejected.
244
+
244
245
  **Preview mode**: Proxy/remote suffixes are stripped (e.g., `scorm1.2-proxy` → `scorm1.2`) so the stub LMS works normally.
245
246
 
246
247
  ---
@@ -309,7 +310,7 @@ Separates data, presentation, logic (used in `app/`, `navigation/`):
309
310
 
310
311
  - **Single ViewManager** in `main.js` controls slide navigation
311
312
  - **No caching** - views render fresh each `showView()` to prevent stale data
312
- - Slide signature: `render(root, context)` where `root` is framework-provided container
313
+ - Slide signature: `render(_root, context)`. The first argument is reserved and currently `null`; slides create and return their own root `HTMLElement`. The second argument carries navigation/render context.
313
314
  - Components with sub-views (assessments) create own ViewManager
314
315
 
315
316
  ### Event Delegation
@@ -66,7 +66,7 @@ A complete guide to creating interactive e-learning courses with AI assistance.
66
66
  ### What You'll Need
67
67
 
68
68
  **Required:**
69
- - [Node.js](https://nodejs.org/) (version 18 or later)
69
+ - [Node.js](https://nodejs.org/) (version 20.19 or later)
70
70
 
71
71
  **Recommended:**
72
72
  - A text editor like [VS Code](https://code.visualstudio.com/) or [Notepad++](https://notepad-plus-plus.org/)
@@ -648,10 +648,14 @@ An LMS (Learning Management System) is the platform your organization uses to de
648
648
  | **SCORM 1.2** | Oldest standard, most compatible. | Older systems, or when you're unsure |
649
649
  | **LTI** | Integration standard, not a package format. | LMS platforms that use LTI (Canvas, Blackboard) |
650
650
 
651
+ > **LTI requires a backend:** A production LTI 1.3 launch is not a static ZIP. A trusted server must complete OIDC/JWT validation, hold the private key, persist learner state, and proxy AGS score writes. The CourseCode browser driver intentionally rejects raw `id_token` launches.
652
+
651
653
  **Not sure which to pick?** Ask your LMS administrator. If they don't know, try **SCORM 1.2** — it works with almost everything.
652
654
 
653
655
  > **SCORM 1.2 caveat:** SCORM 1.2 has a strict ~4KB suspend data limit. CourseCode uses a strict storage mode to fit within that limit, which can reduce how much interaction UI state is restored across slides on resume.
654
656
 
657
+ > **Browser support:** CourseCode learner packages support Chrome 111+, Edge 111+, Firefox 114+, and Safari 16.4+. SCORM format selection does not change that browser floor. Internet Explorer and other non-ESM webviews are not supported.
658
+
655
659
  > **Using CourseCode Cloud?** You don't need to choose a format. Cloud-deployed courses use a universal build — the cloud generates the correct format automatically when you download a ZIP for your LMS. The format setting in `course-config.js` only applies to local `coursecode build` commands.
656
660
 
657
661
  ### Standard Deployment
@@ -669,7 +673,7 @@ This creates a ZIP file in `dist/` that you upload directly to your LMS. Every t
669
673
 
670
674
  For teams that update courses frequently or serve multiple LMS clients, CourseCode supports **CDN deployment**. Instead of uploading the full course to each LMS, you:
671
675
 
672
- 1. Host the course on a CDN (like Netlify, Vercel, or GitHub Pages)
676
+ 1. Host the course behind a CDN/backend that can authorize every course file
673
677
  2. Upload a tiny proxy package (~15KB) to each LMS
674
678
  3. The proxy loads the course from the CDN at runtime
675
679
 
@@ -678,7 +682,7 @@ For teams that update courses frequently or serve multiple LMS clients, CourseCo
678
682
  - **Multi-tenant** — one CDN deployment serves multiple LMS clients, each with their own access token
679
683
  - **Smaller LMS packages** — faster upload and launch times
680
684
 
681
- CDN deployment uses special format variants (`scorm1.2-proxy`, `scorm2004-proxy`, `cmi5-remote`). Ask your AI assistant to set this up it involves configuring an external URL and access tokens in `course-config.js`.
685
+ CDN deployment uses special format variants (`scorm1.2-proxy`, `scorm2004-proxy`, `cmi5-remote`). Configure `accessControl: { enforcement: 'server' }` in `course-config.js`; client credentials are stored separately in the gitignored `.coursecode/access-control.json`.
682
686
 
683
687
  Generate and add client tokens with:
684
688
 
@@ -687,6 +691,8 @@ coursecode token --add client-a
687
691
  coursecode token --add client-b
688
692
  ```
689
693
 
694
+ These commands create build-time credentials only. Your CDN/backend must validate them before serving `index.html`, JavaScript, or assets. Static hosts without request authorization cannot protect course content.
695
+
690
696
  Then build your proxy/remote package and deploy:
691
697
 
692
698
  ```bash
@@ -7,7 +7,7 @@
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
8
8
  <meta name="description" id="page-description" content="" />
9
9
  <link rel="stylesheet" href="css/framework.css" />
10
- <link rel="stylesheet" href="../template/course/theme.css" />
10
+ <link rel="stylesheet" href="../course/theme.css" />
11
11
  <!-- Skip link is styled via framework.css for accessibility -->
12
12
  </head>
13
13
 
@@ -110,11 +110,12 @@ class EventBus {
110
110
  * @returns {boolean} True if event had listeners
111
111
  */
112
112
  emit(event, data) {
113
- if (!this.events[event] || this.events[event].length === 0) {
114
- return false;
115
- }
113
+ // log:error is the logger transport itself; treating it as another source
114
+ // error would recursively call logger.error().
115
+ const isErrorEvent = event.endsWith(':error') && event !== 'log:error';
116
+ const hasListeners = Boolean(this.events[event]?.length);
116
117
 
117
- const isErrorEvent = event.endsWith(':error');
118
+ if (!isErrorEvent && !hasListeners) return false;
118
119
 
119
120
  // Re-entrancy guard — if we're already inside an :error emit,
120
121
  // suppress to prevent infinite cascade
@@ -132,6 +133,8 @@ class EventBus {
132
133
  logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
133
134
  }
134
135
 
136
+ if (!hasListeners) return false;
137
+
135
138
  // Create a copy of listeners to avoid issues if listeners modify the array
136
139
  const listeners = [...this.events[event]];
137
140
  const onceListeners = [];
@@ -170,10 +173,22 @@ class EventBus {
170
173
  * @returns {Promise} Resolves when all listeners have been called
171
174
  */
172
175
  async emitAsync(event, data) {
173
- if (!this.events[event] || this.events[event].length === 0) {
174
- return false;
176
+ const isErrorEvent = event.endsWith(':error') && event !== 'log:error';
177
+ const hasListeners = Boolean(this.events[event]?.length);
178
+ if (!isErrorEvent && !hasListeners) return false;
179
+
180
+ if (isErrorEvent) {
181
+ if (this._emittingError) {
182
+ logger.warn(`[EventBus] Suppressed recursive error event: ${event}`);
183
+ return false;
184
+ }
185
+ this._emittingError = true;
186
+ logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
187
+ this._emittingError = false;
175
188
  }
176
189
 
190
+ if (!hasListeners) return false;
191
+
177
192
  const listeners = [...this.events[event]];
178
193
  const onceListeners = [];
179
194
 
@@ -4,14 +4,14 @@
4
4
  * Extends HttpDriverBase for shared mock state, suspend data, and semantic interface.
5
5
  *
6
6
  * LTI 1.3 launch flow:
7
- * 1. Platform redirects to tool with id_token (JWT) via OIDC
8
- * 2. Driver validates JWT using platform's JWKS endpoint
9
- * 3. State persistence via host-provided state endpoint
10
- * 4. Score reporting via AGS (Assignment and Grade Services)
7
+ * 1. A trusted same-origin backend completes OIDC and validates the launch
8
+ * 2. The backend injects validated display claims into the launch HTML
9
+ * 3. State persistence uses a same-origin authenticated endpoint
10
+ * 4. Score reporting uses a same-origin AGS proxy endpoint
11
11
  *
12
12
  * This driver adds:
13
- * - JWT validation and claims extraction
14
- * - AGS score passback on terminate
13
+ * - Validated-claim extraction from trusted launch HTML
14
+ * - AGS score passback through a server-side proxy on terminate
15
15
  * - State persistence via host endpoint
16
16
  * - Emergency save via sendBeacon
17
17
  */
@@ -19,8 +19,6 @@
19
19
  import { HttpDriverBase } from './http-driver-base.js';
20
20
  import { logger } from '../utilities/logger.js';
21
21
 
22
- // jose is dynamically imported in initialize() to avoid bundling for non-LTI builds
23
-
24
22
  // =============================================================================
25
23
  // LTI Driver Class
26
24
  // =============================================================================
@@ -33,9 +31,7 @@ export class LtiDriver extends HttpDriverBase {
33
31
  this._claims = null;
34
32
 
35
33
  // AGS endpoint (from JWT claims)
36
- this._agsEndpoint = null;
37
- this._agsLineItemUrl = null;
38
- this._accessToken = null;
34
+ this._agsProxyEndpoint = null;
39
35
 
40
36
  // Host state endpoint for suspend_data persistence
41
37
  this._stateEndpoint = null;
@@ -86,7 +82,7 @@ export class LtiDriver extends HttpDriverBase {
86
82
  return true;
87
83
  }
88
84
 
89
- // Check for LTI launch parameters
85
+ // Check for a server-validated LTI launch context
90
86
  if (!this._hasLaunchParameters()) {
91
87
  if (import.meta.env.DEV) {
92
88
  logger.info('[LtiDriver] No LTI launch parameters. Using localStorage mock.');
@@ -96,10 +92,10 @@ export class LtiDriver extends HttpDriverBase {
96
92
  this._logMockStatement('initialized', { verb: 'initialized' });
97
93
  return true;
98
94
  }
99
- throw new Error('[LtiDriver] No LTI launch context detected. Expected <meta name="lms-format" content="lti"> or id_token/state URL parameters.');
95
+ throw new Error('[LtiDriver] No trusted LTI launch context detected. LTI requires a server-side OIDC backend that injects cc-lti-claims.');
100
96
  }
101
97
 
102
- // Production mode: validate JWT and extract claims
98
+ // Production mode: consume the server-validated launch session
103
99
  try {
104
100
  await this._processLaunch();
105
101
  await this._prefetchState();
@@ -255,63 +251,33 @@ export class LtiDriver extends HttpDriverBase {
255
251
  _hasLaunchParameters() {
256
252
  if (typeof window === 'undefined') return false;
257
253
 
258
- // Cloud-hosted: engine handles OIDC server-side, signals via meta tag
259
- const formatMeta = document.querySelector('meta[name="lms-format"]');
260
- if (formatMeta?.content === 'lti') return true;
261
-
262
- // Self-hosted: JWT params in URL from OIDC redirect
254
+ // Raw browser JWT handling is intentionally rejected. A browser cannot
255
+ // safely hold the tool private key or exchange AGS client credentials.
263
256
  const params = new URLSearchParams(window.location.search);
264
- return Boolean(
265
- params.get('id_token') ||
266
- params.get('state') ||
267
- window.location.hash.includes('id_token')
268
- );
257
+ if (params.get('id_token') || params.get('state') || window.location.hash.includes('id_token')) {
258
+ return false;
259
+ }
260
+
261
+ return Boolean(document.querySelector('meta[name="cc-lti-claims"]')?.content);
269
262
  }
270
263
 
271
264
  async _processLaunch() {
272
265
  const params = new URLSearchParams(window.location.search);
273
- const idToken = params.get('id_token');
274
-
275
- // Cloud-hosted path: OIDC handled server-side, no JWT in URL
276
- if (!idToken) {
277
- this._claims = this._resolveCloudClaims();
278
- this._stateEndpoint = this._resolveStateEndpoint();
279
-
280
- const agsUrl = this._resolveCloudAgsEndpoint();
281
- if (agsUrl) {
282
- this._agsLineItemUrl = agsUrl;
283
- logger.debug('[LtiDriver] Cloud AGS endpoint:', agsUrl);
284
- }
285
-
286
- logger.debug('[LtiDriver] Cloud-hosted launch. User:', this._claims?.sub || 'unknown');
287
- return;
266
+ if (params.get('id_token') || params.get('state') || window.location.hash.includes('id_token')) {
267
+ throw new Error('Direct browser OIDC/JWT launches are not supported. Complete LTI OIDC on a trusted backend.');
288
268
  }
289
269
 
290
- // Self-hosted path: validate JWT from URL params
291
- const { jwtVerify, createRemoteJWKSet } = await import('jose');
292
-
293
- const [headerB64] = idToken.split('.');
294
- const header = JSON.parse(atob(headerB64));
270
+ this._claims = this._resolveCloudClaims();
271
+ this._validateLtiClaims(this._claims);
272
+ this._stateEndpoint = this._resolveTrustedSameOriginEndpoint(this._resolveStateEndpoint(), 'state');
295
273
 
296
- const jwksUrl = this._getJwksUrl(header);
297
- const JWKS = createRemoteJWKSet(new URL(jwksUrl));
298
-
299
- const { payload } = await jwtVerify(idToken, JWKS, {
300
- algorithms: ['RS256', 'ES256']
301
- });
302
-
303
- this._validateLtiClaims(payload);
304
- this._claims = payload;
305
-
306
- const agsEndpoint = payload['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'];
307
- if (agsEndpoint) {
308
- this._agsEndpoint = agsEndpoint.lineitems;
309
- this._agsLineItemUrl = agsEndpoint.lineitem;
310
- logger.debug('[LtiDriver] AGS endpoint configured:', this._agsEndpoint);
274
+ const agsUrl = this._resolveCloudAgsEndpoint();
275
+ if (agsUrl) {
276
+ this._agsProxyEndpoint = this._resolveTrustedSameOriginEndpoint(agsUrl, 'AGS proxy');
277
+ logger.debug('[LtiDriver] Same-origin AGS proxy configured:', this._agsProxyEndpoint);
311
278
  }
312
279
 
313
- this._stateEndpoint = this._resolveStateEndpoint();
314
- logger.debug('[LtiDriver] Launch processed. User:', payload.sub);
280
+ logger.debug('[LtiDriver] Server-validated launch. User:', this._claims?.sub || 'unknown');
315
281
  }
316
282
 
317
283
  _validateLtiClaims(claims) {
@@ -338,26 +304,24 @@ export class LtiDriver extends HttpDriverBase {
338
304
  }
339
305
  }
340
306
 
341
- _getJwksUrl(_header) {
342
- const meta = document.querySelector('meta[name="lti-jwks-url"]');
343
- if (meta) return meta.content;
344
-
345
- if (window.__LTI_CONFIG__?.jwksUrl) return window.__LTI_CONFIG__.jwksUrl;
346
-
347
- throw new Error('JWKS URL not configured. Set via meta tag or window.__LTI_CONFIG__.');
348
- }
349
-
350
307
  _resolveStateEndpoint() {
351
308
  const meta = document.querySelector('meta[name="lti-state-endpoint"]');
352
309
  if (meta) return meta.content;
353
310
 
354
- if (window.__LTI_CONFIG__?.stateEndpoint) return window.__LTI_CONFIG__.stateEndpoint;
355
-
356
311
  return '/api/lti/state';
357
312
  }
358
313
 
314
+ _resolveTrustedSameOriginEndpoint(value, label) {
315
+ if (!value) throw new Error(`Missing ${label} endpoint`);
316
+ const endpoint = new URL(value, window.location.origin);
317
+ if (endpoint.origin !== window.location.origin) {
318
+ throw new Error(`${label} endpoint must be same-origin so authentication remains server-controlled`);
319
+ }
320
+ return endpoint.toString();
321
+ }
322
+
359
323
  /**
360
- * Resolves LTI claims from cloud-injected meta tags or config object.
324
+ * Resolves LTI claims from server-injected meta tags.
361
325
  * Used when OIDC is handled server-side (no JWT in URL).
362
326
  */
363
327
  _resolveCloudClaims() {
@@ -370,20 +334,16 @@ export class LtiDriver extends HttpDriverBase {
370
334
  }
371
335
  }
372
336
 
373
- if (window.__LTI_CONFIG__?.claims) return window.__LTI_CONFIG__.claims;
374
-
375
- throw new Error('[LtiDriver] Cloud LTI launch detected but no claims provided. Expected <meta name="cc-lti-claims"> or window.__LTI_CONFIG__.claims.');
337
+ throw new Error('[LtiDriver] LTI launch detected but no valid server-injected <meta name="cc-lti-claims"> was provided.');
376
338
  }
377
339
 
378
340
  /**
379
- * Resolves AGS lineitem URL from cloud-injected meta tags or config object.
341
+ * Resolves the same-origin AGS proxy from a server-injected meta tag.
380
342
  */
381
343
  _resolveCloudAgsEndpoint() {
382
344
  const meta = document.querySelector('meta[name="cc-lti-ags"]');
383
345
  if (meta?.content) return meta.content;
384
346
 
385
- if (window.__LTI_CONFIG__?.agsEndpoint) return window.__LTI_CONFIG__.agsEndpoint;
386
-
387
347
  return null;
388
348
  }
389
349
 
@@ -407,7 +367,7 @@ export class LtiDriver extends HttpDriverBase {
407
367
 
408
368
  try {
409
369
  const response = await fetch(`${this._stateEndpoint}?key=${encodeURIComponent(stateKey)}`, {
410
- headers: this._getAuthHeaders()
370
+ credentials: 'same-origin'
411
371
  });
412
372
 
413
373
  if (response.ok) {
@@ -445,9 +405,9 @@ export class LtiDriver extends HttpDriverBase {
445
405
  const response = await fetch(this._stateEndpoint, {
446
406
  method: 'POST',
447
407
  headers: {
448
- 'Content-Type': 'application/json',
449
- ...this._getAuthHeaders()
408
+ 'Content-Type': 'application/json'
450
409
  },
410
+ credentials: 'same-origin',
451
411
  body: JSON.stringify(payload)
452
412
  });
453
413
 
@@ -460,19 +420,12 @@ export class LtiDriver extends HttpDriverBase {
460
420
  logger.debug('[LtiDriver] State persisted');
461
421
  }
462
422
 
463
- _getAuthHeaders() {
464
- if (this._accessToken) {
465
- return { 'Authorization': `Bearer ${this._accessToken}` };
466
- }
467
- return {};
468
- }
469
-
470
423
  // =========================================================================
471
424
  // Private: AGS Score Passback
472
425
  // =========================================================================
473
426
 
474
427
  async _postScore() {
475
- if (!this._agsLineItemUrl || this._score === null) {
428
+ if (!this._agsProxyEndpoint || this._score === null) {
476
429
  return;
477
430
  }
478
431
 
@@ -487,16 +440,19 @@ export class LtiDriver extends HttpDriverBase {
487
440
  gradingProgress: this._successStatus !== 'unknown' ? 'FullyGraded' : 'NotReady'
488
441
  };
489
442
 
490
- const scoreUrl = `${this._agsLineItemUrl}/scores`;
491
- await fetch(scoreUrl, {
443
+ const response = await fetch(this._agsProxyEndpoint, {
492
444
  method: 'POST',
493
445
  headers: {
494
- 'Content-Type': 'application/vnd.ims.lis.v1.score+json',
495
- ...this._getAuthHeaders()
446
+ 'Content-Type': 'application/vnd.ims.lis.v1.score+json'
496
447
  },
448
+ credentials: 'same-origin',
497
449
  body: JSON.stringify(scorePayload)
498
450
  });
499
451
 
452
+ if (!response.ok) {
453
+ throw new Error(`AGS proxy rejected score: ${response.status} ${response.statusText || ''}`.trim());
454
+ }
455
+
500
456
  logger.debug('[LtiDriver] Score posted to AGS:', this._score);
501
457
 
502
458
  } catch (error) {