Files
STCourses/docs/contract-requests/learner.md
T
Lucas WintherandClaude Opus 5.5 0da35ec6f8 fix(learner): remove the SPA proxy; CSP as a meta tag in index.html (ruling R16)
The proxy, its internal server, Unix socket and NO_PROXY changes are gone; main.ts serves the HTML
import directly again. apps/web/index.html carries the SPA policy as <meta http-equiv=CSP> ahead of every
script (script-src 'self' plus the hash of Bun's dev-server helper, no unsafe-inline/eval; no
frame-ancestors, which meta cannot carry). Dev HMR connects under it. The runner frame keeps its header CSP.

Tests: the served index.html (prod and dev, in a subprocess) has the meta policy and every inline script
is hash-covered; the XSS browser test fails with the sanitizer disabled and passes with it, and the meta
CSP alone blocks unsanitized handlers (with a no-CSP control); no source assigns proxy env vars, and the
feature leaves them untouched at runtime. Contract request 5 asks core for frame-ancestors/X-Frame-Options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:38:04 +01:00

5.6 KiB

Contract requests from WP-A (learner)

1. Feature factories and the auth service (apps/server/src/main.ts, @stc/contracts)

Problem. Phase 0 passes Feature[] to startServer, but ServerContext is built inside startServer, so a feature that needs ctx (every Phase 1 package exports xFeature(ctx)) cannot be listed. There is also no way for a feature to replace the anonymous AuthService.

Workaround in feat/learner (applied, small): startServer accepts FeatureEntry = Feature | ((ctx) => Feature & { auth?: AuthService }). Factories run after ctx exists (with ctx.jobs set right after they return, so factories must not use jobs synchronously), and a feature carrying auth replaces ctx.auth. The main entry lists learnerFeature.

Proposal. Move both into the contract:

export type FeatureFactory = (ctx: ServerContext) => Feature;
export interface Feature { /* … */ auth?: AuthService; migrationsDirs?: string[] }

and let startServer take Array<Feature | FeatureFactory>.

2. Migrations per package

migrate() only reads packages/db/migrations. The learner package exports migrationsDirs and, until Phase 2 wires it in, runs its own migrations lazily inside learnerFeature(ctx) through runMigrations(db, dirs), which uses the same schema_migrations table and file-name keys. When migrate() learns about migrationsDirs, the lazy call becomes a no-op; it can then be removed.

3. Package entry for the browser

Problem. The package index exports both the server (learnerFeature, which pulls in Better Auth and @better-auth/sso) and the browser half (learnerWeb, LessonView). A browser bundle of the index fails: @better-auth/sso imports X509Certificate from node:crypto, which has no browser polyfill. The root tsconfig.json maps @stc/* to packages/*/src, so package.json exports subpaths are not used either.

Workaround in feat/learner (applied). LessonView and learnerWeb are exported from the index (for server-side and type use) and from a browser-only entry, @stc/feature-learner/web. That subpath resolves through the existing mapping to packages/feature-learner/web/src/index.ts, a one-line re-export of src/web. apps/web/src/features.ts imports learnerWeb from it, and the studio should import LessonView from it too.

Proposal. Add "@stc/*/web": ["packages/*/src/web"] ahead of the general mapping in tsconfig.json (Bun honours tsconfig paths), and let every feature package expose src/web/index.ts. The shim directory can then be deleted. This also settles the importer's relative import in features.ts.

4. LessonView for the studio preview

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 (review C1, ruling R16)

Applied in feat/learner. The SPA's CSP is a <meta http-equiv="Content-Security-Policy"> 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:

default-src 'self'; script-src 'self' 'sha256-<Bun dev helper>'; 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'
  • 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://<host>/_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)

Ruling applied. Learner routes now call core canView with per-course authorship only (course_authors), exactly like core's /api/media. A user with only the global author role gets a 404 for a restricted course they are not listed on, on every route.

Proposal for Phase 2. Extend core canView so the global author role can view every course (the studio and AI need the same rule), for example canView(user, course, acl, isCourseAuthor) returning true when user.roles.includes("author"). The learner then needs no change: it already delegates to canView.