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

+13 -3
View File
@@ -6,17 +6,24 @@ import { slugify, uniqueId } from "./util/text";
import { extractFrame, type Tools } from "./media";
export interface ImportEnv { [k: string]: string | undefined }
/** `env` supplies only tool paths and transcription credentials (STC_FFMPEG, STC_FFPROBE, STC_TRANSCRIBE_KEY, _MODEL). */
export interface ImportOptions { env?: ImportEnv }
export interface Deps {
ctx: ServerContext;
env: ImportEnv;
tools: Tools;
/** AppConfig.python (STC_PYTHON). */
python: string;
/** AppConfig.importRoots (STC_IMPORT_ROOTS). */
importRoots: string[];
/** AppConfig.transcribeUrl (STC_TRANSCRIBE_URL). */
transcribeUrl: string | null;
}
export const coursesDir = (d: Deps) => resolve(d.ctx.config.dataDir, "courses");
export const cacheDir = (d: Deps) => resolve(d.ctx.config.dataDir, "cache");
export const coursesDir = (d: Deps) => resolve(d.ctx.config.importedCoursesDir ?? resolve(d.ctx.config.dataDir, "courses"));
export const cacheDir = (d: Deps) => resolve(d.ctx.config.cacheDir ?? resolve(d.ctx.config.dataDir, "cache"));
const studioRepo = (d: Deps, id: string) => join(d.ctx.config.studioReposDir ?? resolve(d.ctx.config.dataDir, "studio", "repos"), `${id}.git`);
export const deckCacheDir = (d: Deps, courseId: string, deckId: string) => join(cacheDir(d), "decks", courseId, deckId);
export const coverCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "covers", courseId);
export const transcriptCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "transcripts", courseId);
@@ -27,11 +34,14 @@ export const isPlainId = (s: string) => /^[a-z0-9][a-z0-9-]*$/.test(s);
export async function freshCourseId(d: Deps, wanted: string | undefined, title: string): Promise<string> {
if (wanted !== undefined) {
if (!isPlainId(wanted)) throw new Error(`invalid courseId "${wanted}" (use lowercase letters, digits and dashes)`);
if (await exists(join(coursesDir(d), wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`);
// Studio repos share the id space (ruling R9): refuse an id the studio already uses, even before it is published.
if (await exists(join(coursesDir(d), wanted)) || await exists(studioRepo(d, wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`);
return wanted;
}
const taken = new Set<string>(d.ctx.courses.list().map((c) => c.id));
for (const e of await readdir(coursesDir(d)).catch(() => [] as string[])) taken.add(e);
const repos = d.ctx.config.studioReposDir ?? resolve(d.ctx.config.dataDir, "studio", "repos");
for (const e of await readdir(repos).catch(() => [] as string[])) if (e.endsWith(".git")) taken.add(e.slice(0, -4));
return uniqueId(slugify(title), (x) => taken.has(x));
}
+14 -3
View File
@@ -1,3 +1,4 @@
import { resolve } from "node:path";
import type { Feature, JobHandler, ServerContext } from "@stc/contracts";
import type { Deps, ImportOptions } from "./context";
import { importCodeRepo } from "./jobs/code-repo";
@@ -14,15 +15,22 @@ export { deckCacheDir, coverCacheDir, transcriptCacheDir } from "./context";
/**
* `ctx` is only read when a job runs or a request arrives, never while this function runs, so the server can pass
* its context object before the job queue (which needs these handlers) exists.
* Environment: STC_PYTHON (Python with PyMuPDF, default python3), STC_FFMPEG / STC_FFPROBE, STC_TRANSCRIBE_URL (+ _KEY, _MODEL).
* Config: AppConfig.python, importRoots, transcribeUrl. Environment: STC_FFMPEG / STC_FFPROBE, STC_TRANSCRIBE_KEY / _MODEL.
*/
export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Feature {
const deps = (): Deps => {
const env = opts.env ?? process.env;
return { ctx, env, tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" }, python: env.STC_PYTHON || "python3" };
const c = ctx.config;
return {
ctx, env,
tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" },
python: c.python || "python3",
importRoots: c.importRoots?.length ? c.importRoots : [resolve(c.dataDir, "import-inbox")],
transcribeUrl: c.transcribeUrl ?? null,
};
};
const job = (fn: (d: Deps, p: unknown, jc: Parameters<JobHandler>[1]) => Promise<unknown>): JobHandler => (p, jc) => fn(deps(), p, jc);
ensureRoots({ ctx, env: opts.env ?? process.env });
ensureRoots(deps());
return {
name: "import",
routes: importRoutes(deps),
@@ -37,3 +45,6 @@ export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Fea
}
export { importWeb } from "./web";
/** The importer adds no tables. */
export const migrationsDirs: string[] = [];
+1 -1
View File
@@ -37,7 +37,7 @@ export async function adoptCover(d: Deps, courseId: string, courseDir: string, c
export async function maybeQueueTranscription(d: Deps, courseId: string, wanted: boolean, warnings: string[]): Promise<void> {
if (!wanted) return;
if (!d.env.STC_TRANSCRIBE_URL) {
if (!d.transcribeUrl) {
warnings.push("transcription requested but STC_TRANSCRIBE_URL is not set; skipped");
return;
}
@@ -33,7 +33,7 @@ export function segmentsToMarkdown(title: string, segments: Segment[], fallbackT
*/
export async function transcribeCourse(d: Deps, payload: unknown, jc: JobContext): Promise<TranscribeResult> {
const p = (payload ?? {}) as Record<string, unknown>;
const url = d.env.STC_TRANSCRIBE_URL;
const url = d.transcribeUrl;
const courseId = optStr(p, "courseId") ?? "";
if (!url) return { courseId, skipped: true, reason: "STC_TRANSCRIBE_URL is not set", transcribed: 0, warnings: [] };
const course = await managedCourse(d, courseId);
+5 -7
View File
@@ -5,19 +5,17 @@ import type { Deps } from "./context";
export const OUTSIDE = "path is outside the allowed import folders";
/** STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
export function importRoots(d: Pick<Deps, "ctx" | "env">): string[] {
const raw = d.env.STC_IMPORT_ROOTS;
const list = raw ? raw.split(":").filter(Boolean) : [resolve(d.ctx.config.dataDir, "import-inbox")];
return list.map((r) => resolve(r));
/** AppConfig.importRoots: STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
export function importRoots(d: Pick<Deps, "importRoots">): string[] {
return d.importRoots.map((r) => resolve(r));
}
export function ensureRoots(d: Pick<Deps, "ctx" | "env">): void {
export function ensureRoots(d: Pick<Deps, "importRoots">): void {
for (const r of importRoots(d)) { try { mkdirSync(r, { recursive: true }); } catch { /* reported when used */ } }
}
/** Real path (symlinks followed) must lie inside the real path of one root. Throws OUTSIDE otherwise. */
export async function assertAllowedPath(d: Pick<Deps, "ctx" | "env">, path: string): Promise<string> {
export async function assertAllowedPath(d: Pick<Deps, "importRoots">, path: string): Promise<string> {
let real: string;
try { real = await realpath(resolve(path)); } catch { throw new Error(OUTSIDE); }
for (const root of importRoots(d)) {
+2 -2
View File
@@ -26,7 +26,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
const d = deps();
const dir = coursesDir(d);
const served = d.ctx.config.courseDirs.some((c) => resolve(c) === dir);
return json({ transcribe: Boolean(d.env.STC_TRANSCRIBE_URL), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
return json({ transcribe: Boolean(d.transcribeUrl), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
}),
},
"/api/import/jobs": {
@@ -42,7 +42,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
const options: Record<string, unknown> = {};
for (const k of OPTION_KEYS) if (body?.options?.[k] !== undefined && body.options[k] !== "") options[k] = body.options[k];
if (kind === "transcribe") {
if (!d.env.STC_TRANSCRIBE_URL) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409);
if (!d.transcribeUrl) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409);
if (typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400);
return json({ id: await d.ctx.jobs.enqueue("import.transcribe", { courseId: options.courseId }) }, 202);
}
+1 -1
View File
@@ -3,5 +3,5 @@ import type { WebFeature } from "@stc/contracts";
/** Browser-safe entry (no server imports): apps/web imports this as "@stc/feature-import/web". */
export const importWeb: WebFeature = {
name: "import",
routes: [{ path: "/admin/import", element: () => import("./web/ImportPage"), nav: { label: "Import", area: "admin" } }],
routes: [{ path: "/admin/import", element: () => import("./web/ImportPage"), nav: { label: "Import", area: "admin", roles: ["owner"] } }],
};