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
@@ -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 });
}