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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 15:02:46 +01:00
1 parent 427eda6e93
commit 777ae2cc37
72 files changed
+1066 -244

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ export * from "./exercise";
export * from "./ai";
export * from "./import";
export * from "./web";
export * from "./routes";
+14
View File
@@ -0,0 +1,14 @@
// The learner's public page routes. The learner package registers these patterns; every other package (AI
// citations, studio links) builds links with the helpers, so a link always matches a real route.
// Pure functions with no dependencies, the only runtime code in @stc/contracts.
export const COURSE_ROUTE = "/c/:courseId";
export const LESSON_ROUTE = "/c/:courseId/:lessonId";
export function courseUrl(courseId: string): string {
return `/c/${encodeURIComponent(courseId)}`;
}
export function lessonUrl(courseId: string, lessonId: string): string {
return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`;
}
+26 -2
View File
@@ -7,7 +7,12 @@ export interface Feature {
name: string; // "learner" | "import" | "studio" | "ai" | "core"
routes: Record<string, Partial<Record<"GET"|"POST"|"PUT"|"PATCH"|"DELETE", RouteHandler>>>; // paths start with /api/
jobs?: Record<string, JobHandler>; // job kind -> handler
auth?: AuthService; // replaces the anonymous AuthService (the learner package supplies it)
stop?(): void | Promise<void>; // called when the server stops (timers, watchers)
}
/** Features are built after the ServerContext exists. The job queue is built from the union of their `jobs`, so a
* factory must not use `ctx.jobs` synchronously (only while a request or job runs). */
export type FeatureFactory = (ctx: ServerContext) => Feature;
export interface JobContext { reportProgress(fraction: number, message?: string): Promise<void>; signal: AbortSignal }
export type JobHandler = (payload: unknown, jc: JobContext) => Promise<unknown>;
export type JobStatus = "queued" | "running" | "done" | "failed";
@@ -20,8 +25,27 @@ export interface ServerContext {
jobs: JobQueue; auth: AuthService;
}
export interface AppConfig {
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, media/, worktrees/
courseDirs: string[]; // folders scanned for stcourse.yaml (course repos and overlays/)
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, courses/, studio/, worktrees/
/** Folders scanned for stcourse.yaml: STC_COURSE_DIRS, plus importedCoursesDir and studioWorktreesDir (deduplicated). */
courseDirs: string[];
secret: string; // STC_SECRET, used to encrypt BYOK keys
publicUrl: string;
/** `<dataDir>/courses`: the importer's plain course folders. */
importedCoursesDir: string;
/** `<dataDir>/studio/repos`: studio bare repos (`<id>.git`), never scanned. */
studioReposDir: string;
/** `<dataDir>/worktrees`: studio published worktrees (`<id>/published`); drafts sit one level deeper and are never scanned. */
studioWorktreesDir: string;
/** `<dataDir>/cache`: derived files (decks/, covers/, transcripts/). */
cacheDir: string;
/** STC_IMPORT_ROOTS (":"-separated), default `<dataDir>/import-inbox`: the only folders the importer reads from. */
importRoots: string[];
/** STC_UPLOAD_MAX_MB, default 100. */
uploadMaxMb: number;
/** STC_AI_ALLOWED_BASE_URLS (comma-separated origins) that learners' own AI keys may use besides the official endpoints. */
aiAllowedBaseUrls: string[];
/** STC_TRANSCRIBE_URL: Whisper-compatible endpoint; null disables transcription. */
transcribeUrl: string | null;
/** STC_PYTHON, default python3: Python with PyMuPDF, used for PDF page images and text. */
python: string;
}
+3 -1
View File
@@ -1,4 +1,6 @@
import type React from "react";
import type { Role } from "./server";
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin" } }
/** `nav.roles`: the shell shows the link only to signed-in users holding one of these roles (absent: everyone). */
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin"; roles?: Role[] } }
export interface WebFeature { name: string; routes: WebRoute[] }