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
package/README.md CHANGED
@@ -1,26 +1,29 @@
1
1
  # CourseCode
2
2
 
3
- **Open-source, local-first course authoring built for AI tools like Claude Code, Codex, Cursor, and [CourseCode Desktop](https://coursecodedesktop.com) no coding required to start, full code control when you need it.**
3
+ **Open-source, local-first course authoring with no vendor lock-in. Built for AI tools like Claude Code, Codex, Cursor, and [CourseCode Desktop](https://coursecodedesktop.com). No coding required to start, full code control when you need it.**
4
4
 
5
- CourseCode creates real project files you can inspect, version, and edit directly with a predictable, file-based workflow instead of a black-box GUI. Built-in MCP integration means your AI assistant connects directly to your course project.
5
+ CourseCode creates real project files you can inspect, version, and edit directly instead of relying on restrictive graphical editing tools. Built-in MCP integration means your AI assistant connects directly to your course project and understands the framework immediately.
6
6
 
7
- Bring your own PDFs, Word docs, or PowerPoints, use AI to accelerate authoring, and deploy to any LMS format without vendor lock-in or subscriptions.
7
+ Bring your own PDFs, Word docs, or other reference documents, use AI to accelerate authoring, and deploy to any LMS without vendor lock-in or subscriptions.
8
8
 
9
9
  ## View the demo
10
10
 
11
11
  - [View the live CourseCode demo course](https://preview.coursecodecloud.com/preview/coursecode-demo/nTfJU2qvp23P0mguxrrGFZr4FlXfM03N)
12
12
 
13
- ## Explore the ecosystem
13
+ ## CourseCode ecosystem
14
14
 
15
15
  - [Build SCORM courses with the framework](https://coursecodeframework.com/scorm/)
16
16
  - [Use CourseCode Desktop if you prefer a GUI](https://coursecodedesktop.com/scorm/)
17
17
  - [Use CourseCode Cloud for hosted delivery](https://coursecodecloud.com)
18
18
 
19
+ > CourseCode Framework is a standalone tool. Desktop and Cloud are optional.
20
+
19
21
  ## Features
20
22
 
21
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
22
24
  - **No coding required to start**: Describe what you want and let AI help build slides, interactions, and structure
23
- - **Full LMS integration**: SCORM 1.2, SCORM 2004, cmi5, and LTI with complete tracking records
25
+ - **Course modernization**: Use existing SCORM courses, PowerPoints, PDFs, Word docs, scripts, and SME notes as source material for upgraded courses
26
+ - **LMS integration**: SCORM 1.2, SCORM 2004, and cmi5 packaging, plus an LTI 1.3 browser contract for trusted server-backed launches
24
27
  - **AI-assisted authoring workflow**: Structured guides and MCP tools for faster course development
25
28
  - **Rich UI components**: Images, video, accordions, tabs, and custom sandboxed HTML/JS embeds
26
29
  - **Rich interactions**: Multiple choice, drag-drop, fill-in-the-blank, matching, sequencing, and more
@@ -41,12 +44,14 @@ Bring your own PDFs, Word docs, or PowerPoints, use AI to accelerate authoring,
41
44
 
42
45
  ### Required
43
46
 
44
- Install [Node.js](https://nodejs.org/) (v18 or later), then run:
47
+ Install [Node.js](https://nodejs.org/) (v20.19 or later), then run:
45
48
 
46
49
  ```bash
47
50
  npm install -g coursecode
48
51
  ```
49
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
+
50
55
  ### Recommended
51
56
 
52
57
  - A code or text editor — [VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.com/), or similar
package/bin/cli.js CHANGED
@@ -210,6 +210,7 @@ program
210
210
  .option('-p, --password <password>', 'Require password to access preview (export only)')
211
211
  .option('--title <title>', 'Custom title (default: course title)')
212
212
  .option('--port <port>', 'Preview server port (default: 4173)', '4173')
213
+ .option('--host <host>', 'Preview bind address (default: 127.0.0.1)', '127.0.0.1')
213
214
  .option('--skip-build', 'Skip build step for export (use existing dist folder)')
214
215
  .option('--nojekyll', 'Add .nojekyll file (required for GitHub Pages)')
215
216
  .option('--no-content', 'Exclude course content viewer from preview toolbar')
@@ -24,9 +24,17 @@
24
24
 
25
25
  ============================================================================ */
26
26
 
27
- /* Loading indicator - overlay that covers the content area */
27
+ /* Hide shell chrome until course-config-derived layout attributes are applied. */
28
+ html[data-course-loading="true"] .course-header,
29
+ html[data-course-loading="true"] .sidebar,
30
+ html[data-course-loading="true"] .sidebar-backdrop,
31
+ html[data-course-loading="true"] .app-footer {
32
+ display: none;
33
+ }
34
+
35
+ /* Loading indicator - overlay that covers the full shell during startup */
28
36
  #loading {
29
- position: absolute;
37
+ position: fixed;
30
38
  top: 0;
31
39
  left: 0;
32
40
  right: 0;
@@ -42,7 +50,7 @@
42
50
  radial-gradient(circle at 20% 50%, var(--color-secondary-alpha-05) 0%, transparent 50%),
43
51
  radial-gradient(circle at 80% 80%, var(--color-primary-alpha-10) 0%, transparent 50%);
44
52
  color: var(--text-muted);
45
- z-index: 100;
53
+ z-index: var(--z-overlay);
46
54
  }
47
55
 
48
56
  /* Loading text styling */
@@ -35,6 +35,8 @@
35
35
  | `coursecode test-data` | Test data reporting webhook configuration |
36
36
  | `coursecode mcp` | Start MCP server for AI agent integration |
37
37
 
38
+ 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.
39
+
38
40
  **Format options** (for local `dev`, `build`, `preview` — not needed for cloud-deployed courses):
39
41
  ```bash
40
42
  coursecode build --format scorm1.2 # Build for SCORM 1.2 LMS
@@ -105,10 +107,13 @@ coursecode preview # Open http://localhost:4173
105
107
  | Option | Description |
106
108
  |--------|-------------|
107
109
  | `--port <port>` | Preview server port (default: 4173) |
110
+ | `--host <host>` | Bind address (default: `127.0.0.1`; use another address only for deliberate LAN access) |
108
111
  | `--title <title>` | Custom browser title |
109
112
  | `--no-content` | Disable course content viewer |
110
113
  | `-f, --format <format>` | LMS format: `scorm2004`, `scorm1.2`, `cmi5`, or `lti` |
111
114
 
115
+ 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.
116
+
112
117
  ### Static Export
113
118
 
114
119
  Generate a self-contained folder for stakeholder sharing (Netlify, GitHub Pages, etc.):
@@ -283,17 +288,14 @@ Deploy courses to a CDN for instant updates without re-uploading to the LMS.
283
288
  format: 'scorm1.2-proxy',
284
289
  externalUrl: 'https://cdn.example.com/my-course',
285
290
  accessControl: {
286
- clients: {
287
- 'acme-corp': { token: 'abc123' },
288
- 'globex': { token: 'def456' }
289
- }
291
+ enforcement: 'server'
290
292
  }
291
293
  ```
292
294
 
293
295
  **Generate tokens:**
294
296
  ```bash
295
297
  coursecode token # Generate random token
296
- coursecode token --add acme-corp # Add client with token to config
298
+ coursecode token --add acme-corp # Store token in .coursecode/access-control.json
297
299
  ```
298
300
 
299
301
  **Build:**
@@ -302,7 +304,7 @@ coursecode build # Creates dist/ + *_<client>_proxy.zip per client
302
304
  ```
303
305
 
304
306
  **Deploy:**
305
- 1. Upload `dist/` to CDN (GitHub Pages, Vercel, Netlify, etc.)
307
+ 1. Upload `dist/` behind a CDN/backend that validates the generated client credentials before serving any file
306
308
  2. Upload client-specific proxy ZIP to each client's LMS
307
309
  3. Future updates: just redeploy to CDN—no LMS re-upload needed
308
310
 
@@ -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
  ---
@@ -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
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-course-loading="true">
3
3
 
4
4
  <head>
5
5
  <meta charset="UTF-8" />
@@ -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
 
@@ -174,6 +174,7 @@ export function initAppUI() {
174
174
  * Hides the loading indicator after the course has finished initializing.
175
175
  */
176
176
  export function hideLoadingIndicator() {
177
+ document.documentElement.removeAttribute('data-course-loading');
177
178
  if (loadingIndicator) {
178
179
  loadingIndicator.style.display = 'none';
179
180
  AppState.setLoadingVisible(false);
@@ -613,4 +614,4 @@ function _lockApplicationForExit() {
613
614
  closeSidebar();
614
615
  hideModal(); // Hide any active modal
615
616
  showModal('postExit'); // Show the final "safe to close" modal
616
- }
617
+ }
@@ -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) {