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:
1 parent
427eda6e93
commit
777ae2cc37
72 files changed
+1066
-244
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
@@ -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,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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"] } }],
|
||||
};
|
||||
Reference in new issue
Block a user