diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index 9187eae..ca26cd7 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -2,7 +2,7 @@ import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerConte
import { createApp, createJobQueue, loadConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
-import { learnerFeature, spaWithCsp } from "@stc/feature-learner";
+import { learnerFeature } from "@stc/feature-learner";
import indexHtml from "../../web/index.html";
// Used until a feature supplies an `auth` service (the learner package does).
@@ -31,19 +31,16 @@ export async function startServer(
ctx.jobs = jobs;
const app = createApp(ctx, built);
jobs.start();
- // The SPA document carries a CSP header (served through spaWithCsp; contract request learner §5).
- const spa = spaWithCsp(indexHtml, { development: process.env.NODE_ENV !== "production" });
const server = Bun.serve({
port: config.port,
// Bun routes win over fetch; "/api/*" falls through to the composed app (404 for unknown API paths).
- routes: { ...app.routes, "/api/*": (req) => app.fetch(req), ...spa.routes },
+ routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
fetch: (req) => app.fetch(req),
});
return {
server,
async stop() {
await server.stop(true);
- spa.stop();
await jobs.stop();
await db.close();
},
diff --git a/apps/web/index.html b/apps/web/index.html
index 2d79c9a..cd1895c 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -3,6 +3,10 @@
+
+
STCourses
diff --git a/docs/contract-requests/learner.md b/docs/contract-requests/learner.md
index b751e07..6121a3d 100644
--- a/docs/contract-requests/learner.md
+++ b/docs/contract-requests/learner.md
@@ -29,13 +29,26 @@ and let `startServer` take `Array`.
`import { LessonView } from "@stc/feature-learner/web"`. Props: `lesson: LessonPayload` (the JSON from `GET /api/courses/:id/lessons/:lessonId`), plus optional `look`, `track`, `onTrackChange`, `preview`. With `preview` set, it makes no notes, attempt or progress requests; it still loads code listings and decks through the normal media routes. It injects its own stylesheet (scoped under `.lr`) and works with or without a router.
-## 5. Content-Security-Policy on the SPA, set by core (review C1)
+## 5. Content-Security-Policy on the SPA (review C1, ruling R16)
-**Problem.** Lesson HTML is author content. The sanitizer (sanitize-html allowlist) is the first defence, and a CSP without inline script is the second. Bun.serve cannot attach headers to an HTML import route.
+**Applied in `feat/learner`.** The SPA's CSP is a `` tag in `apps/web/index.html`, ahead of every script. `Bun.serve` serves the HTML import directly, as in Phase 0; there is no proxy and no change to proxy environment variables. The policy:
-**Workaround in `feat/learner` (applied).** `spaWithCsp(indexHtml, { development })` in `@stc/feature-learner` serves the bundle from an internal server bound to 127.0.0.1 and proxies it. HTML responses get `Content-Security-Policy` (`script-src 'self'`, no `'unsafe-inline'`; `connect-src 'self'`; `object-src 'none'`; `frame-ancestors 'self'`; see `spaCsp()`), plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`. `main.ts` uses it for `"/*"`. HMR is off on the internal server, so Bun injects no inline script. If it ever does in development, only the hashes of those exact scripts are allowed.
+```
+default-src 'self'; script-src 'self' 'sha256-'; style-src 'self' 'unsafe-inline';
+img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self';
+worker-src 'self' blob:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'
+```
-**Proposal.** Core owns the SPA route and sets this CSP globally (and for every feature's HTML), replacing the proxy. Other features must then avoid inline scripts as well. The studio preview through `LessonView` already complies.
+- **No inline script.**
+ - The one hash is the inline helper Bun's dev server injects (the `/_bun/unref` beacon). Production serving does not inject it.
+ - If a Bun upgrade changes the helper, the only effect is a dev-only CSP console error; `test/csp.test.ts` (dev mode) will then fail and name it.
+- **Dev HMR works:** the HMR websocket `ws:///_bun/hmr` is same-origin, and Chrome matches it with `connect-src 'self'` (checked: handshake 101, no violations).
+- **The runner frame** (`/api/learner/runner-frame`) keeps its own header-based CSP, including `sandbox allow-scripts` and `frame-ancestors 'self'`.
+
+**Proposal for core (Phase 2).**
+- A meta tag cannot carry `frame-ancestors`, `sandbox` or `report-uri`. Core should additionally send `Content-Security-Policy: frame-ancestors 'self'` (or `X-Frame-Options: SAMEORIGIN`), plus `X-Content-Type-Options: nosniff`, on HTML responses. That is possible if Bun ever allows headers on HTML imports, or by serving a production static build (`bun build apps/web/index.html`) through a normal route that sets headers.
+- Ideally core then sends the whole policy as a header and the meta tag stays as defence in depth.
+- Every feature's web code must keep to this policy: no inline scripts or event handlers, and no `eval`. The learner and studio preview, through `LessonView`, comply.
## 6. `canView` and the global `author` role (review I1)
diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts
index dc67d6b..c01208d 100644
--- a/packages/feature-learner/src/index.ts
+++ b/packages/feature-learner/src/index.ts
@@ -1,7 +1,6 @@
// Server
export { migrationsDirs, runMigrations } from "./server/migrate";
export { learnerFeature } from "./server/feature";
-export { spaWithCsp, spaCsp } from "./server/spa";
// Browser (also available without the server half as "@stc/feature-learner/web")
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
// Shared JSON shapes
diff --git a/packages/feature-learner/src/server/spa.ts b/packages/feature-learner/src/server/spa.ts
deleted file mode 100644
index 9b774ac..0000000
--- a/packages/feature-learner/src/server/spa.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { chmodSync, mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import type { HTMLBundle } from "bun";
-
-/**
- * Content-Security-Policy for the SPA document. No inline script: `script-src 'self'` only (plus, in development,
- * the hashes of the inline helper scripts Bun's dev server injects). Style attributes are allowed because React
- * components set them; styles cannot run script. The exercise runner frame is a same-origin URL (`frame-src
- * 'self'`) with its own, stricter policy, see `runnerFrame.ts`.
- */
-export function spaCsp(inlineScriptHashes: string[] = []): string {
- const hashes = inlineScriptHashes.map((h) => ` '${h}'`).join("");
- return [
- "default-src 'self'",
- `script-src 'self'${hashes}`,
- "style-src 'self' 'unsafe-inline'",
- "img-src 'self' data: blob: https:",
- "media-src 'self' blob:",
- "font-src 'self' data:",
- "connect-src 'self'",
- "worker-src 'self' blob:",
- "frame-src 'self'",
- "object-src 'none'",
- "base-uri 'self'",
- "form-action 'self'",
- "frame-ancestors 'self'",
- ].join("; ");
-}
-
-/** `sha256-…` CSP sources for every inline `