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:
co-authored by
Claude Opus 5.5
parent
427eda6e93
commit
777ae2cc37
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user