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
@@ -3,3 +3,4 @@ export * from "./exercise";
|
||||
export * from "./ai";
|
||||
export * from "./import";
|
||||
export * from "./web";
|
||||
export * from "./routes";
|
||||
@@ -0,0 +1,14 @@
|
||||
// The learner's public page routes. The learner package registers these patterns; every other package (AI
|
||||
// citations, studio links) builds links with the helpers, so a link always matches a real route.
|
||||
// Pure functions with no dependencies, the only runtime code in @stc/contracts.
|
||||
|
||||
export const COURSE_ROUTE = "/c/:courseId";
|
||||
export const LESSON_ROUTE = "/c/:courseId/:lessonId";
|
||||
|
||||
export function courseUrl(courseId: string): string {
|
||||
return `/c/${encodeURIComponent(courseId)}`;
|
||||
}
|
||||
|
||||
export function lessonUrl(courseId: string, lessonId: string): string {
|
||||
return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`;
|
||||
}
|
||||
@@ -7,7 +7,12 @@ export interface Feature {
|
||||
name: string; // "learner" | "import" | "studio" | "ai" | "core"
|
||||
routes: Record<string, Partial<Record<"GET"|"POST"|"PUT"|"PATCH"|"DELETE", RouteHandler>>>; // paths start with /api/
|
||||
jobs?: Record<string, JobHandler>; // job kind -> handler
|
||||
auth?: AuthService; // replaces the anonymous AuthService (the learner package supplies it)
|
||||
stop?(): void | Promise<void>; // called when the server stops (timers, watchers)
|
||||
}
|
||||
/** Features are built after the ServerContext exists. The job queue is built from the union of their `jobs`, so a
|
||||
* factory must not use `ctx.jobs` synchronously (only while a request or job runs). */
|
||||
export type FeatureFactory = (ctx: ServerContext) => Feature;
|
||||
export interface JobContext { reportProgress(fraction: number, message?: string): Promise<void>; signal: AbortSignal }
|
||||
export type JobHandler = (payload: unknown, jc: JobContext) => Promise<unknown>;
|
||||
export type JobStatus = "queued" | "running" | "done" | "failed";
|
||||
@@ -20,8 +25,27 @@ export interface ServerContext {
|
||||
jobs: JobQueue; auth: AuthService;
|
||||
}
|
||||
export interface AppConfig {
|
||||
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, media/, worktrees/
|
||||
courseDirs: string[]; // folders scanned for stcourse.yaml (course repos and overlays/)
|
||||
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, courses/, studio/, worktrees/
|
||||
/** Folders scanned for stcourse.yaml: STC_COURSE_DIRS, plus importedCoursesDir and studioWorktreesDir (deduplicated). */
|
||||
courseDirs: string[];
|
||||
secret: string; // STC_SECRET, used to encrypt BYOK keys
|
||||
publicUrl: string;
|
||||
/** `<dataDir>/courses`: the importer's plain course folders. */
|
||||
importedCoursesDir: string;
|
||||
/** `<dataDir>/studio/repos`: studio bare repos (`<id>.git`), never scanned. */
|
||||
studioReposDir: string;
|
||||
/** `<dataDir>/worktrees`: studio published worktrees (`<id>/published`); drafts sit one level deeper and are never scanned. */
|
||||
studioWorktreesDir: string;
|
||||
/** `<dataDir>/cache`: derived files (decks/, covers/, transcripts/). */
|
||||
cacheDir: string;
|
||||
/** STC_IMPORT_ROOTS (":"-separated), default `<dataDir>/import-inbox`: the only folders the importer reads from. */
|
||||
importRoots: string[];
|
||||
/** STC_UPLOAD_MAX_MB, default 100. */
|
||||
uploadMaxMb: number;
|
||||
/** STC_AI_ALLOWED_BASE_URLS (comma-separated origins) that learners' own AI keys may use besides the official endpoints. */
|
||||
aiAllowedBaseUrls: string[];
|
||||
/** STC_TRANSCRIBE_URL: Whisper-compatible endpoint; null disables transcription. */
|
||||
transcribeUrl: string | null;
|
||||
/** STC_PYTHON, default python3: Python with PyMuPDF, used for PDF page images and text. */
|
||||
python: string;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { Role } from "./server";
|
||||
|
||||
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin" } }
|
||||
/** `nav.roles`: the shell shows the link only to signed-in users holding one of these roles (absent: everyone). */
|
||||
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin"; roles?: Role[] } }
|
||||
export interface WebFeature { name: string; routes: WebRoute[] }
|
||||
Reference in new issue
Block a user