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 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,4 +1,5 @@
import type { AppConfig } from "@stc/contracts";
import { storeDirs } from "./paths";
import { createStudioStore, StudioError, safeAuthor, type Identity } from "./store";
/** Programmatic draft API for other packages (WP-D's studio generator). Writes only to `draft/<authorId>`, never publishes. */
@@ -16,8 +17,11 @@ export interface DraftWriterOptions {
identity?: (authorId: string) => Promise<Identity | null> | Identity | null;
}
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, opts: DraftWriterOptions = {}): DraftWriter {
const store = createStudioStore({ dataDir: ctx.config.dataDir });
export function createDraftWriter(
ctx: { config: Pick<AppConfig, "dataDir"> & Partial<Pick<AppConfig, "studioReposDir" | "importedCoursesDir" | "studioWorktreesDir">> },
opts: DraftWriterOptions = {},
): DraftWriter {
const store = createStudioStore(storeDirs(ctx.config));
return {
async writeDraftFiles(courseId, authorId, files, message) {
if (!store.isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404);
+20 -11
View File
@@ -1,21 +1,20 @@
import { existsSync, realpathSync } from "node:fs";
import { basename, extname, resolve, sep } from "node:path";
import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
import { serveMedia } from "@stc/core";
import { canView, serveMedia } from "@stc/core";
import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format";
import { aclRepo } from "@stc/db";
import { createDraftWriter, type DraftWriter } from "./draft-writer";
import { studioWorktreesDir } from "./paths";
import { storeDirs, studioWorktreesDir } from "./paths";
import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioError, type FileWrite, type StudioStore } from "./store";
import { createPublishedWatcher, type PublishedWatcher } from "./watch";
export const OUTSIDE_MESSAGE = "This course is managed outside STCourses";
export const IMPORTED_MESSAGE = "Imported course: read-only in the studio for now";
/** Upload cap in bytes: STC_UPLOAD_MAX_MB, default 100 (review I2). */
export function uploadMaxBytes(env: Record<string, string | undefined> = process.env): number {
const mb = Number(env.STC_UPLOAD_MAX_MB ?? "100");
return (Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
/** Upload cap in bytes from AppConfig.uploadMaxMb (STC_UPLOAD_MAX_MB, default 100; review I2). */
export function uploadMaxBytes(mb: number | undefined): number {
return (typeof mb === "number" && Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
}
/** Server-side allowlist by extension for uploads: text, documents, images, media, archives and code. */
@@ -73,19 +72,29 @@ function manifestToYaml(m: unknown): string {
}
export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature {
const store = createStudioStore({ dataDir: ctx.config.dataDir });
const store = createStudioStore(storeDirs(ctx.config));
const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs });
const acl = aclRepo(ctx.db);
const wtDir = resolve(studioWorktreesDir(ctx.config.dataDir));
const wtDir = resolve(ctx.config.studioWorktreesDir ?? studioWorktreesDir(ctx.config.dataDir));
const draftWriter = createDraftWriter(ctx);
const hasRole = (u: AuthUser) => u.roles.includes("owner") || u.roles.includes("author");
const authoredCourses = async (u: AuthUser): Promise<string[]> =>
(await ctx.db.sql`SELECT course_id FROM course_authors WHERE user_id = ${u.id}`).map((r: { course_id: string }) => r.course_id);
const canEdit = async (u: AuthUser, courseId: string) => hasRole(u) || (await acl.isAuthor(courseId, u.id));
/**
* An authoring right (owner, the `author` role, or a course_authors row) and, for a course the server has loaded,
* core `canView` (the one visibility rule, R14). A studio course not loaded yet (e.g. its published manifest is
* broken) has no visibility to check.
*/
const canEdit = async (u: AuthUser, courseId: string) => {
const isAuthor = await acl.isAuthor(courseId, u.id);
if (!hasRole(u) && !isAuthor) return false;
const c = ctx.courses.get(courseId);
return !c || canView(u, c, await acl.listForCourse(courseId), isAuthor);
};
const isStudioCourse = (c: LoadedCourse) => resolve(c.manifestPath).startsWith(wtDir + sep);
const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid`, owner: u.roles.includes("owner") });
const importedRoot = (() => { const d = resolve(ctx.config.dataDir, "courses"); return existsSync(d) ? realpathSync(d) : d; })();
const importedRoot = (() => { const d = resolve(ctx.config.importedCoursesDir ?? resolve(ctx.config.dataDir, "courses")); return existsSync(d) ? realpathSync(d) : d; })();
/** Why a course that is not studio-managed is read-only here. */
const readOnlyMessage = (c: LoadedCourse): string => {
const kind = c.manifest?.source?.kind;
@@ -250,7 +259,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
"/api/studio/courses/:courseId/upload": {
POST: course(async (req, _rc, user, courseId) => {
const max = uploadMaxBytes();
const max = uploadMaxBytes(ctx.config.uploadMaxMb);
const declared = Number(req.headers.get("content-length") ?? "0");
if (declared > max) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`);
const bytes = await readCapped(req, max);
+11 -3
View File
@@ -14,10 +14,18 @@ export function studioReposDir(dataDir: string): string {
/**
* The course dirs the CourseRepository must scan: the configured ones, the importer's plain folders in
* `<dataDir>/courses`, and studio courses through their published worktree in `<dataDir>/worktrees`.
* Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md.
* Core `loadConfig`/`resolveConfig` already include both in `config.courseDirs`; this stays for callers that build
* an AppConfig by hand.
*/
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs">): string[] {
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs"> & Partial<Pick<AppConfig, "importedCoursesDir" | "studioWorktreesDir">>): string[] {
const dirs = config.courseDirs.map((d) => resolve(d));
for (const d of [resolve(config.dataDir, "courses"), resolve(studioWorktreesDir(config.dataDir))]) if (!dirs.includes(d)) dirs.push(d);
const imported = config.importedCoursesDir ?? resolve(config.dataDir, "courses");
const worktrees = config.studioWorktreesDir ?? studioWorktreesDir(config.dataDir);
for (const d of [resolve(imported), resolve(worktrees)]) if (!dirs.includes(d)) dirs.push(d);
return dirs;
}
/** Store options from the server configuration (core AppConfig owns the layout). */
export function storeDirs(config: Pick<AppConfig, "dataDir"> & Partial<Pick<AppConfig, "studioReposDir" | "importedCoursesDir" | "studioWorktreesDir">>) {
return { dataDir: config.dataDir, reposDir: config.studioReposDir, importedDir: config.importedCoursesDir, worktreesDir: config.studioWorktreesDir };
}
+9 -3
View File
@@ -123,6 +123,12 @@ export type Actor = Identity & { id: string; owner?: boolean };
export interface StudioStoreOptions {
dataDir: string;
/** AppConfig.studioReposDir (default `<dataDir>/studio/repos`). */
reposDir?: string;
/** AppConfig.importedCoursesDir (default `<dataDir>/courses`): ids taken by the importer. */
importedDir?: string;
/** AppConfig.studioWorktreesDir (default `<dataDir>/worktrees`). */
worktreesDir?: string;
publishedBranch?: string;
/** Tests only: allow local-path remotes (the HTTP feature never sets this). */
allowLocalRemotes?: boolean;
@@ -132,9 +138,9 @@ export function createStudioStore(opts: StudioStoreOptions) {
const dataDir = opts.dataDir;
const main = opts.publishedBranch ?? "main";
// Studio repos live apart from the importer's plain folders in <dataDir>/courses (controller ruling, review I4).
const reposDir = join(dataDir, "studio", "repos");
const importedDir = join(dataDir, "courses");
const worktreesDir = join(dataDir, "worktrees");
const reposDir = opts.reposDir ?? join(dataDir, "studio", "repos");
const importedDir = opts.importedDir ?? join(dataDir, "courses");
const worktreesDir = opts.worktreesDir ?? join(dataDir, "worktrees");
const remoteGuard = opts.allowLocalRemotes ? ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"] : REMOTE_GUARD;
const paths = (courseId: string) => {