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.
- package/README.md +6 -3
- package/bin/cli.js +14 -1
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +13 -7
- package/framework/docs/FRAMEWORK_GUIDE.md +23 -22
- package/framework/docs/USER_GUIDE.md +9 -3
- package/framework/index.html +1 -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 +3 -15
- 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/create.js +71 -18
- 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/scaffold.js +33 -4
- package/lib/stub-player.js +19 -2
- package/lib/token.js +26 -29
- package/lib/upgrade.js +76 -2
- package/package.json +33 -26
- package/template/course/course-config.js +9 -7
- package/template/gitattributes +44 -0
- package/template/gitignore +38 -0
- package/template/package.json +13 -4
- 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
|
-
- **
|
|
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/) (
|
|
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(
|
|
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
|
-
|
|
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 #
|
|
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/`
|
|
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
|
-
###
|
|
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
|
---
|
|
@@ -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(
|
|
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
|
|
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
|
@@ -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
|
|
|
@@ -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) {
|