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.
- package/README.md +11 -6
- package/bin/cli.js +1 -0
- package/framework/css/components/loading.css +11 -3
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +8 -6
- package/framework/docs/FRAMEWORK_GUIDE.md +22 -21
- package/framework/docs/USER_GUIDE.md +9 -3
- package/framework/index.html +2 -2
- package/framework/js/app/AppUI.js +2 -1
- package/framework/js/core/event-bus.js +21 -6
- package/framework/js/drivers/lti-driver.js +50 -94
- package/framework/js/drivers/proxy-driver.js +8 -5
- package/framework/js/main.js +7 -18
- package/framework/js/utilities/access-control.js +11 -45
- package/framework/js/utilities/data-reporter.js +16 -3
- package/framework/version.json +26 -50
- package/lib/build-packaging.d.ts +4 -0
- package/lib/build-packaging.js +39 -2
- package/lib/cloud.js +1 -1
- package/lib/course-writer.js +97 -32
- package/lib/manifest/cmi5-manifest.js +19 -10
- package/lib/manifest/lti-tool-config.js +18 -5
- package/lib/manifest/scorm-12-manifest.js +11 -5
- package/lib/manifest/scorm-2004-manifest.js +16 -9
- package/lib/manifest/xml-utils.js +14 -0
- package/lib/preview-routes-api.js +16 -7
- package/lib/preview-server.js +49 -28
- package/lib/project-utils.js +34 -0
- package/lib/proxy-templates/proxy.html +7 -3
- package/lib/proxy-templates/scorm-bridge.js +15 -9
- package/lib/stub-player.js +19 -2
- package/lib/token.js +26 -29
- package/lib/upgrade.js +76 -2
- package/package.json +32 -25
- package/template/course/course-config.js +9 -7
- package/template/package.json +13 -4
- package/template/vite.config.js +80 -27
package/README.md
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
# CourseCode
|
|
2
2
|
|
|
3
|
-
**Open-source, local-first course authoring
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
- **
|
|
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/) (
|
|
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
|
-
/*
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 #
|
|
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/`
|
|
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
|
-
###
|
|
40
|
+
### Build and Browser Compatibility Policy
|
|
41
41
|
|
|
42
|
-
The
|
|
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
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
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
|
-
|
|
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: {
|
|
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 #
|
|
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
|
|
228
|
-
-
|
|
229
|
-
-
|
|
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
|
-
|
|
|
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
|
|
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
|
|
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`).
|
|
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
|
package/framework/index.html
CHANGED
|
@@ -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="../
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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.
|
|
8
|
-
* 2.
|
|
9
|
-
* 3. State persistence
|
|
10
|
-
* 4. Score reporting
|
|
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
|
-
* -
|
|
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.
|
|
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
|
|
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.
|
|
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:
|
|
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
|
-
//
|
|
259
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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) {
|