feat: resolve Phase 1 contract requests and compose every feature

Contracts and core:
- Feature gains auth and stop; FeatureFactory; core composeServer builds the
  context, runs the factories, then the job queue from the union of their jobs
  (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI.
- AppConfig/loadConfig own the layout and settings: courseDirs include
  <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir,
  importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python.
  resolveConfig derives the same for tests; cachePaths shares the importer's
  cache layout.
- canView: the global author role views every course (R14); the learner,
  studio and AI packages all call it.
- migrate(db, packages): core first, then packages in name order.
- Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on
  an empty file is 416.
- courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts.

Packages:
- import, studio and AI read AppConfig instead of process.env; the importer
  refuses ids used by studio/repos.
- learner migrations run through core migrate; library covers fall back to
  the importer's cached candidate behind a canView-checked route.
- studio preview renders through the learner LessonView (anchors honoured,
  draft URLs); AI drafts go to the studio draft branch for studio courses (as
  the requesting user) and to <dataDir>/drafts otherwise, and the generator
  page says which.

Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts
imports them by package name; nav.roles hides links by role.

Tests: compose, config, canView, media headers, migrations order, covers,
preview, nav, SPA bundle without server code, and an end-to-end flow on
SQLite and Postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:02:46 +01:00
co-authored by Claude Opus 5.5
parent 427eda6e93
commit 777ae2cc37
72 changed files with 1066 additions and 244 deletions
+1
View File
@@ -1,6 +1,7 @@
// Server
export { migrationsDirs, runMigrations } from "./server/migrate";
export { learnerFeature } from "./server/feature";
export { userIdentity } from "./server/identity";
// Browser (also available without the server half as "@stc/feature-learner/web")
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
// Shared JSON shapes
@@ -30,8 +30,8 @@ export function canEdit(user: AuthUser | null, courseId: string, idx: AccessInde
/**
* Learners see courses with a manifest that core `canView` allows; owners also see broken ones. The author input
* is per-course authorship only, the same as core's media route, so every route applies one rule. (The global
* `author` role grants viewing only once Phase 2 extends `canView`; contract request learner §6.)
* is per-course authorship (course_authors), the same as core's media route; core `canView` itself lets the owner
* and the global `author` role view every course (R14), so every route applies one rule.
*/
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
if (!c.manifest) return isOwner(user);
@@ -7,6 +7,7 @@ import type {
} from "../shared/types";
import { mediaUrl } from "../shared/types";
import { accessIndex, courseFor, isOwner, visible } from "./access";
import { coverUrlFor } from "./covers";
import { moduleLocks } from "./gating";
import { json, notFound, param } from "./http";
@@ -93,7 +94,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
const doneCount = lessons.filter((l) => progress[l.id]?.state === "done").length;
return {
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [],
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
coverUrl: coverUrlFor(ctx, m),
accent: m.accent ?? null, formats: formatsOf(m), lessonCount: lessons.length, minutes,
collections: m.collections ?? [], tracks: (m.tracks ?? []).map((t) => t.label), source: m.source?.kind ?? null,
addedAt: addedAt(c), status: c.status, progress: lessons.length ? doneCount / lessons.length : 0, doneCount,
@@ -204,7 +205,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
}));
const payload: CoursePayload = {
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [], accent: m.accent ?? null,
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
coverUrl: coverUrlFor(ctx, m),
tracks: m.tracks ?? [], gating: m.gating, source: m.source?.kind ?? null, status: course.status,
modules, progress, canEdit: found.canEdit, signedIn: !!rc.user,
diagnostics: found.canEdit ? course.diagnostics.map((d) => ({ level: d.level, lessonId: d.lessonId, path: d.path, message: d.message })) : [],
@@ -0,0 +1,47 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import type { Feature, ServerContext } from "@stc/contracts";
import { cachePaths, serveMedia } from "@stc/core";
import type { Manifest } from "@stc/course-format";
import { mediaUrl } from "../shared/types";
import { courseFor } from "./access";
import { notFound } from "./http";
const CANDIDATE = /^(frame-\d+|deck-[A-Za-z0-9._-]+)\.jpg$/;
/** The importer's first cover candidate (`<cacheDir>/covers/<courseId>/frame-N.jpg | deck-<id>.jpg`), if any. */
export function coverCandidate(ctx: ServerContext, courseId: string): string | null {
let dir: string;
try { dir = cachePaths(ctx.config).coversDir(courseId); } catch { return null; }
let names: string[];
try { names = readdirSync(dir).filter((n) => CANDIDATE.test(n)); } catch { return null; }
if (!names.length) return null;
// Prefer video frames (the importer's own first choice), then deck covers; numeric order within frames.
names.sort((a, b) => (a.startsWith("frame-") === b.startsWith("frame-") ? a.localeCompare(b, "en", { numeric: true }) : a.startsWith("frame-") ? -1 : 1));
return join(dir, names[0]!);
}
export const coverRoute = (courseId: string) => `/api/courses/${encodeURIComponent(courseId)}/cover`;
/** The manifest's cover if it resolves inside the course; else the importer's cached candidate; else null. */
export function coverUrlFor(ctx: ServerContext, m: Manifest): string | null {
if (m.cover && ctx.courses.resolvePath(m.id, m.cover)) return mediaUrl(m.id, m.cover);
return coverCandidate(ctx, m.id) ? coverRoute(m.id) : null;
}
/** Serves the cached cover candidate to users who may view the course (core canView; 404 otherwise). */
export function coverRoutes(ctx: ServerContext): Feature["routes"] {
return {
"/api/courses/:courseId/cover": {
GET: async (req, rc) => {
const found = await courseFor(ctx, rc.user, rc.params.courseId ?? "");
if (!found) return notFound();
const file = coverCandidate(ctx, found.course.id);
if (!file) return notFound();
const res = await serveMedia(req, file);
res.headers.set("Cache-Control", "private, max-age=300");
return res;
},
},
};
}
+5 -11
View File
@@ -1,9 +1,9 @@
import type { AuthService, Feature, RouteHandler, ServerContext } from "@stc/contracts";
import type { AuthService, Feature, ServerContext } from "@stc/contracts";
import { createAuth } from "./auth";
import { runMigrations } from "./migrate";
import { json } from "./http";
import { adminRoutes } from "./admin";
import { libraryRoutes } from "./catalog";
import { coverRoutes } from "./covers";
import { lessonRoutes } from "./lesson";
import { userDataRoutes } from "./userdata";
import { codeRoutes } from "./code";
@@ -16,9 +16,8 @@ import { runnerFrameRoutes } from "./runnerFrame";
* Returns the routes plus the `AuthService` that replaces the server's anonymous one.
*/
export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthService } {
const ready = runMigrations(ctx.db);
ready.catch((e) => console.error("[learner] migrations failed", e));
const auth = createAuth(ctx, ready);
// The server runs this package's migrations (migrationsDirs) in core migrate() before any feature is built.
const auth = createAuth(ctx, Promise.resolve());
const routes: Feature["routes"] = {
"/api/auth/*": {
GET: (req, rc) => auth.handler(req, rc.user),
@@ -30,6 +29,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
},
...adminRoutes(ctx, auth.service.invalidate),
...libraryRoutes(ctx),
...coverRoutes(ctx),
...lessonRoutes(ctx),
...userDataRoutes(ctx),
...codeRoutes(ctx),
@@ -37,11 +37,5 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
...assetRoutes(),
...runnerFrameRoutes(),
};
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
for (const methods of Object.values(routes)) {
for (const [m, h] of Object.entries(methods)) {
(methods as Record<string, RouteHandler>)[m] = async (req, rc) => { await ready; return h!(req, rc); };
}
}
return { name: "learner", routes, auth: auth.service };
}
@@ -0,0 +1,9 @@
import type { Db } from "@stc/db";
/** A user's display name and email (for commit authorship, e.g. the AI generator's studio drafts); null if unknown. */
export async function userIdentity(db: Db, userId: string): Promise<{ name: string; email: string } | null> {
const rows = await db.sql`SELECT name, email FROM auth_user WHERE id = ${userId}`;
const r = rows[0] as { name: string | null; email: string } | undefined;
if (!r) return null;
return { name: r.name?.trim() || r.email, email: r.email };
}
+5 -28
View File
@@ -1,33 +1,10 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Db } from "@stc/db";
import { migrate, type Db } from "@stc/db";
/** Folders holding this package's migrations; Phase 2 wires these into `migrate()`. */
/** Folders holding this package's migrations; the server passes them to core `migrate()` (contract request 2). */
export const migrationsDirs: string[] = [join(import.meta.dir, "..", "..", "migrations")];
function statements(text: string): string[] {
const noComments = text.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n");
return noComments.split(";").map((s) => s.trim()).filter(Boolean);
}
/**
* Applies the learner migrations with the same bookkeeping as `@stc/db`'s `migrate()`
* (table `schema_migrations`, keyed by file name). Safe to call repeatedly.
*/
export async function runMigrations(db: Db, dirs: string[] = migrationsDirs): Promise<string[]> {
await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name));
const applied: string[] = [];
for (const dir of dirs) {
for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
if (done.has(name)) continue;
const stmts = statements(readFileSync(join(dir, name), "utf8"));
await db.sql.begin(async (tx) => {
for (const s of stmts) await tx.unsafe(s);
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`;
});
applied.push(name);
}
}
return applied;
/** Core migrations plus the learner's, through core `migrate()` (tests and tools; the server migrates once at startup). */
export function runMigrations(db: Db): Promise<string[]> {
return migrate(db, { "feature-learner": migrationsDirs });
}
+1 -2
View File
@@ -182,8 +182,7 @@ export function fmtSize(n: number | null | undefined): string {
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`;
export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`;
export { lessonUrl as lessonHref, courseUrl as courseHref } from "@stc/contracts";
export async function copyText(text: string): Promise<boolean> {
try { await navigator.clipboard.writeText(text); return true; } catch {
+4 -3
View File
@@ -1,6 +1,7 @@
import type { WebFeature } from "@stc/contracts";
import { COURSE_ROUTE, LESSON_ROUTE, type WebFeature } from "@stc/contracts";
export { LessonView, type LessonViewProps } from "./lesson/LessonView";
export type { LessonPayload } from "../shared/types";
export { ensureStyles as ensureLearnerStyles } from "./api";
/** Learner routes: library, course pages, lessons, notes and the account pages. */
@@ -8,9 +9,9 @@ export const learnerWeb: WebFeature = {
name: "learner",
routes: [
{ path: "/", element: () => import("./library/LibraryPage") },
{ path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
{ path: COURSE_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
{ path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) },
{ path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
{ path: LESSON_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
{ path: "/account", element: () => import("./account/AccountPage") },
{ path: "/account/admin", element: () => import("./account/AdminPage") },
],
@@ -24,6 +24,8 @@ export interface LessonViewProps {
signedIn?: boolean;
/** Studio preview: no notes, no saved attempts, no progress reports. */
preview?: boolean;
/** URL for a course-relative file in the lesson text (the studio preview points these at draft files). */
mediaUrl?: (courseRel: string) => string;
onProgress?: (state: ProgressState, positionSeconds?: number) => void;
/** An exercise attempt was saved (the server already updated progress). */
onSaved?: () => void;
@@ -85,12 +87,12 @@ function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track
);
}
function useLessonHtml(lesson: LessonPayload, track: string | null): string | null {
function useLessonHtml(lesson: LessonPayload, track: string | null, media?: (courseRel: string) => string): string | null {
return useMemo(() => {
const md = (track && lesson.trackMarkdown[track]) || lesson.markdown;
if (!md) return null;
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true });
}, [lesson, track]);
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true, mediaUrl: media });
}, [lesson, track, media]);
}
function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) {
@@ -109,7 +111,7 @@ function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayloa
function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const html = useLessonHtml(lesson, track, p.mediaUrl);
const cmd = localCommandFor(lesson.localCommand, track);
const minutes = lesson.lesson.minutes;
const hasMeta = !!minutes || lesson.tracks.length > 1;
@@ -228,9 +230,9 @@ function useVideoProgress(v: React.RefObject<HTMLVideoElement | null>, p: Lesson
function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const html = useLessonHtml(lesson, track, p.mediaUrl);
const hintsHtml = useMemo(() => (lesson.hints.length
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles })
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, mediaUrl: p.mediaUrl })
: null), [lesson, track]);
const cmd = localCommandFor(lesson.localCommand, track);
return (
+3 -1
View File
@@ -24,6 +24,8 @@ export interface RenderOptions {
lessonFiles?: Record<string, string>;
/** Drop a leading heading (the page shows the lesson title itself). */
stripFirstHeading?: boolean;
/** URL for a course-relative file; default `/api/media/<course>/<path>` (the studio preview serves draft files). */
mediaUrl?: (courseRel: string) => string;
}
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -226,7 +228,7 @@ export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "i
if (rel === null) return null;
const lessonId = kind === "link" ? opts.lessonFiles?.[rel] : undefined;
if (lessonId) return { url: `/c/${encodeURIComponent(opts.courseId)}/${encodeURIComponent(lessonId)}${hash ? `#${hash}` : ""}`, external: false };
return { url: mediaUrl(opts.courseId, rel) + (hash ? `#${hash}` : ""), external: false };
return { url: (opts.mediaUrl ? opts.mediaUrl(rel) : mediaUrl(opts.courseId, rel)) + (hash ? `#${hash}` : ""), external: false };
}
export function renderLessonHtml(md: string, opts: RenderOptions): string {