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