diff --git a/apps/server/src/features.ts b/apps/server/src/features.ts new file mode 100644 index 0000000..10c7028 --- /dev/null +++ b/apps/server/src/features.ts @@ -0,0 +1,44 @@ +import type { FeatureFactory, ServerContext } from "@stc/contracts"; +import type { PackageMigrations } from "@stc/db"; +import { aiFeature, createFsDraftSink, migrationsDirs as aiMigrations, type DraftSink } from "@stc/feature-ai"; +import { importFeature, migrationsDirs as importMigrations } from "@stc/feature-import"; +import { learnerFeature, migrationsDirs as learnerMigrations, userIdentity } from "@stc/feature-learner"; +import { createDraftWriter, migrationsDirs as studioMigrations, studioFeature, type StudioFeature } from "@stc/feature-studio"; + +/** Every package's migration folders, keyed by package name (core runs first, then these in name order). */ +export const packageMigrations: PackageMigrations = { + "feature-ai": aiMigrations, + "feature-import": importMigrations, + "feature-learner": learnerMigrations, + "feature-studio": studioMigrations, +}; + +/** + * Where the AI generator writes drafts: studio-managed courses get a commit on the requesting author's + * `draft/` branch, authored as that user's name and email; overlay and imported courses (which the studio + * does not manage) fall back to files under `/drafts///`. The generator page says which. + */ +export function aiDraftSink(ctx: ServerContext, studio: Pick): DraftSink { + const writer = createDraftWriter(ctx, { identity: (userId) => userIdentity(ctx.db, userId) }); + const files = createFsDraftSink(ctx.config.dataDir); + return { + writeDraftFiles: (courseId, authorId, list, message) => + (studio.store.isManaged(courseId) ? writer : files).writeDraftFiles(courseId, authorId, list, message), + }; +} + +export interface FeatureSetOptions { + /** Studio published-worktree polling (default on). */ + studioWatch?: boolean; +} + +/** The product: learner (auth, library, reader), importer, studio and AI, in that order. */ +export function productFeatures(opts: FeatureSetOptions = {}): FeatureFactory[] { + let studio: StudioFeature | null = null; + return [ + learnerFeature, + (ctx) => importFeature(ctx), + (ctx) => (studio = studioFeature(ctx, { watch: opts.studioWatch ?? true })), + (ctx) => aiFeature(ctx, { draftSink: aiDraftSink(ctx, studio!) }), + ]; +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 201fc97..6a46694 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,62 +1,42 @@ -import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerContext } from "@stc/contracts"; -import { createApp, createJobQueue, loadConfig } from "@stc/core"; -import { createCourseRepository } from "@stc/course-format"; -import { connect, migrate } from "@stc/db"; -import { importFeature } from "@stc/feature-import"; -import { learnerFeature } from "@stc/feature-learner"; -import { resolve } from "node:path"; +import type { AppConfig, Feature, FeatureFactory } from "@stc/contracts"; +import { composeServer, loadConfig, type ComposeOptions } from "@stc/core"; import indexHtml from "../../web/index.html"; +import { packageMigrations, productFeatures } from "./features"; -// Used until a feature supplies an `auth` service (the learner package does). -const anonymousAuth: AuthService = { userFromRequest: async () => null }; +export interface StartOptions extends ComposeOptions {} -/** A feature, or a factory that needs the server context. A feature may carry the `AuthService`. */ -export type FeatureEntry = (Feature & { auth?: AuthService }) | ((ctx: ServerContext) => Feature & { auth?: AuthService }); - -export async function startServer( - config: AppConfig, - features: FeatureEntry[], - _webFeatures?: unknown, -): Promise<{ server: Bun.Server; stop(): Promise }> { - const db = await connect(config.databaseUrl); - await migrate(db); - const courses = await createCourseRepository(config.courseDirs); - const handlers: Record = {}; - // Factories get the context before the job queue exists; ctx.jobs is set right after they run. - const ctx: ServerContext = { config, db, courses, jobs: null as unknown as JobQueue, auth: anonymousAuth }; - const built = features.map((f) => (typeof f === "function" ? f(ctx) : f)); - for (const f of built) { - Object.assign(handlers, f.jobs); - if (f.auth) ctx.auth = f.auth; +/** + * Composes the features (core `composeServer`) and serves them with the SPA. Bun routes win over `fetch`; + * "/api/*" falls through to the composed app, so unknown API paths are 404 rather than the SPA. + */ +export async function startServer(config: AppConfig, features: (Feature | FeatureFactory)[], opts: StartOptions = {}) { + const composed = await composeServer(config, features, opts); + const { app } = composed; + let server: Bun.Server; + try { + server = Bun.serve({ + port: config.port, + routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml }, + fetch: (req) => app.fetch(req), + // Production (NODE_ENV=production, as in the Docker image) bundles the SPA once, minified, without HMR. + development: process.env.NODE_ENV !== "production", + }); + } catch (e) { + await composed.stop(); + throw e; } - const jobs = createJobQueue(db, handlers); - ctx.jobs = jobs; - const app = createApp(ctx, built); - jobs.start(); - const server = Bun.serve({ - port: config.port, - // Bun routes win over fetch; "/api/*" falls through to the composed app (404 for unknown API paths). - routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml }, - fetch: (req) => app.fetch(req), - }); return { server, + ctx: composed.ctx, async stop() { await server.stop(true); - await jobs.stop(); - await db.close(); + await composed.stop(); }, }; } if (import.meta.main) { const config = loadConfig(); - // Contract request (import.md): core config should include /courses itself. - const generated = resolve(config.dataDir, "courses"); - if (!config.courseDirs.some((c) => resolve(c) === generated)) config.courseDirs.push(generated); - const { server } = await startServer(config, [ - learnerFeature, - (ctx) => importFeature(ctx), - ]); + const { server } = await startServer(config, productFeatures(), { migrations: packageMigrations }); console.log(`STCourses listening on ${server.url}`); } diff --git a/apps/server/test/e2e.test.ts b/apps/server/test/e2e.test.ts new file mode 100644 index 0000000..2952b6a --- /dev/null +++ b/apps/server/test/e2e.test.ts @@ -0,0 +1,117 @@ +// End-to-end (plan Task I3): the real server with every feature, against the fixtures, on SQLite and Postgres. +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { loadConfig } from "@stc/core"; +import { studioFeature } from "@stc/feature-studio"; +import { createBrowserRunner } from "../../../packages/feature-learner/src/web/runner/runner"; +import { aiDraftSink, packageMigrations, productFeatures } from "../src/features"; +import { startServer } from "../src/main"; +import { PG, pgSchema } from "./pg"; + +const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses"); +const ORIGIN = "http://localhost:3000"; + +const cookieFrom = (r: Response) => r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; "); + +async function run(databaseUrl: string) { + const dataDir = mkdtempSync(join(tmpdir(), "stc-e2e-")); + const config = loadConfig({ + STC_PORT: "0", STC_DATABASE_URL: databaseUrl, STC_SECRET: "e2e-secret-0123456789abcdef0123456789", + STC_DATA_DIR: dataDir, STC_COURSE_DIRS: FIXTURES, STC_PUBLIC_URL: ORIGIN, + }); + const s = await startServer(config, productFeatures({ studioWatch: false }), { migrations: packageMigrations }); + const base = s.server.url.toString().replace(/\/$/, ""); + const call = async (method: string, path: string, opts: { cookie?: string; body?: unknown } = {}) => { + const headers: Record = { origin: ORIGIN }; + if (opts.cookie) headers.cookie = opts.cookie; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return fetch(base + path, { method, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) }); + }; + const signUp = async (email: string, name: string) => { + const r = await call("POST", "/api/auth/sign-up/email", { body: { email, name, password: "correct horse battery" } }); + expect(r.status).toBe(200); + return cookieFrom(r); + }; + try { + // 1. the server is up with every feature and the fixture course + expect(await (await call("GET", "/api/health")).json()).toEqual({ ok: true, courses: 1, db: databaseUrl.startsWith("postgres") ? "postgres" : "sqlite" }); + expect((await call("GET", "/c/sample")).headers.get("content-type")).toContain("text/html"); + + // 2. sign up: the first account is the owner + const owner = await signUp("ada@example.org", "Ada Lovelace"); + const me = (await (await call("GET", "/api/me", { cookie: owner })).json()) as { user: { id: string; roles: string[] } }; + expect(me.user.roles).toContain("owner"); + + // 3. mark a lesson done + expect((await call("PUT", "/api/courses/sample/progress/m1-intro", { cookie: owner, body: { state: "done" } })).status).toBe(200); + + // 4. a slide note on the video lesson's deck + const note = await call("POST", "/api/courses/sample/notes", { cookie: owner, body: { lessonId: "m1-video", anchorKind: "slide", anchorValue: 2, deckId: "d1", body: "Slide two matters" } }); + expect(note.status).toBe(201); + + // 5. run the sample exercise with the learner's runner, then save the passing attempt + const lesson = (await (await call("GET", "/api/courses/sample/lessons/m2-ex-add", { cookie: owner })).json()) as { browser: { starter: string; tests: string } }; + const runner = createBrowserRunner("js"); + const failing = await runner.run(lesson.browser.starter, lesson.browser.tests, 5000); + expect(failing.some((t) => !t.passed)).toBe(true); + const code = lesson.browser.starter.replace("a - b", "a + b"); + const results = await runner.run(code, lesson.browser.tests, 5000); + expect(results.length).toBeGreaterThan(0); + expect(results.every((t) => t.passed)).toBe(true); + expect((await call("PUT", "/api/courses/sample/lessons/m2-ex-add/attempt", { cookie: owner, body: { code, passed: true, result: results, track: "js" } })).status).toBe(200); + + // 6. read progress and notes back + const progress = (await (await call("GET", "/api/courses/sample/progress", { cookie: owner })).json()) as Record; + expect(progress["m1-intro"]?.state).toBe("done"); + expect(progress["m2-ex-add"]?.state).toBe("done"); + const notes = (await (await call("GET", "/api/courses/sample/notes", { cookie: owner })).json()) as { body: string; anchorKind: string; deckId: string }[]; + expect(notes.map((n) => [n.body, n.anchorKind, n.deckId])).toEqual([["Slide two matters", "slide", "d1"]]); + + // 7. a second account is a plain learner: own data only, and the owner/author features refuse it + const lea = await signUp("lea@example.org", "Lea"); + expect(await (await call("GET", "/api/courses/sample/progress", { cookie: lea })).json()).toEqual({}); + expect((await call("GET", "/api/import/info", { cookie: lea })).status).toBe(403); + expect((await call("GET", "/api/studio/courses", { cookie: lea })).status).toBe(403); + expect((await call("GET", "/api/ai/admin/config", { cookie: lea })).status).toBe(403); + expect((await call("GET", "/api/import/info", { cookie: owner })).status).toBe(200); + expect((await call("GET", "/api/studio/courses", { cookie: owner })).status).toBe(200); + expect((await call("GET", "/api/ai/status", { cookie: owner })).status).toBe(200); + + // 8. media: Range, nosniff + const clip = await call("GET", "/api/media/sample/media/clip.mp4", { cookie: lea }); + expect(clip.headers.get("x-content-type-options")).toBe("nosniff"); + + // 9. the AI draft sink: studio-managed course -> the author's draft branch, as that user; other courses -> files + const studio = studioFeature(s.ctx, { watch: false }); + try { + await studio.store.createCourse({ id: "gen-target", title: "Generated" }, { name: "Ada Lovelace", email: "ada@example.org" }); + const sink = aiDraftSink(s.ctx, studio); + const onStudio = await sink.writeDraftFiles("gen-target", me.user.id, [{ path: "lessons/q.md", content: "# Q\n" }], "AI draft"); + expect(onStudio.branch).toMatch(/^draft\//); + const repo = join(config.studioReposDir, "gen-target.git"); + const author = (await Bun.$`git --git-dir=${repo} log -1 ${"--format=%an <%ae>"} ${onStudio.branch!}`.text()).trim(); + expect(author).toBe("Ada Lovelace "); + const onFiles = await sink.writeDraftFiles("sample", me.user.id, [{ path: "q.md", content: "# Q\n" }], "AI draft"); + expect(onFiles.branch).toBeUndefined(); + expect(onFiles.location!.startsWith(resolve(dataDir, "drafts", "sample"))).toBe(true); + expect(readFileSync(join(onFiles.location!, "q.md"), "utf8")).toBe("# Q\n"); + } finally { + studio.stop(); + } + } finally { + await s.stop(); + } +} + +describe("end to end", () => { + test("sign up, mark done, slide note, exercise, progress [sqlite]", () => run("sqlite://:memory:"), 60_000); + const pg = PG; + if (pg) { + test("sign up, mark done, slide note, exercise, progress [postgres]", async () => { + const { url, drop } = await pgSchema(pg); + try { await run(url); } finally { await drop(); } + }, 60_000); + } +}); diff --git a/apps/server/test/pg.ts b/apps/server/test/pg.ts new file mode 100644 index 0000000..355a8a9 --- /dev/null +++ b/apps/server/test/pg.ts @@ -0,0 +1,13 @@ +import { connect } from "@stc/db"; + +export const PG = process.env.STC_TEST_PG_URL; + +/** A fresh Postgres schema (search_path in the URL), dropped by `drop()`. Same isolation as packages/db's forEachDb. */ +export async function pgSchema(base: string): Promise<{ url: string; drop(): Promise }> { + const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`; + const admin = await connect(base); + await admin.sql.unsafe(`CREATE SCHEMA ${schema}`); + const u = new URL(base); + u.searchParams.set("options", `-c search_path=${schema}`); + return { url: u.toString(), drop: async () => { await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); await admin.close(); } }; +} diff --git a/apps/server/test/server.test.ts b/apps/server/test/server.test.ts index 8f17abb..722fd2b 100644 --- a/apps/server/test/server.test.ts +++ b/apps/server/test/server.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { resolve } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { loadConfig } from "@stc/core"; import { startServer } from "../src/main"; @@ -7,7 +9,7 @@ let s: Awaited>; let base: string; beforeAll(async () => { const config = loadConfig({ - STC_PORT: "0", STC_DATABASE_URL: "sqlite://:memory:", STC_SECRET: "t", + STC_PORT: "0", STC_DATABASE_URL: "sqlite://:memory:", STC_SECRET: "t", STC_DATA_DIR: mkdtempSync(join(tmpdir(), "stc-server-")), STC_COURSE_DIRS: resolve(import.meta.dir, "../../../fixtures/courses"), }); s = await startServer(config, []); diff --git a/apps/web/src/bundle.test.ts b/apps/web/src/bundle.test.ts new file mode 100644 index 0000000..dc6efe6 --- /dev/null +++ b/apps/web/src/bundle.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +// A subprocess, because HTML bundling inside a full `bun test` run is unreliable (see the learner's csp.test). +test("the SPA bundles for the browser with no server code in it", async () => { + const out = mkdtempSync(join(tmpdir(), "stc-spa-")); + try { + const root = resolve(import.meta.dir, "../../.."); + const p = Bun.spawn(["bun", "build", "apps/web/index.html", "--outdir", out, "--target", "browser"], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const code = await p.exited; + expect(code === 0 ? "" : await new Response(p.stderr).text()).toBe(""); + const js = readdirSync(out).filter((f) => f.endsWith(".js")).map((f) => readFileSync(join(out, f), "utf8")).join("\n"); + expect(js.length).toBeGreaterThan(1000); + // Server-only modules of the feature packages and their dependencies. + for (const marker of ["better-auth", "X509Certificate", "createStudioStore", "importFeature(", "aiFeature(", "createJobQueue", "bun:sqlite"]) { + expect([marker, js.includes(marker)]).toEqual([marker, false]); + } + // Every feature's browser half is present. + for (const page of ["/admin/import", "/studio/:courseId", "/admin/ai", "/c/:courseId/:lessonId"]) expect(js).toContain(page); + } finally { + rmSync(out, { recursive: true, force: true }); + } +}, 120_000); diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts index 946564e..076a7bd 100644 --- a/apps/web/src/features.ts +++ b/apps/web/src/features.ts @@ -1,8 +1,9 @@ import type { WebFeature } from "@stc/contracts"; +// Browser-only entries ("@stc//web", mapped in tsconfig paths and package.json exports). Never import a +// package's server index here: it pulls Better Auth, git and node:fs into the bundle. import { learnerWeb } from "@stc/feature-learner/web"; -import { importWeb } from "../../../packages/feature-import/src/web"; -import { studioWeb } from "../../../packages/feature-studio/src/web"; -import { aiWeb } from "../../../packages/feature-ai/src/web"; +import { importWeb } from "@stc/feature-import/web"; +import { studioWeb } from "@stc/feature-studio/web"; +import { aiWeb } from "@stc/feature-ai/web"; -// Phase 1 packages append one import line each and push into this list. export const webFeatures: WebFeature[] = [learnerWeb, importWeb, studioWeb, aiWeb]; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a9afe98..a5a8c9f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,15 +1,33 @@ import { createRoot } from "react-dom/client"; -import { lazy, Suspense } from "react"; -import { createBrowserRouter, Link, Outlet, RouterProvider, type RouteObject } from "react-router-dom"; +import { lazy, Suspense, useEffect, useState } from "react"; +import { createBrowserRouter, Link, Outlet, RouterProvider, useLocation, type RouteObject } from "react-router-dom"; +import type { AuthUser } from "@stc/contracts"; import { AppShell } from "@stc/ui"; import { webFeatures } from "./features"; +import { visibleNav } from "./nav"; function Home() { return

No features installed yet

; } +/** The signed-in user (GET /api/me), refreshed on navigation so sign-in and sign-out update the nav. */ +function useSessionUser(): AuthUser | null { + const { pathname } = useLocation(); + const [user, setUser] = useState(null); + useEffect(() => { + let live = true; + fetch("/api/me", { credentials: "same-origin" }) + .then((r) => (r.ok ? r.json() : { user: null })) + .then((b: { user: AuthUser | null }) => { if (live) setUser(b.user ?? null); }) + .catch(() => { if (live) setUser(null); }); + return () => { live = false; }; + }, [pathname]); + return user; +} + function Layout() { - const nav = webFeatures.flatMap((f) => f.routes.filter((r) => r.nav?.area === "header")); + // Links the user cannot use are hidden; the pages and APIs still enforce access themselves. + const nav = visibleNav(webFeatures, useSessionUser()); const header = ( <> STCourses diff --git a/apps/web/src/nav.test.ts b/apps/web/src/nav.test.ts new file mode 100644 index 0000000..3e3a19f --- /dev/null +++ b/apps/web/src/nav.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { webFeatures } from "./features"; +import { visibleNav } from "./nav"; + +const labels = (roles: ("owner" | "author" | "learner")[] | null) => visibleNav(webFeatures, roles ? { roles } : null).map((r) => r.nav!.label); + +test("nav items are limited by role", () => { + expect(labels(null)).toEqual([]); + expect(labels(["learner"])).toEqual([]); + expect(labels(["author"])).toEqual(["Studio"]); + expect(labels(["owner"])).toEqual(["Studio", "Import", "AI"]); +}); + +test("features.ts imports only browser entries, by package name", () => { + const src = readFileSync(join(import.meta.dir, "features.ts"), "utf8"); + const specs = [...src.matchAll(/from\s+"([^"]+)"/g)].map((m) => m[1]!); + for (const s of specs) expect(s === "@stc/contracts" || /^@stc\/feature-[a-z]+\/web$/.test(s)).toBe(true); + expect(specs.filter((s) => s.endsWith("/web")).length).toBe(4); +}); diff --git a/apps/web/src/nav.ts b/apps/web/src/nav.ts new file mode 100644 index 0000000..3364a07 --- /dev/null +++ b/apps/web/src/nav.ts @@ -0,0 +1,15 @@ +import type { AuthUser, Role, WebFeature, WebRoute } from "@stc/contracts"; + +/** Admin-area links default to the owner; header links default to everyone. `nav.roles` overrides both. */ +const DEFAULT_ROLES: Record<"header" | "admin", Role[] | undefined> = { header: undefined, admin: ["owner"] }; + +/** The nav links this user may use, header links first, then admin links. */ +export function visibleNav(features: WebFeature[], user: Pick | null): WebRoute[] { + const all = features.flatMap((f) => f.routes.filter((r) => r.nav)); + const allowed = (r: WebRoute) => { + const roles = r.nav!.roles ?? DEFAULT_ROLES[r.nav!.area]; + if (!roles) return true; + return !!user && roles.some((x) => user.roles.includes(x)); + }; + return [...all.filter((r) => r.nav!.area === "header" && allowed(r)), ...all.filter((r) => r.nav!.area === "admin" && allowed(r))]; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 859b47a..9ccab89 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,3 +3,4 @@ export * from "./exercise"; export * from "./ai"; export * from "./import"; export * from "./web"; +export * from "./routes"; diff --git a/packages/contracts/src/routes.ts b/packages/contracts/src/routes.ts new file mode 100644 index 0000000..e99b199 --- /dev/null +++ b/packages/contracts/src/routes.ts @@ -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)}`; +} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1ac169e..cf8377c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -7,7 +7,12 @@ export interface Feature { name: string; // "learner" | "import" | "studio" | "ai" | "core" routes: Record>>; // paths start with /api/ jobs?: Record; // job kind -> handler + auth?: AuthService; // replaces the anonymous AuthService (the learner package supplies it) + stop?(): void | Promise; // 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; signal: AbortSignal } export type JobHandler = (payload: unknown, jc: JobContext) => Promise; 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; + /** `/courses`: the importer's plain course folders. */ + importedCoursesDir: string; + /** `/studio/repos`: studio bare repos (`.git`), never scanned. */ + studioReposDir: string; + /** `/worktrees`: studio published worktrees (`/published`); drafts sit one level deeper and are never scanned. */ + studioWorktreesDir: string; + /** `/cache`: derived files (decks/, covers/, transcripts/). */ + cacheDir: string; + /** STC_IMPORT_ROOTS (":"-separated), default `/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; } diff --git a/packages/contracts/src/web.ts b/packages/contracts/src/web.ts index 2b7717c..2f95a0c 100644 --- a/packages/contracts/src/web.ts +++ b/packages/contracts/src/web.ts @@ -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[] } diff --git a/packages/contracts/test/types.test.ts b/packages/contracts/test/types.test.ts index ebf1370..e5cfbf0 100644 --- a/packages/contracts/test/types.test.ts +++ b/packages/contracts/test/types.test.ts @@ -25,6 +25,9 @@ test("contract interfaces accept well-formed literals", async () => { const config: AppConfig = { port: 3000, databaseUrl: "sqlite://./data/stcourses.db", dataDir: "./data", courseDirs: ["./overlays"], secret: "s", publicUrl: "http://localhost:3000", + importedCoursesDir: "./data/courses", studioReposDir: "./data/studio/repos", studioWorktreesDir: "./data/worktrees", + cacheDir: "./data/cache", importRoots: ["./data/import-inbox"], uploadMaxMb: 100, aiAllowedBaseUrls: [], + transcribeUrl: null, python: "python3", }; const ctx: Pick = { config, jobs: queue, auth }; diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index fc7554f..931328c 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -6,12 +6,17 @@ import { serveMedia } from "./media"; type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type Routes = Record Response | Promise>>>; +/** + * The one visibility rule (ruling R14). Every package calls this; none keeps a local variant. + * public: everyone; signed-in: any signed-in user; restricted: the owner, the global `author` role, a per-course + * author (`isAuthor`, a course_authors row), or an ACL grant to the user or one of their groups. + */ export function canView(user: AuthUser | null, course: LoadedCourse, acl: { principal_type: string; principal_id: string }[], isAuthor = false): boolean { const vis = course.manifest?.visibility ?? "restricted"; if (vis === "public") return true; if (!user) return false; if (vis === "signed-in") return true; - if (user.roles.includes("owner") || isAuthor) return true; + if (user.roles.includes("owner") || user.roles.includes("author") || isAuthor) return true; return acl.some((a) => (a.principal_type === "user" && a.principal_id === user.id) || (a.principal_type === "group" && user.groups.includes(a.principal_id))); } diff --git a/packages/core/src/compose.ts b/packages/core/src/compose.ts new file mode 100644 index 0000000..4b68181 --- /dev/null +++ b/packages/core/src/compose.ts @@ -0,0 +1,75 @@ +import type { AppConfig, AuthService, Feature, FeatureFactory, JobHandler, JobQueue, ServerContext } from "@stc/contracts"; +import { createCourseRepository } from "@stc/course-format"; +import { connect, migrate, type PackageMigrations } from "@stc/db"; +import { createApp } from "./app"; +import { createJobQueue } from "./jobs"; + +/** Used until a feature supplies an `auth` service (the learner package does). */ +const anonymousAuth: AuthService = { userFromRequest: async () => null }; + +export interface ComposeOptions { + /** Each package's migration folders, keyed by package name; run after core's, in name order. */ + migrations?: PackageMigrations; + /** Start polling the job queue (default true). */ + startJobs?: boolean; +} + +export interface ComposedServer { + ctx: ServerContext; + features: Feature[]; + app: ReturnType; + stop(): Promise; +} + +/** + * Builds the server context and the features (rulings R8/R14). Order: + * 1. connect and migrate (core, then packages); + * 2. load courses from `config.courseDirs`; + * 3. run each factory with the context. `ctx.jobs` already exists as a handle, but it forwards to the real queue, + * which is built in step 4 from the union of the features' `jobs`, so factories may keep it but must not + * enqueue while they run; + * 4. a feature carrying `auth` replaces the anonymous AuthService (at most one may). + */ +export async function composeServer(config: AppConfig, entries: (Feature | FeatureFactory)[], opts: ComposeOptions = {}): Promise { + const db = await connect(config.databaseUrl); + try { + await migrate(db, opts.migrations ?? {}); + const courses = await createCourseRepository(config.courseDirs); + let queue: (JobQueue & { start(): void; stop(): Promise }) | null = null; + const notYet = (): never => { throw new Error("the job queue is not ready while features are being built"); }; + const jobs: JobQueue = { + enqueue: (kind, payload) => (queue ?? notYet()).enqueue(kind, payload), + get: (id) => (queue ?? notYet()).get(id), + list: (kind) => (queue ?? notYet()).list(kind), + }; + const ctx: ServerContext = { config, db, courses, jobs, auth: anonymousAuth }; + const features = entries.map((f) => (typeof f === "function" ? f(ctx) : f)); + const handlers: Record = {}; + let authFrom: string | null = null; + for (const f of features) { + for (const [kind, h] of Object.entries(f.jobs ?? {})) { + if (handlers[kind]) throw new Error(`Duplicate job kind "${kind}" (feature "${f.name}")`); + handlers[kind] = h; + } + if (f.auth) { + if (authFrom) throw new Error(`Both "${authFrom}" and "${f.name}" supply an AuthService`); + authFrom = f.name; + ctx.auth = f.auth; + } + } + queue = createJobQueue(db, handlers); + const app = createApp(ctx, features); + if (opts.startJobs !== false) queue.start(); + return { + ctx, features, app, + async stop() { + for (const f of features) { try { await f.stop?.(); } catch (e) { console.error(`[stc] stopping ${f.name}`, e); } } + await queue!.stop(); + await db.close(); + }, + }; + } catch (e) { + await db.close().catch(() => {}); + throw e; + } +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 0359442..aebf8b9 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,7 +1,48 @@ +import { resolve } from "node:path"; import type { AppConfig } from "@stc/contracts"; const DEV_SECRET = "stc-dev-secret-change-me"; +/** The settings a deployment chooses; everything else in AppConfig is derived by `resolveConfig`. */ +export type ConfigInput = Pick & Partial>; + +// Path lists are split on ":" only (paths may contain spaces); URL lists are trimmed. +const paths = (raw: string | undefined) => (raw ?? "").split(":").filter(Boolean); +const urls = (raw: string | undefined) => (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean); + +/** + * Fills in the derived parts of the configuration. Course discovery always covers the importer's folders and the + * studio's published worktrees, after the configured dirs, without duplicates (compared as absolute paths). + * Tests and dev entries use this to build an AppConfig the same way `loadConfig` does. + */ +export function resolveConfig(input: ConfigInput): AppConfig { + const dataDir = input.dataDir; + const importedCoursesDir = input.importedCoursesDir ?? resolve(dataDir, "courses"); + const studioWorktreesDir = input.studioWorktreesDir ?? resolve(dataDir, "worktrees"); + const courseDirs: string[] = []; + for (const d of [...(input.courseDirs ?? []), importedCoursesDir, studioWorktreesDir]) { + if (!courseDirs.some((c) => resolve(c) === resolve(d))) courseDirs.push(d); + } + const port = input.port ?? 3000; + return { + port, + databaseUrl: input.databaseUrl ?? "sqlite://./data/stcourses.db", + dataDir, + courseDirs, + secret: input.secret ?? DEV_SECRET, + publicUrl: input.publicUrl ?? `http://localhost:${port}`, + importedCoursesDir, + studioReposDir: input.studioReposDir ?? resolve(dataDir, "studio", "repos"), + studioWorktreesDir, + cacheDir: input.cacheDir ?? resolve(dataDir, "cache"), + importRoots: input.importRoots ?? [resolve(dataDir, "import-inbox")], + uploadMaxMb: input.uploadMaxMb ?? 100, + aiAllowedBaseUrls: input.aiAllowedBaseUrls ?? [], + transcribeUrl: input.transcribeUrl ?? null, + python: input.python ?? "python3", + }; +} + export function loadConfig(env: Record = process.env): AppConfig { const port = env.STC_PORT !== undefined && env.STC_PORT !== "" ? Number(env.STC_PORT) : 3000; if (!Number.isInteger(port) || port < 0) throw new Error(`Invalid STC_PORT: ${env.STC_PORT}`); @@ -11,12 +52,40 @@ export function loadConfig(env: Record = process.env console.warn("[stc] STC_SECRET is not set; using an insecure development default"); secret = DEV_SECRET; } - return { + let uploadMaxMb = 100; + if (env.STC_UPLOAD_MAX_MB) { + uploadMaxMb = Number(env.STC_UPLOAD_MAX_MB); + if (!Number.isFinite(uploadMaxMb) || uploadMaxMb <= 0) throw new Error(`Invalid STC_UPLOAD_MAX_MB: ${env.STC_UPLOAD_MAX_MB}`); + } + const dataDir = env.STC_DATA_DIR || "./data"; + const roots = paths(env.STC_IMPORT_ROOTS); + return resolveConfig({ port, databaseUrl: env.STC_DATABASE_URL || "sqlite://./data/stcourses.db", - dataDir: env.STC_DATA_DIR || "./data", - courseDirs: (env.STC_COURSE_DIRS || "./overlays:./courses").split(":").filter(Boolean), + dataDir, + courseDirs: paths(env.STC_COURSE_DIRS || "./overlays:./courses"), secret, publicUrl: env.STC_PUBLIC_URL || `http://localhost:${port}`, + importRoots: roots.length ? roots.map((r) => resolve(r)) : undefined, + uploadMaxMb, + aiAllowedBaseUrls: urls(env.STC_AI_ALLOWED_BASE_URLS), + transcribeUrl: env.STC_TRANSCRIBE_URL || null, + python: env.STC_PYTHON || "python3", + }); +} + +/** The importer's derived-file layout under `cacheDir` (decks, cover candidates, transcripts), shared by every package. */ +export function cachePaths(config: Pick) { + const root = config.cacheDir; + const seg = (s: string) => { + if (!s || s === "." || s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0")) throw new Error(`invalid cache path segment "${s}"`); + return s; + }; + return { + root, + deckDir: (courseId: string, deckId: string) => resolve(root, "decks", seg(courseId), seg(deckId)), + coversDir: (courseId: string) => resolve(root, "covers", seg(courseId)), + transcriptFile: (courseId: string, lessonId: string) => resolve(root, "transcripts", seg(courseId), `${seg(lessonId)}.json`), + transcriptsDir: (courseId: string) => resolve(root, "transcripts", seg(courseId)), }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d955c36..11619c1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ -export { loadConfig } from "./config"; +export { loadConfig, resolveConfig, cachePaths, type ConfigInput } from "./config"; export { createJobQueue } from "./jobs"; -export { serveMedia, contentType, contentDisposition } from "./media"; +export { serveMedia, contentType, contentDisposition, mediaSecurityHeaders, MEDIA_SANDBOX_CSP } from "./media"; export { createApp, canView, type Routes } from "./app"; +export { composeServer, type ComposeOptions, type ComposedServer } from "./compose"; diff --git a/packages/core/src/media.ts b/packages/core/src/media.ts index 941c1ed..c1a5ec3 100644 --- a/packages/core/src/media.ts +++ b/packages/core/src/media.ts @@ -1,9 +1,23 @@ const TYPES: Record = { mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", svg: "image/svg+xml", md: "text/markdown; charset=utf-8", txt: "text/plain; charset=utf-8", zip: "application/zip", - js: "text/javascript; charset=utf-8", ts: "text/plain; charset=utf-8", + js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8", ts: "text/plain; charset=utf-8", + html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", xhtml: "application/xhtml+xml", xml: "application/xml", }; +/** Types a browser could run script from. Course media is author content on the app origin, so these get a sandbox. */ +const ACTIVE = new Set(["svg", "html", "htm", "xhtml", "xml", "js", "mjs"]); +/** Opaque origin, no script, no plugins: an svg still renders as an image, an html page as inert markup. */ +export const MEDIA_SANDBOX_CSP = "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'"; + +/** Headers every media response carries; plus a sandbox CSP for script-capable types. */ +export function mediaSecurityHeaders(path: string): Record { + const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase(); + const h: Record = { "X-Content-Type-Options": "nosniff" }; + if (ACTIVE.has(ext)) h["Content-Security-Policy"] = MEDIA_SANDBOX_CSP; + return h; +} + export function contentType(path: string): string { const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase(); return TYPES[ext] ?? "application/octet-stream"; @@ -42,11 +56,14 @@ export async function serveMedia(req: Request, absPath: string, opts: { download const headers: Record = { "Content-Type": contentType(absPath), "Accept-Ranges": "bytes", + ...mediaSecurityHeaders(absPath), }; if (opts.download) headers["Content-Disposition"] = contentDisposition(name); const range = req.headers.get("range"); const parsed = range ? parseRange(range, size) : null; - if (parsed === "invalid") return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes" } }); + if (parsed === "invalid" || (parsed && size === 0)) { + return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes", "X-Content-Type-Options": "nosniff" } }); + } if (!parsed) { headers["Content-Length"] = String(size); return new Response(req.method === "HEAD" ? null : file, { status: 200, headers }); diff --git a/packages/core/test/app.test.ts b/packages/core/test/app.test.ts index 6a8545c..ad6f1c1 100644 --- a/packages/core/test/app.test.ts +++ b/packages/core/test/app.test.ts @@ -97,5 +97,13 @@ test("canView matrix", async () => { const pub = ctx.courses.get("sample")!, priv = ctx.courses.get("secret")!; expect(canView(null, pub, [])).toBe(true); expect(canView(null, priv, [])).toBe(false); + const u = (roles: AuthUser["roles"], groups: string[] = []): AuthUser => ({ id: "u1", email: "u@x", name: "U", roles, groups }); + expect(canView(u(["learner"]), priv, [])).toBe(false); + expect(canView(u(["owner"]), priv, [])).toBe(true); + expect(canView(u(["author"]), priv, [])).toBe(true); // R14: the global author role views every course + expect(canView(u(["learner"]), priv, [], true)).toBe(true); // per-course author + expect(canView(u(["learner"]), priv, [{ principal_type: "user", principal_id: "u1" }])).toBe(true); + expect(canView(u(["learner"], ["g"]), priv, [{ principal_type: "group", principal_id: "g" }])).toBe(true); + expect(canView(u(["learner"], ["h"]), priv, [{ principal_type: "group", principal_id: "g" }])).toBe(false); await db.close(); }); diff --git a/packages/core/test/compose.test.ts b/packages/core/test/compose.test.ts new file mode 100644 index 0000000..213877b --- /dev/null +++ b/packages/core/test/compose.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { AuthUser, Feature, ServerContext } from "@stc/contracts"; +import { composeServer, resolveConfig } from "../src"; + +const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses"); +const config = () => resolveConfig({ dataDir: mkdtempSync(join(tmpdir(), "stc-compose-")), databaseUrl: "sqlite://:memory:", courseDirs: [FIXTURES] }); + +test("factories get the context; the queue is built from the union of their jobs; auth and stop are honoured", async () => { + const seen: string[] = []; + let stopped = 0; + const user: AuthUser = { id: "u", email: "u@x", name: "U", roles: ["owner"], groups: [] }; + let early: unknown = null; + const a = (ctx: ServerContext): Feature => { + // the handle exists while factories run, but enqueuing must wait for the real queue + try { void ctx.jobs.enqueue("b.work", {}); } catch (e) { early = e; } + return { + name: "a", + routes: { "/api/a": { GET: async (_r, rc) => Response.json({ who: rc.user?.id ?? null, courses: ctx.courses.list().length }) } }, + jobs: { "a.work": async (p) => { seen.push(`a:${JSON.stringify(p)}`); return null; } }, + auth: { userFromRequest: async () => user }, + stop: () => { stopped++; }, + }; + }; + const b = (ctx: ServerContext): Feature => ({ + name: "b", + routes: { "/api/b": { POST: async () => Response.json({ id: await ctx.jobs.enqueue("a.work", { n: 1 }) }) } }, + jobs: { "b.work": async () => null }, + }); + const s = await composeServer(config(), [a, b]); + try { + expect(String(early)).toMatch(/not ready/); + expect(await (await s.app.fetch(new Request("http://x/api/a"))).json()).toEqual({ who: "u", courses: 1 }); + const { id } = (await (await s.app.fetch(new Request("http://x/api/b", { method: "POST" }))).json()) as { id: string }; + for (let i = 0; i < 200 && (await s.ctx.jobs.get(id))?.status !== "done"; i++) await Bun.sleep(10); + expect((await s.ctx.jobs.get(id))?.status).toBe("done"); + expect(seen).toEqual(['a:{"n":1}']); + } finally { + await s.stop(); + } + expect(stopped).toBe(1); +}); + +test("duplicate job kinds and two auth services are refused", async () => { + const f = (name: string, extra: Partial) => (): Feature => ({ name, routes: {}, ...extra }); + await expect(composeServer(config(), [f("x", { jobs: { k: async () => null } }), f("y", { jobs: { k: async () => null } })])).rejects.toThrow(/Duplicate job kind "k"/); + const auth = { userFromRequest: async () => null }; + await expect(composeServer(config(), [f("x", { auth }), f("y", { auth })])).rejects.toThrow(/AuthService/); +}); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 3305ea7..85c3d15 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -1,5 +1,6 @@ import { expect, test, spyOn } from "bun:test"; -import { loadConfig } from "../src"; +import { resolve } from "node:path"; +import { cachePaths, loadConfig, resolveConfig } from "../src"; test("defaults", () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); @@ -7,21 +8,59 @@ test("defaults", () => { expect(c.port).toBe(3000); expect(c.databaseUrl).toBe("sqlite://./data/stcourses.db"); expect(c.dataDir).toBe("./data"); - expect(c.courseDirs).toEqual(["./overlays", "./courses"]); + // configured dirs first, then the importer's folders and the studio's published worktrees + expect(c.courseDirs).toEqual(["./overlays", "./courses", resolve("./data/courses"), resolve("./data/worktrees")]); + expect(c.importedCoursesDir).toBe(resolve("./data/courses")); + expect(c.studioReposDir).toBe(resolve("./data/studio/repos")); + expect(c.studioWorktreesDir).toBe(resolve("./data/worktrees")); + expect(c.cacheDir).toBe(resolve("./data/cache")); + expect(c.importRoots).toEqual([resolve("./data/import-inbox")]); + expect(c.uploadMaxMb).toBe(100); + expect(c.aiAllowedBaseUrls).toEqual([]); + expect(c.transcribeUrl).toBeNull(); + expect(c.python).toBe("python3"); expect(c.publicUrl).toBe("http://localhost:3000"); expect(warn).toHaveBeenCalledTimes(1); warn.mockRestore(); }); test("colon splitting and overrides", () => { - const c = loadConfig({ STC_PORT: "8080", STC_COURSE_DIRS: "/a b:/c", STC_SECRET: "s", STC_PUBLIC_URL: "https://x.test" }); + const c = loadConfig({ + STC_PORT: "8080", STC_COURSE_DIRS: "/a b:/c", STC_SECRET: "s", STC_PUBLIC_URL: "https://x.test", STC_DATA_DIR: "/d", + STC_IMPORT_ROOTS: "/in box:/other", STC_UPLOAD_MAX_MB: "5", STC_AI_ALLOWED_BASE_URLS: "http://gw.lan:8080, https://x.test", + STC_TRANSCRIBE_URL: "http://whisper.lan/v1/audio/transcriptions", STC_PYTHON: "/opt/py/bin/python", + }); expect(c.port).toBe(8080); - expect(c.courseDirs).toEqual(["/a b", "/c"]); + expect(c.courseDirs).toEqual(["/a b", "/c", "/d/courses", "/d/worktrees"]); expect(c.secret).toBe("s"); expect(c.publicUrl).toBe("https://x.test"); + expect(c.importRoots).toEqual(["/in box", "/other"]); + expect(c.uploadMaxMb).toBe(5); + expect(c.aiAllowedBaseUrls).toEqual(["http://gw.lan:8080", "https://x.test"]); + expect(c.transcribeUrl).toBe("http://whisper.lan/v1/audio/transcriptions"); + expect(c.python).toBe("/opt/py/bin/python"); +}); + +test("configured dirs that already name the derived dirs are not repeated", () => { + const c = loadConfig({ STC_SECRET: "s", STC_DATA_DIR: "/d", STC_COURSE_DIRS: "/d/courses:/x" }); + expect(c.courseDirs).toEqual(["/d/courses", "/x", "/d/worktrees"]); + expect(resolveConfig({ dataDir: "/d", courseDirs: ["/d/worktrees/"] }).courseDirs).toEqual(["/d/worktrees/", "/d/courses"]); +}); + +test("invalid upload cap throws", () => { + expect(() => loadConfig({ STC_SECRET: "s", STC_UPLOAD_MAX_MB: "lots" })).toThrow(/STC_UPLOAD_MAX_MB/); }); test("production without a secret throws", () => { expect(() => loadConfig({ NODE_ENV: "production" })).toThrow(/STC_SECRET/); expect(loadConfig({ NODE_ENV: "production", STC_SECRET: "x" }).secret).toBe("x"); }); + +test("cachePaths: the importer's layout, no escaping segments", () => { + const p = cachePaths({ cacheDir: "/d/cache" }); + expect(p.deckDir("c", "deck1")).toBe("/d/cache/decks/c/deck1"); + expect(p.coversDir("c")).toBe("/d/cache/covers/c"); + expect(p.transcriptFile("c", "m1-l1")).toBe("/d/cache/transcripts/c/m1-l1.json"); + expect(() => p.coversDir("..")).toThrow(); + expect(() => p.deckDir("c", "a/b")).toThrow(); +}); diff --git a/packages/core/test/media.test.ts b/packages/core/test/media.test.ts index 10fdb74..0060c1c 100644 --- a/packages/core/test/media.test.ts +++ b/packages/core/test/media.test.ts @@ -57,3 +57,36 @@ test("non-ASCII filename gets a valid filename*", async () => { test("missing file is 404", async () => { expect((await serveMedia(req(), join(dir, "nope.mp4"))).status).toBe(404); }); + +test("every response is nosniff; script-capable types get a sandbox CSP", async () => { + const d = mkdtempSync(join(tmpdir(), "stc-media-csp-")); + const { MEDIA_SANDBOX_CSP } = await import("../src"); + for (const [name, body, sandboxed] of [ + ["x.svg", "", true], + ["page.html", "", true], + ["Page.HTM", "

x

", true], + ["code.js", "alert(1)", true], + ["mod.mjs", "export {}", true], + ["notes.md", "# hi", false], + ["clip.mp4", "x", false], + ] as const) { + writeFileSync(join(d, name), body); + for (const r of [await serveMedia(req(), join(d, name)), await serveMedia(req("bytes=0-0"), join(d, name))]) { + expect(r.headers.get("x-content-type-options")).toBe("nosniff"); + expect([name, r.headers.get("content-security-policy")]).toEqual([name, sandboxed ? MEDIA_SANDBOX_CSP : null]); + } + } + expect(MEDIA_SANDBOX_CSP).toMatch(/^sandbox;/); + expect(MEDIA_SANDBOX_CSP).not.toContain("allow-scripts"); +}); + +test("a range on an empty file is 416, never a negative Content-Range", async () => { + const d = mkdtempSync(join(tmpdir(), "stc-media-empty-")); + writeFileSync(join(d, "empty.txt"), ""); + for (const range of ["bytes=0-", "bytes=-5", "bytes=0-0"]) { + const r = await serveMedia(req(range), join(d, "empty.txt")); + expect(r.status).toBe(416); + expect(r.headers.get("content-range")).toBe("bytes */0"); + } + expect((await serveMedia(req(), join(d, "empty.txt"))).status).toBe(200); +}); diff --git a/packages/course-format/package.json b/packages/course-format/package.json index 935c4e7..b3d7584 100644 --- a/packages/course-format/package.json +++ b/packages/course-format/package.json @@ -6,5 +6,9 @@ "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.6.7" + }, + "exports": { + ".": "./src/index.ts", + "./markdown": "./src/markdown.ts" } } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ec4d4e5..7129602 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,5 +1,5 @@ export { connect, type Db } from "./connect"; -export { migrate } from "./migrate"; +export { migrate, type PackageMigrations } from "./migrate"; export { progressRepo, type LessonProgress, type ProgressState } from "./repos/progress"; export { notesRepo, type Note, type NoteInput } from "./repos/notes"; export { attemptsRepo, type Attempt } from "./repos/attempts"; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index be5f38f..7f71ffc 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -9,19 +9,34 @@ function statements(text: string): string[] { return noComments.split(";").map((s) => s.trim()).filter(Boolean); } -export async function migrate(db: Db): Promise { +/** Migration folders contributed by feature packages, keyed by package name. */ +export type PackageMigrations = Record; + +/** + * Applies pending migrations: core (`packages/db/migrations`) first, then each package's folders in alphabetical + * order of package name, files in name order within a folder. `schema_migrations` is keyed by file name, so file + * names must be unique across packages (each package prefixes its files, e.g. `0001_learner_auth.sql`). + */ +export async function migrate(db: Db, packages: PackageMigrations = {}): Promise { await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name)); - const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")).sort(); + const dirs = [MIGRATIONS_DIR, ...Object.keys(packages).sort().flatMap((k) => packages[k]!)]; + const seen = new Map(); const applied: string[] = []; - for (const name of files) { - if (done.has(name)) continue; - const stmts = statements(readFileSync(join(MIGRATIONS_DIR, name), "utf8")); - await db.sql.begin(async (tx) => { - for (const s of stmts) await tx.unsafe(s); - await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`; - }); - applied.push(name); + for (const dir of dirs) { + for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const other = seen.get(name); + if (other && other !== dir) throw new Error(`migration ${name} exists in both ${other} and ${dir}`); + seen.set(name, dir); + if (done.has(name)) continue; + const stmts = statements(readFileSync(join(dir, name), "utf8")); + await db.sql.begin(async (tx) => { + for (const s of stmts) await tx.unsafe(s); + await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`; + }); + done.add(name); + applied.push(name); + } } return applied; } diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 8619728..23f3301 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -30,3 +30,22 @@ forEachDb("all tables exist", async (db) => { await one("collection_courses", s`INSERT INTO collection_courses VALUES ('co','c',0)`); await one("jobs", s`INSERT INTO jobs VALUES ('j','k','queued',0,NULL,'{}',NULL,NULL,${t},${t})`); }); + +forEachDb("package migrations run after core, in package-name order, once", async (db) => { + const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const root = mkdtempSync(join(tmpdir(), "stc-mig-")); + const a = join(root, "a"), z = join(root, "z"); + mkdirSync(a); mkdirSync(z); + // z's table is referenced by a's second file only through ordering by package name: "alpha" runs before "zeta". + writeFileSync(join(z, "0001_zeta.sql"), "CREATE TABLE zeta_t (id TEXT PRIMARY KEY);"); + writeFileSync(join(a, "0001_alpha.sql"), "CREATE TABLE alpha_t (id TEXT PRIMARY KEY);"); + writeFileSync(join(a, "0002_alpha.sql"), "INSERT INTO alpha_t VALUES ('x');"); + expect(await migrate(db, { zeta: [z], alpha: [a] })).toEqual(["0001_alpha.sql", "0002_alpha.sql", "0001_zeta.sql"]); + expect(await migrate(db, { zeta: [z], alpha: [a] })).toEqual([]); + expect((await db.sql`SELECT id FROM alpha_t`).length).toBe(1); + // the same file name in two packages is refused rather than silently skipped + writeFileSync(join(z, "0001_alpha.sql"), "SELECT 1;"); + await expect(migrate(db, { zeta: [z], alpha: [a] })).rejects.toThrow(/exists in both/); +}); diff --git a/packages/feature-ai/src/feature.ts b/packages/feature-ai/src/feature.ts index d17f3a7..13b5180 100644 --- a/packages/feature-ai/src/feature.ts +++ b/packages/feature-ai/src/feature.ts @@ -24,8 +24,6 @@ export interface AiFeatureOptions { /** Overrides provider construction (tests use a fake AiProvider). */ providerFactory?: (cfg: ProviderConfig, apiKey: string | null) => AiProvider; mcpTimeoutMs?: number; - /** Environment to read STC_AI_ALLOWED_BASE_URLS from. Default: process.env. */ - env?: Record; /** Server-side log for provider errors that are not shown to users. Default: console.error. */ log?: (message: string) => void; } @@ -147,8 +145,9 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat const progress = progressRepo(ctx.db); const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir); const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`)); - const env = opts.env ?? process.env; - const allowedOrigins = async () => [...new Set([...parseAllowedOrigins(env.STC_AI_ALLOWED_BASE_URLS ?? ""), ...(await settings.getServer()).allowedBaseUrls])]; + // AppConfig.aiAllowedBaseUrls (STC_AI_ALLOWED_BASE_URLS) plus the admin's list in the server AI settings. + const configOrigins = parseAllowedOrigins((ctx.config.aiAllowedBaseUrls ?? []).join(",")); + const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.getServer()).allowedBaseUrls])]; /** * Providers for a user's own key never follow redirects, and their errors reach the user only as the HTTP * status plus a fixed sentence (the full text is logged here). Server-scope providers are the admin's own. @@ -186,9 +185,8 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat }; /** * Generation and the studio file listing: the user must hold an authoring right (owner, the `author` role, or a - * `course_authors` row) AND see the course under core's `canView`, the same visibility rule as everywhere else. - * A global `author` therefore cannot reach a restricted course without an ACL grant or a course_authors row - * (see contract request 8). + * `course_authors` row) AND see the course under core's `canView`, the one visibility rule (R14; it lets the + * global `author` role view every course). */ const canAuthor = async (user: AuthUser, course: LoadedCourse) => { const isAuthorRow = await acl.isAuthor(course.id, user.id); diff --git a/packages/feature-ai/src/web/GeneratePage.tsx b/packages/feature-ai/src/web/GeneratePage.tsx index c96c0fe..fd53529 100644 --- a/packages/feature-ai/src/web/GeneratePage.tsx +++ b/packages/feature-ai/src/web/GeneratePage.tsx @@ -178,7 +178,11 @@ export default function GeneratePage() { {job.status === "failed" ?

{job.error}

: null} {job.result ? ( <> - {job.result.branch ? "Committed to your draft branch " : "Saved as a draft at "}{job.result.location}. Review it in the studio before publishing. + {job.result.branch ? ( + Committed to your draft branch {job.result.location}. Review it in the studio before publishing. + ) : ( + This course is not edited in the studio, so the draft was saved as files on the server at {job.result.location}. Nothing in the course changed; copy what you want into it yourself. + )}
    {job.result.paths.map((p) =>
  • {p}draft
  • )}
{job.result.warnings.length ? (
diff --git a/packages/feature-ai/src/web/index.ts b/packages/feature-ai/src/web/index.ts index 181fc97..9fb7424 100644 --- a/packages/feature-ai/src/web/index.ts +++ b/packages/feature-ai/src/web/index.ts @@ -4,7 +4,7 @@ export const aiWeb: WebFeature = { name: "ai", routes: [ { path: "/c/:courseId/assistant", element: () => import("./AssistantPage") }, - { path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin" } }, + { path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin", roles: ["owner"] } }, { path: "/account/ai", element: () => import("./AccountAiPage") }, { path: "/studio/generate", element: () => import("./GeneratePage") }, ], diff --git a/packages/feature-ai/src/web/urls.ts b/packages/feature-ai/src/web/urls.ts index d08fb57..375f206 100644 --- a/packages/feature-ai/src/web/urls.ts +++ b/packages/feature-ai/src/web/urls.ts @@ -1,11 +1,2 @@ -/** - * Learner URLs the AI pages link to. The learner app (WP-A) owns these routes; keep every builder here so - * Phase 2 can change the shape in one place (contract request 7). - */ -export function courseUrl(courseId: string): string { - return `/c/${encodeURIComponent(courseId)}`; -} - -export function lessonUrl(courseId: string, lessonId: string): string { - return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`; -} +// Learner URLs the AI pages link to; the learner owns the routes (shared helpers in @stc/contracts, request 7). +export { courseUrl, lessonUrl } from "@stc/contracts"; diff --git a/packages/feature-ai/test/feature.test.ts b/packages/feature-ai/test/feature.test.ts index 96fa0bd..148ad52 100644 --- a/packages/feature-ai/test/feature.test.ts +++ b/packages/feature-ai/test/feature.test.ts @@ -2,8 +2,8 @@ import { expect } from "bun:test"; import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import type { AuthUser, JobRecord, ServerContext } from "@stc/contracts"; -import { createApp, createJobQueue } from "@stc/core"; +import type { AppConfig, AuthUser, JobRecord, ServerContext } from "@stc/contracts"; +import { createApp, createJobQueue, resolveConfig } from "@stc/core"; import { createCourseRepository } from "@stc/course-format"; import type { Db } from "@stc/db"; import { forEachDb } from "../../db/test/helpers"; @@ -18,7 +18,7 @@ const USERS: Record = { auth: { id: "auth", email: "w@x", name: "Writer", roles: ["author"], groups: [] }, }; -async function setup(db: Db, turns: Parameters[0] = [() => ({ text: "ok" })], extra: Partial = {}) { +async function setup(db: Db, turns: Parameters[0] = [() => ({ text: "ok" })], extra: Partial = {}, configOverrides: Partial = {}) { const dir = mkdtempSync(join(tmpdir(), "stc-ai-feat-")); cpSync(SAMPLE, join(dir, "courses/sample"), { recursive: true }); cpSync(SAMPLE, join(dir, "courses/secret"), { recursive: true }); @@ -29,7 +29,7 @@ async function setup(db: Db, turns: Parameters[0] = [() => const keys: (string | null)[] = []; const ctx = { db, courses, - config: { port: 0, databaseUrl: "", dataDir: join(dir, "data"), courseDirs: [], secret: "test-secret", publicUrl: "" }, + config: resolveConfig({ port: 0, databaseUrl: "", dataDir: join(dir, "data"), courseDirs: [], secret: "test-secret", publicUrl: "", ...configOverrides }), auth: { userFromRequest: async (req: Request) => USERS[req.headers.get("x-user") ?? ""] ?? null }, } as unknown as ServerContext; const feature = aiFeature(ctx, { providerFactory: (_cfg, key) => { keys.push(key); return fake.provider; }, ...extra }); @@ -159,7 +159,7 @@ forEachDb("generation is for authors and writes drafts, not course files", async // SSRF guard: a learner's own key (BYOK) may only go to the official endpoints or admin-allowed origins. forEachDb("BYOK base URLs are limited to official endpoints and allowed origins", async (db) => { - const { call } = await setup(db, undefined, { env: { STC_AI_ALLOWED_BASE_URLS: "http://ollama.internal:11434, https://llm.example.org" } }); + const { call } = await setup(db, undefined, {}, { aiAllowedBaseUrls: ["http://ollama.internal:11434", "https://llm.example.org"] }); const put = (body: unknown) => call("PUT", "/api/ai/account/key", "ann", body); // Internal or arbitrary hosts are refused for either kind. for (const baseUrl of ["http://127.0.0.1:8080/v1", "http://169.254.169.254/latest", "http://localhost:11434/v1", "https://api.anthropic.com.evil.test", "http://ollama.internal:11435/v1", "https://ollama.internal:11434/v1", "file:///etc/passwd"]) { @@ -203,7 +203,7 @@ forEachDb("provider errors on a user's own key show the status and a fixed messa seen.push({ url: String(input), redirect: init?.redirect }); return new Response(JSON.stringify({ error: { message: "INTERNAL-SECRET-BODY from upstream" } }), { status: 502, headers: { "content-type": "application/json" } }); }) as typeof fetch; - const { call } = await setup(db, undefined, { providerFactory: undefined, fetch: fakeFetch, log: (m) => logs.push(m), env: { STC_AI_ALLOWED_BASE_URLS: "http://ollama.internal:11434" } }); + const { call } = await setup(db, undefined, { providerFactory: undefined, fetch: fakeFetch, log: (m) => logs.push(m) }, { aiAllowedBaseUrls: ["http://ollama.internal:11434"] }); expect((await call("PUT", "/api/ai/account/key", "ann", { kind: "openai", model: "m", apiKey: "k", baseUrl: "http://ollama.internal:11434/v1" })).status).toBe(200); const text = await (await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" })).text(); expect(text).not.toContain("INTERNAL-SECRET-BODY"); @@ -219,22 +219,18 @@ forEachDb("provider errors on a user's own key show the status and a fixed messa expect(t2).toContain("502: INTERNAL-SECRET-BODY"); }); -forEachDb("authoring uses core canView: a global author needs a grant for a restricted course", async (db) => { +forEachDb("authoring uses core canView: the global author role reaches restricted courses (R14)", async (db) => { const { call } = await setup(db); await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] }); const body = { courseId: "secret", kind: "questions", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId: "m2-text" }] }; const courses = async (u: string) => ((await (await call("GET", "/api/ai/studio/courses", u)).json()) as { id: string }[]).map((c) => c.id).sort(); - // Public course: the author role is enough. Restricted course: not without a grant. - expect(await courses("auth")).toEqual(["sample"]); - expect((await call("GET", "/api/ai/studio/sources?courseId=secret", "auth")).status).toBe(403); - expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(403); + // The global author role views every course under core canView, so it may author the restricted one too. + expect(await courses("auth")).toEqual(["sample", "secret"]); + expect((await call("GET", "/api/ai/studio/sources?courseId=secret", "auth")).status).toBe(200); + expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(202); // The owner sees everything. expect(await courses("owner")).toEqual(["sample", "secret"]); expect((await call("POST", "/api/ai/studio/generate", "owner", body)).status).toBe(202); - // An ACL grant (core canView) lets the global author in. - await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'auth')`; - expect(await courses("auth")).toEqual(["sample", "secret"]); - expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(202); // A course_authors row makes a learner an author of that course only. expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(403); await db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'bob')`; diff --git a/packages/feature-ai/test/web.test.tsx b/packages/feature-ai/test/web.test.tsx index 683652d..231dbd3 100644 --- a/packages/feature-ai/test/web.test.tsx +++ b/packages/feature-ai/test/web.test.tsx @@ -32,7 +32,7 @@ test("markdown renders code and text without raw HTML", () => { test("web routes", () => { expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]); - expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin" }); + expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin", roles: ["owner"] }); }); test("lesson links go through one builder", async () => { @@ -44,3 +44,13 @@ test("lesson links go through one builder", async () => { expect(src).toContain("lessonUrl(courseId, c.lessonId)"); expect(src).not.toMatch(/`\/c\//); }); + +test("citation links use the learner's registered lesson route", async () => { + const { learnerWeb } = await import("@stc/feature-learner/web"); + const { matchPath } = await import("react-router-dom"); + const { lessonUrl } = await import("../src/web/urls"); + const url = lessonUrl("pure snake", "m1-ü/x"); + const hit = learnerWeb.routes.map((r) => matchPath(r.path, url)).find((m) => m && m.pattern.path === "/c/:courseId/:lessonId"); + // matchPath leaves params encoded (the router decodes the pathname before matching). + expect(Object.fromEntries(Object.entries(hit?.params ?? {}).map(([k, v]) => [k, decodeURIComponent(v!)]))).toEqual({ courseId: "pure snake", lessonId: "m1-ü/x" }); +}); diff --git a/packages/feature-import/src/context.ts b/packages/feature-import/src/context.ts index caeb3d2..d0eff37 100644 --- a/packages/feature-import/src/context.ts +++ b/packages/feature-import/src/context.ts @@ -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 { 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(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)); } diff --git a/packages/feature-import/src/index.ts b/packages/feature-import/src/index.ts index 367fe43..0262bea 100644 --- a/packages/feature-import/src/index.ts +++ b/packages/feature-import/src/index.ts @@ -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[1]) => Promise): 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[] = []; diff --git a/packages/feature-import/src/jobs/common.ts b/packages/feature-import/src/jobs/common.ts index f8158fc..e685e81 100644 --- a/packages/feature-import/src/jobs/common.ts +++ b/packages/feature-import/src/jobs/common.ts @@ -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 { 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; } diff --git a/packages/feature-import/src/jobs/transcribe.ts b/packages/feature-import/src/jobs/transcribe.ts index 7afd802..dfbafb4 100644 --- a/packages/feature-import/src/jobs/transcribe.ts +++ b/packages/feature-import/src/jobs/transcribe.ts @@ -33,7 +33,7 @@ export function segmentsToMarkdown(title: string, segments: Segment[], fallbackT */ export async function transcribeCourse(d: Deps, payload: unknown, jc: JobContext): Promise { const p = (payload ?? {}) as Record; - 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); diff --git a/packages/feature-import/src/roots.ts b/packages/feature-import/src/roots.ts index 621c96f..a33fdd0 100644 --- a/packages/feature-import/src/roots.ts +++ b/packages/feature-import/src/roots.ts @@ -5,19 +5,17 @@ import type { Deps } from "./context"; export const OUTSIDE = "path is outside the allowed import folders"; -/** STC_IMPORT_ROOTS (":"-separated), default /import-inbox. */ -export function importRoots(d: Pick): 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 /import-inbox. */ +export function importRoots(d: Pick): string[] { + return d.importRoots.map((r) => resolve(r)); } -export function ensureRoots(d: Pick): void { +export function ensureRoots(d: Pick): 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, path: string): Promise { +export async function assertAllowedPath(d: Pick, path: string): Promise { let real: string; try { real = await realpath(resolve(path)); } catch { throw new Error(OUTSIDE); } for (const root of importRoots(d)) { diff --git a/packages/feature-import/src/routes.ts b/packages/feature-import/src/routes.ts index c44570f..01384eb 100644 --- a/packages/feature-import/src/routes.ts +++ b/packages/feature-import/src/routes.ts @@ -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 = {}; 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); } diff --git a/packages/feature-import/src/web.ts b/packages/feature-import/src/web.ts index 369fa85..5a013b4 100644 --- a/packages/feature-import/src/web.ts +++ b/packages/feature-import/src/web.ts @@ -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"] } }], }; diff --git a/packages/feature-import/test/harness.ts b/packages/feature-import/test/harness.ts index ff22cc2..fe2a8ea 100644 --- a/packages/feature-import/test/harness.ts +++ b/packages/feature-import/test/harness.ts @@ -2,6 +2,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { JobContext, ServerContext } from "@stc/contracts"; import { createCourseRepository } from "@stc/course-format"; +import { resolveConfig } from "@stc/core"; import type { Deps } from "../src/context"; import { PYTHON, tmp } from "./fixtures"; @@ -13,10 +14,14 @@ export async function makeEnv(env: Record = {}): Pro const queued: TestEnv["queued"] = []; const progress: TestEnv["progress"] = []; const ctx = { - config: { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "http://x" }, + config: resolveConfig({ databaseUrl: "sqlite://:memory:", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "http://x" }), courses, jobs: { enqueue: async (kind: string, payload: unknown) => { queued.push({ kind, payload }); return "j1"; }, get: async () => null, list: async () => [] }, } as unknown as ServerContext; const jc: JobContext = { signal: new AbortController().signal, reportProgress: async (f, m) => { progress.push([f, m]); } }; - return { deps: { ctx, env: { STC_IMPORT_ROOTS: tmpdir(), ...env }, tools: { ffmpeg: "ffmpeg", ffprobe: "ffprobe" }, python: PYTHON }, dataDir, queued, jc, progress }; + const deps: Deps = { + ctx, env, tools: { ffmpeg: "ffmpeg", ffprobe: "ffprobe" }, python: PYTHON, + importRoots: (env.STC_IMPORT_ROOTS ?? tmpdir()).split(":"), transcribeUrl: env.STC_TRANSCRIBE_URL ?? null, + }; + return { deps, dataDir, queued, jc, progress }; } diff --git a/packages/feature-import/test/packt-video.test.ts b/packages/feature-import/test/packt-video.test.ts index 6ca5ab5..5d0e681 100644 --- a/packages/feature-import/test/packt-video.test.ts +++ b/packages/feature-import/test/packt-video.test.ts @@ -58,3 +58,13 @@ test("transcribe option queues the transcription job when a URL is configured", const r = await importPacktVideo(env.deps, { path: zip, options: { transcribe: true } }, env.jc); expect(env.queued).toEqual([{ kind: "import.transcribe", payload: { courseId: r.courseId } }]); }); + +test("an id the studio already uses (studio/repos/.git) is refused, and never picked automatically", async () => { + const { mkdir } = await import("node:fs/promises"); + const env = await makeEnv(); + await mkdir(join(env.deps.ctx.config.studioReposDir, "taken.git"), { recursive: true }); + const zip = await makePacktZip(await tmp()); + await expect(importPacktVideo(env.deps, { path: zip, options: { title: "X", courseId: "taken" } }, env.jc)).rejects.toThrow(/already exists/); + const r = await importPacktVideo(env.deps, { path: zip, options: { title: "Taken" } }, env.jc); + expect(r.courseId).toBe("taken-2"); +}); diff --git a/packages/feature-import/test/routes.test.ts b/packages/feature-import/test/routes.test.ts index 3d2f359..5fc3994 100644 --- a/packages/feature-import/test/routes.test.ts +++ b/packages/feature-import/test/routes.test.ts @@ -2,7 +2,7 @@ import { expect } from "bun:test"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AuthUser, ServerContext } from "@stc/contracts"; -import { createApp, createJobQueue } from "@stc/core"; +import { createApp, createJobQueue, resolveConfig } from "@stc/core"; import { createCourseRepository } from "@stc/course-format"; import { forEachDb } from "../../db/test/helpers"; import { importFeature } from "../src"; @@ -16,11 +16,16 @@ async function setup(db: any, env: Record = {}) { const dataDir = await tmp("stc-data-"); const courses = await createCourseRepository([join(dataDir, "courses")]); const ctx = { - config: { port: 0, databaseUrl: "", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "" }, + config: resolveConfig({ + databaseUrl: "", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "", + python: process.env.STC_PYTHON || "python3", + importRoots: "STC_IMPORT_ROOTS" in env ? (env.STC_IMPORT_ROOTS ? [env.STC_IMPORT_ROOTS] : undefined) : [tmpdir()], + transcribeUrl: env.STC_TRANSCRIBE_URL ?? null, + }), db, courses, auth: { userFromRequest: async (req: Request) => ({ owner, learner }[req.headers.get("x-user") ?? ""] ?? null) }, } as unknown as ServerContext; - const feature = importFeature(ctx, { env: { STC_PYTHON: process.env.STC_PYTHON, STC_IMPORT_ROOTS: tmpdir(), ...env } }); + const feature = importFeature(ctx); const queue = createJobQueue(db, feature.jobs!, { pollMs: 10 }); ctx.jobs = queue; const app = createApp(ctx, [feature]); diff --git a/packages/feature-learner/package.json b/packages/feature-learner/package.json index bf47b54..f64690f 100644 --- a/packages/feature-learner/package.json +++ b/packages/feature-learner/package.json @@ -13,7 +13,7 @@ }, "exports": { ".": "./src/index.ts", - "./web": "./web/src/index.ts" + "./web": "./src/web/index.ts" }, "devDependencies": { "@types/sanitize-html": "^2.16.2" diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts index c01208d..cad2f30 100644 --- a/packages/feature-learner/src/index.ts +++ b/packages/feature-learner/src/index.ts @@ -1,6 +1,7 @@ // Server export { migrationsDirs, runMigrations } from "./server/migrate"; export { learnerFeature } from "./server/feature"; +export { userIdentity } from "./server/identity"; // Browser (also available without the server half as "@stc/feature-learner/web") export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web"; // Shared JSON shapes diff --git a/packages/feature-learner/src/server/access.ts b/packages/feature-learner/src/server/access.ts index 64fbbec..e0a7bc9 100644 --- a/packages/feature-learner/src/server/access.ts +++ b/packages/feature-learner/src/server/access.ts @@ -30,8 +30,8 @@ export function canEdit(user: AuthUser | null, courseId: string, idx: AccessInde /** * Learners see courses with a manifest that core `canView` allows; owners also see broken ones. The author input - * is per-course authorship only, the same as core's media route, so every route applies one rule. (The global - * `author` role grants viewing only once Phase 2 extends `canView`; contract request learner §6.) + * is per-course authorship (course_authors), the same as core's media route; core `canView` itself lets the owner + * and the global `author` role view every course (R14), so every route applies one rule. */ export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean { if (!c.manifest) return isOwner(user); diff --git a/packages/feature-learner/src/server/catalog.ts b/packages/feature-learner/src/server/catalog.ts index e2395b7..3db40cf 100644 --- a/packages/feature-learner/src/server/catalog.ts +++ b/packages/feature-learner/src/server/catalog.ts @@ -7,6 +7,7 @@ import type { } from "../shared/types"; import { mediaUrl } from "../shared/types"; import { accessIndex, courseFor, isOwner, visible } from "./access"; +import { coverUrlFor } from "./covers"; import { moduleLocks } from "./gating"; import { json, notFound, param } from "./http"; @@ -93,7 +94,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] { const doneCount = lessons.filter((l) => progress[l.id]?.state === "done").length; return { id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [], - coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null, + coverUrl: coverUrlFor(ctx, m), accent: m.accent ?? null, formats: formatsOf(m), lessonCount: lessons.length, minutes, collections: m.collections ?? [], tracks: (m.tracks ?? []).map((t) => t.label), source: m.source?.kind ?? null, addedAt: addedAt(c), status: c.status, progress: lessons.length ? doneCount / lessons.length : 0, doneCount, @@ -204,7 +205,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] { })); const payload: CoursePayload = { id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [], accent: m.accent ?? null, - coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null, + coverUrl: coverUrlFor(ctx, m), tracks: m.tracks ?? [], gating: m.gating, source: m.source?.kind ?? null, status: course.status, modules, progress, canEdit: found.canEdit, signedIn: !!rc.user, diagnostics: found.canEdit ? course.diagnostics.map((d) => ({ level: d.level, lessonId: d.lessonId, path: d.path, message: d.message })) : [], diff --git a/packages/feature-learner/src/server/covers.ts b/packages/feature-learner/src/server/covers.ts new file mode 100644 index 0000000..22c2f5a --- /dev/null +++ b/packages/feature-learner/src/server/covers.ts @@ -0,0 +1,47 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Feature, ServerContext } from "@stc/contracts"; +import { cachePaths, serveMedia } from "@stc/core"; +import type { Manifest } from "@stc/course-format"; +import { mediaUrl } from "../shared/types"; +import { courseFor } from "./access"; +import { notFound } from "./http"; + +const CANDIDATE = /^(frame-\d+|deck-[A-Za-z0-9._-]+)\.jpg$/; + +/** The importer's first cover candidate (`/covers//frame-N.jpg | deck-.jpg`), if any. */ +export function coverCandidate(ctx: ServerContext, courseId: string): string | null { + let dir: string; + try { dir = cachePaths(ctx.config).coversDir(courseId); } catch { return null; } + let names: string[]; + try { names = readdirSync(dir).filter((n) => CANDIDATE.test(n)); } catch { return null; } + if (!names.length) return null; + // Prefer video frames (the importer's own first choice), then deck covers; numeric order within frames. + names.sort((a, b) => (a.startsWith("frame-") === b.startsWith("frame-") ? a.localeCompare(b, "en", { numeric: true }) : a.startsWith("frame-") ? -1 : 1)); + return join(dir, names[0]!); +} + +export const coverRoute = (courseId: string) => `/api/courses/${encodeURIComponent(courseId)}/cover`; + +/** The manifest's cover if it resolves inside the course; else the importer's cached candidate; else null. */ +export function coverUrlFor(ctx: ServerContext, m: Manifest): string | null { + if (m.cover && ctx.courses.resolvePath(m.id, m.cover)) return mediaUrl(m.id, m.cover); + return coverCandidate(ctx, m.id) ? coverRoute(m.id) : null; +} + +/** Serves the cached cover candidate to users who may view the course (core canView; 404 otherwise). */ +export function coverRoutes(ctx: ServerContext): Feature["routes"] { + return { + "/api/courses/:courseId/cover": { + GET: async (req, rc) => { + const found = await courseFor(ctx, rc.user, rc.params.courseId ?? ""); + if (!found) return notFound(); + const file = coverCandidate(ctx, found.course.id); + if (!file) return notFound(); + const res = await serveMedia(req, file); + res.headers.set("Cache-Control", "private, max-age=300"); + return res; + }, + }, + }; +} diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts index ebfe694..186e158 100644 --- a/packages/feature-learner/src/server/feature.ts +++ b/packages/feature-learner/src/server/feature.ts @@ -1,9 +1,9 @@ -import type { AuthService, Feature, RouteHandler, ServerContext } from "@stc/contracts"; +import type { AuthService, Feature, ServerContext } from "@stc/contracts"; import { createAuth } from "./auth"; -import { runMigrations } from "./migrate"; import { json } from "./http"; import { adminRoutes } from "./admin"; import { libraryRoutes } from "./catalog"; +import { coverRoutes } from "./covers"; import { lessonRoutes } from "./lesson"; import { userDataRoutes } from "./userdata"; import { codeRoutes } from "./code"; @@ -16,9 +16,8 @@ import { runnerFrameRoutes } from "./runnerFrame"; * Returns the routes plus the `AuthService` that replaces the server's anonymous one. */ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthService } { - const ready = runMigrations(ctx.db); - ready.catch((e) => console.error("[learner] migrations failed", e)); - const auth = createAuth(ctx, ready); + // The server runs this package's migrations (migrationsDirs) in core migrate() before any feature is built. + const auth = createAuth(ctx, Promise.resolve()); const routes: Feature["routes"] = { "/api/auth/*": { GET: (req, rc) => auth.handler(req, rc.user), @@ -30,6 +29,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic }, ...adminRoutes(ctx, auth.service.invalidate), ...libraryRoutes(ctx), + ...coverRoutes(ctx), ...lessonRoutes(ctx), ...userDataRoutes(ctx), ...codeRoutes(ctx), @@ -37,11 +37,5 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic ...assetRoutes(), ...runnerFrameRoutes(), }; - // Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()). - for (const methods of Object.values(routes)) { - for (const [m, h] of Object.entries(methods)) { - (methods as Record)[m] = async (req, rc) => { await ready; return h!(req, rc); }; - } - } return { name: "learner", routes, auth: auth.service }; } diff --git a/packages/feature-learner/src/server/identity.ts b/packages/feature-learner/src/server/identity.ts new file mode 100644 index 0000000..2c892a6 --- /dev/null +++ b/packages/feature-learner/src/server/identity.ts @@ -0,0 +1,9 @@ +import type { Db } from "@stc/db"; + +/** A user's display name and email (for commit authorship, e.g. the AI generator's studio drafts); null if unknown. */ +export async function userIdentity(db: Db, userId: string): Promise<{ name: string; email: string } | null> { + const rows = await db.sql`SELECT name, email FROM auth_user WHERE id = ${userId}`; + const r = rows[0] as { name: string | null; email: string } | undefined; + if (!r) return null; + return { name: r.name?.trim() || r.email, email: r.email }; +} diff --git a/packages/feature-learner/src/server/migrate.ts b/packages/feature-learner/src/server/migrate.ts index ecf343d..1dfacba 100644 --- a/packages/feature-learner/src/server/migrate.ts +++ b/packages/feature-learner/src/server/migrate.ts @@ -1,33 +1,10 @@ -import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import type { Db } from "@stc/db"; +import { migrate, type Db } from "@stc/db"; -/** Folders holding this package's migrations; Phase 2 wires these into `migrate()`. */ +/** Folders holding this package's migrations; the server passes them to core `migrate()` (contract request 2). */ export const migrationsDirs: string[] = [join(import.meta.dir, "..", "..", "migrations")]; -function statements(text: string): string[] { - const noComments = text.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n"); - return noComments.split(";").map((s) => s.trim()).filter(Boolean); -} - -/** - * Applies the learner migrations with the same bookkeeping as `@stc/db`'s `migrate()` - * (table `schema_migrations`, keyed by file name). Safe to call repeatedly. - */ -export async function runMigrations(db: Db, dirs: string[] = migrationsDirs): Promise { - await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); - const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name)); - const applied: string[] = []; - for (const dir of dirs) { - for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { - if (done.has(name)) continue; - const stmts = statements(readFileSync(join(dir, name), "utf8")); - await db.sql.begin(async (tx) => { - for (const s of stmts) await tx.unsafe(s); - await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`; - }); - applied.push(name); - } - } - return applied; +/** Core migrations plus the learner's, through core `migrate()` (tests and tools; the server migrates once at startup). */ +export function runMigrations(db: Db): Promise { + return migrate(db, { "feature-learner": migrationsDirs }); } diff --git a/packages/feature-learner/src/web/api.ts b/packages/feature-learner/src/web/api.ts index 9826e1a..034b514 100644 --- a/packages/feature-learner/src/web/api.ts +++ b/packages/feature-learner/src/web/api.ts @@ -182,8 +182,7 @@ export function fmtSize(n: number | null | undefined): string { if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } -export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`; -export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`; +export { lessonUrl as lessonHref, courseUrl as courseHref } from "@stc/contracts"; export async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); return true; } catch { diff --git a/packages/feature-learner/src/web/index.ts b/packages/feature-learner/src/web/index.ts index 4fcd26f..4144ed4 100644 --- a/packages/feature-learner/src/web/index.ts +++ b/packages/feature-learner/src/web/index.ts @@ -1,6 +1,7 @@ -import type { WebFeature } from "@stc/contracts"; +import { COURSE_ROUTE, LESSON_ROUTE, type WebFeature } from "@stc/contracts"; export { LessonView, type LessonViewProps } from "./lesson/LessonView"; +export type { LessonPayload } from "../shared/types"; export { ensureStyles as ensureLearnerStyles } from "./api"; /** Learner routes: library, course pages, lessons, notes and the account pages. */ @@ -8,9 +9,9 @@ export const learnerWeb: WebFeature = { name: "learner", routes: [ { path: "/", element: () => import("./library/LibraryPage") }, - { path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) }, + { path: COURSE_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) }, { path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) }, - { path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) }, + { path: LESSON_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) }, { path: "/account", element: () => import("./account/AccountPage") }, { path: "/account/admin", element: () => import("./account/AdminPage") }, ], diff --git a/packages/feature-learner/src/web/lesson/LessonView.tsx b/packages/feature-learner/src/web/lesson/LessonView.tsx index 2e4735b..7b1ac43 100644 --- a/packages/feature-learner/src/web/lesson/LessonView.tsx +++ b/packages/feature-learner/src/web/lesson/LessonView.tsx @@ -24,6 +24,8 @@ export interface LessonViewProps { signedIn?: boolean; /** Studio preview: no notes, no saved attempts, no progress reports. */ preview?: boolean; + /** URL for a course-relative file in the lesson text (the studio preview points these at draft files). */ + mediaUrl?: (courseRel: string) => string; onProgress?: (state: ProgressState, positionSeconds?: number) => void; /** An exercise attempt was saved (the server already updated progress). */ onSaved?: () => void; @@ -85,12 +87,12 @@ function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track ); } -function useLessonHtml(lesson: LessonPayload, track: string | null): string | null { +function useLessonHtml(lesson: LessonPayload, track: string | null, media?: (courseRel: string) => string): string | null { return useMemo(() => { const md = (track && lesson.trackMarkdown[track]) || lesson.markdown; if (!md) return null; - return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true }); - }, [lesson, track]); + return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true, mediaUrl: media }); + }, [lesson, track, media]); } function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) { @@ -109,7 +111,7 @@ function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayloa function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) { const { lesson, track } = p; - const html = useLessonHtml(lesson, track); + const html = useLessonHtml(lesson, track, p.mediaUrl); const cmd = localCommandFor(lesson.localCommand, track); const minutes = lesson.lesson.minutes; const hasMeta = !!minutes || lesson.tracks.length > 1; @@ -228,9 +230,9 @@ function useVideoProgress(v: React.RefObject, p: Lesson function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) { const { lesson, track } = p; - const html = useLessonHtml(lesson, track); + const html = useLessonHtml(lesson, track, p.mediaUrl); const hintsHtml = useMemo(() => (lesson.hints.length - ? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles }) + ? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, mediaUrl: p.mediaUrl }) : null), [lesson, track]); const cmd = localCommandFor(lesson.localCommand, track); return ( diff --git a/packages/feature-learner/src/web/markdown.ts b/packages/feature-learner/src/web/markdown.ts index fa9e8d2..d05dd7a 100644 --- a/packages/feature-learner/src/web/markdown.ts +++ b/packages/feature-learner/src/web/markdown.ts @@ -24,6 +24,8 @@ export interface RenderOptions { lessonFiles?: Record; /** Drop a leading heading (the page shows the lesson title itself). */ stripFirstHeading?: boolean; + /** URL for a course-relative file; default `/api/media//` (the studio preview serves draft files). */ + mediaUrl?: (courseRel: string) => string; } const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); @@ -226,7 +228,7 @@ export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "i if (rel === null) return null; const lessonId = kind === "link" ? opts.lessonFiles?.[rel] : undefined; if (lessonId) return { url: `/c/${encodeURIComponent(opts.courseId)}/${encodeURIComponent(lessonId)}${hash ? `#${hash}` : ""}`, external: false }; - return { url: mediaUrl(opts.courseId, rel) + (hash ? `#${hash}` : ""), external: false }; + return { url: (opts.mediaUrl ? opts.mediaUrl(rel) : mediaUrl(opts.courseId, rel)) + (hash ? `#${hash}` : ""), external: false }; } export function renderLessonHtml(md: string, opts: RenderOptions): string { diff --git a/packages/feature-learner/test/app.ts b/packages/feature-learner/test/app.ts index 9bc48b6..706573f 100644 --- a/packages/feature-learner/test/app.ts +++ b/packages/feature-learner/test/app.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import type { AppConfig, JobQueue, ServerContext } from "@stc/contracts"; -import { createApp } from "@stc/core"; +import { createApp, resolveConfig } from "@stc/core"; import { createCourseRepository } from "@stc/course-format"; import type { Db } from "@stc/db"; import { learnerFeature } from "../src/server/feature"; @@ -13,10 +13,10 @@ export const ORIGIN = "http://localhost:3000"; const fakeJobs: JobQueue = { enqueue: async () => "j", get: async () => null, list: async () => [] }; export async function makeApp(db: Db, courseDirs: string[] = [FIXTURES]) { - const config: AppConfig = { + const config: AppConfig = resolveConfig({ port: 0, databaseUrl: "", dataDir: mkdtempSync(join(tmpdir(), "stc-learner-")), courseDirs, secret: "test-secret-0123456789abcdef0123456789", publicUrl: ORIGIN, - }; + }); const ctx: ServerContext = { config, db, courses: await createCourseRepository(courseDirs), jobs: fakeJobs, auth: { userFromRequest: async () => null }, diff --git a/packages/feature-learner/test/covers.test.ts b/packages/feature-learner/test/covers.test.ts new file mode 100644 index 0000000..5ae7ee5 --- /dev/null +++ b/packages/feature-learner/test/covers.test.ts @@ -0,0 +1,40 @@ +import { expect } from "bun:test"; +import { join } from "node:path"; +import { forEachDb } from "./helpers"; +import { FIXTURES, makeApp } from "./app"; +import { extraCourses } from "./courses"; + +const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1, 0xff, 0xd9]); + +forEachDb("the library falls back to the importer's cover candidate, served only to users who can view the course", async (db) => { + const dir = await extraCourses(); + const { call, signUp, ctx } = await makeApp(db, [FIXTURES, dir]); + const covers = (id: string) => join(ctx.config.cacheDir, "covers", id); + await Bun.write(join(covers("sample"), "deck-d1.jpg"), JPEG); + await Bun.write(join(covers("sample"), "frame-2.jpg"), JPEG); + await Bun.write(join(covers("sample"), "frame-10.jpg"), new Uint8Array([1])); + await Bun.write(join(covers("secret"), "frame-1.jpg"), JPEG); + await Bun.write(join(covers("sample"), "notes.txt"), "not a cover"); + + const lib = async (cookie?: string) => ((await (await call("GET", "/api/library", { cookie })).json()) as { courses: { id: string; coverUrl: string | null }[] }).courses; + const sample = (await lib()).find((c) => c.id === "sample")!; + expect(sample.coverUrl).toBe("/api/courses/sample/cover"); + const r = await call("GET", "/api/courses/sample/cover"); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toBe("image/jpeg"); + expect(r.headers.get("x-content-type-options")).toBe("nosniff"); + expect(new Uint8Array(await r.arrayBuffer())).toEqual(JPEG); // frame-2 (frames first, numeric order), not frame-10 or the deck + expect((await (await call("GET", "/api/courses/sample")).json()).coverUrl).toBe("/api/courses/sample/cover"); + + // restricted: 404 for anonymous users and learners without access, 200 for the owner + const owner = await signUp("owner@x.test"); + const learner = await signUp("lea@x.test"); + expect((await call("GET", "/api/courses/secret/cover")).status).toBe(404); + expect((await call("GET", "/api/courses/secret/cover", { cookie: learner })).status).toBe(404); + expect((await call("GET", "/api/courses/secret/cover", { cookie: owner })).status).toBe(200); + expect((await lib(owner)).find((c) => c.id === "secret")!.coverUrl).toBe("/api/courses/secret/cover"); + // no candidate: no cover, and the route is 404 + expect((await lib(owner)).find((c) => c.id === "gated")!.coverUrl).toBeNull(); + expect((await call("GET", "/api/courses/gated/cover", { cookie: owner })).status).toBe(404); + expect((await call("GET", "/api/courses/..%2Fsample/cover", { cookie: owner })).status).toBe(404); +}); diff --git a/packages/feature-learner/test/visibility.test.ts b/packages/feature-learner/test/visibility.test.ts index 1d9ec5f..9b3aef1 100644 --- a/packages/feature-learner/test/visibility.test.ts +++ b/packages/feature-learner/test/visibility.test.ts @@ -3,12 +3,13 @@ import { forEachDb } from "./helpers"; import { FIXTURES, makeApp } from "./app"; import { extraCourses } from "./courses"; -forEachDb("one visibility rule: the global author role alone does not open a restricted course (I1)", async (db) => { +forEachDb("one visibility rule (core canView, R14): owner, the global author role and per-course authors open a restricted course; learners do not", async (db) => { const dir = await extraCourses(); const { call, signUp } = await makeApp(db, [FIXTURES, dir]); const owner = await signUp("owner@x.test"); const author = await signUp("author@x.test"); const coAuthor = await signUp("co@x.test"); + const learner = await signUp("lea@x.test"); const users = (await (await call("GET", "/api/admin/users", { cookie: owner })).json()) as { id: string; email: string }[]; const id = (e: string) => users.find((u) => u.email === e)!.id; expect((await call("PUT", `/api/admin/users/${id("author@x.test")}/roles`, { cookie: owner, body: { roles: ["author"] } })).status).toBe(200); @@ -16,12 +17,15 @@ forEachDb("one visibility rule: the global author role alone does not open a res const paths = ["/api/courses/secret", "/api/courses/secret/lessons/s1", "/api/media/secret/a.md"]; for (const p of paths) { - expect([p, (await call("GET", p, { cookie: author })).status]).toEqual([p, 404]); + expect([p, (await call("GET", p, { cookie: author })).status]).toEqual([p, 200]); expect([p, (await call("GET", p, { cookie: coAuthor })).status]).toEqual([p, 200]); + expect([p, (await call("GET", p, { cookie: learner })).status]).toEqual([p, 404]); + expect([p, (await call("GET", p)).status]).toEqual([p, 404]); } const lib = async (c: string) => ((await (await call("GET", "/api/library", { cookie: c })).json()) as { courses: { id: string }[] }).courses.map((x) => x.id); - expect(await lib(author)).not.toContain("secret"); + expect(await lib(author)).toContain("secret"); expect(await lib(coAuthor)).toContain("secret"); - const search = (await (await call("GET", "/api/library/search?q=hidden", { cookie: author })).json()) as unknown[]; + expect(await lib(learner)).not.toContain("secret"); + const search = (await (await call("GET", "/api/library/search?q=hidden", { cookie: learner })).json()) as unknown[]; expect(search).toEqual([]); }); diff --git a/packages/feature-learner/web/src/index.ts b/packages/feature-learner/web/src/index.ts deleted file mode 100644 index 4c833d1..0000000 --- a/packages/feature-learner/web/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Browser entry "@stc/feature-learner/web". The repo maps "@stc/*" to "packages/*/src", so this subpath -// resolves here (tsc and Bun alike). It re-exports the web half only: the package index also carries the -// server (Better Auth, SSO), which cannot be bundled for the browser. -export * from "../../src/web"; diff --git a/packages/feature-studio/src/server/draft-writer.ts b/packages/feature-studio/src/server/draft-writer.ts index a4100d3..ca63e5e 100644 --- a/packages/feature-studio/src/server/draft-writer.ts +++ b/packages/feature-studio/src/server/draft-writer.ts @@ -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/`, never publishes. */ @@ -16,8 +17,11 @@ export interface DraftWriterOptions { identity?: (authorId: string) => Promise | Identity | null; } -export function createDraftWriter(ctx: { config: Pick }, opts: DraftWriterOptions = {}): DraftWriter { - const store = createStudioStore({ dataDir: ctx.config.dataDir }); +export function createDraftWriter( + ctx: { config: Pick & Partial> }, + 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); diff --git a/packages/feature-studio/src/server/feature.ts b/packages/feature-studio/src/server/feature.ts index e691487..7a321ae 100644 --- a/packages/feature-studio/src/server/feature.ts +++ b/packages/feature-studio/src/server/feature.ts @@ -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 = 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 => (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); diff --git a/packages/feature-studio/src/server/paths.ts b/packages/feature-studio/src/server/paths.ts index 4f954bc..e56283a 100644 --- a/packages/feature-studio/src/server/paths.ts +++ b/packages/feature-studio/src/server/paths.ts @@ -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 * `/courses`, and studio courses through their published worktree in `/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): string[] { +export function studioCourseDirs(config: Pick & Partial>): 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 & Partial>) { + return { dataDir: config.dataDir, reposDir: config.studioReposDir, importedDir: config.importedCoursesDir, worktreesDir: config.studioWorktreesDir }; +} diff --git a/packages/feature-studio/src/server/store.ts b/packages/feature-studio/src/server/store.ts index 6d1c3fe..638822a 100644 --- a/packages/feature-studio/src/server/store.ts +++ b/packages/feature-studio/src/server/store.ts @@ -123,6 +123,12 @@ export type Actor = Identity & { id: string; owner?: boolean }; export interface StudioStoreOptions { dataDir: string; + /** AppConfig.studioReposDir (default `/studio/repos`). */ + reposDir?: string; + /** AppConfig.importedCoursesDir (default `/courses`): ids taken by the importer. */ + importedDir?: string; + /** AppConfig.studioWorktreesDir (default `/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 /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) => { diff --git a/packages/feature-studio/src/ui/editors/LessonEditor.tsx b/packages/feature-studio/src/ui/editors/LessonEditor.tsx index 0c630a6..7f5834d 100644 --- a/packages/feature-studio/src/ui/editors/LessonEditor.tsx +++ b/packages/feature-studio/src/ui/editors/LessonEditor.tsx @@ -82,7 +82,7 @@ export function LessonEditor({ ws, lessonId, onGone }: { ws: Workspace; lessonId

Preview

- ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} /> + ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
) : null} diff --git a/packages/feature-studio/src/ui/learnerPreview.tsx b/packages/feature-studio/src/ui/learnerPreview.tsx new file mode 100644 index 0000000..210c32f --- /dev/null +++ b/packages/feature-studio/src/ui/learnerPreview.tsx @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { extractSection } from "@stc/course-format/markdown"; +import { LessonView, type LessonPayload } from "@stc/feature-learner/web"; +import type { LessonPreviewProps } from "./preview"; + +/** + * The studio's live preview, rendered by the learner's real `LessonView` (contract request studio 3) in preview + * mode: no notes, attempts or progress. Draft files (images, links) resolve through the studio's raw-file route. + * Video lessons preview their transcript; exercises preview their instructions. + */ +export default function LearnerLessonPreview(props: LessonPreviewProps) { + const { courseId, markdown, lesson, file, resolveCourseUrl, resolveUrl } = props; + const payload = useMemo(() => { + const anchored = lesson?.anchor ? extractSection(markdown, lesson.anchor) : null; + const body = lesson?.anchor && anchored === null ? `> Heading anchor \`#${lesson.anchor}\` was not found in this file.\n\n${markdown}` : anchored ?? markdown; + return { + courseId, courseTitle: "", + lesson: { id: lesson?.id ?? "preview", title: lesson?.title ?? "", type: lesson?.type === "exercise" ? "exercise" : "text", minutes: null }, + module: { id: "", title: "Preview", index: 0 }, + locked: false, + markdown: body, baseFile: resolveCourseUrl ? file ?? null : null, trackMarkdown: {}, tracks: [], + video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null, + prev: null, next: null, lessonFiles: {}, + }; + }, [courseId, markdown, lesson?.id, lesson?.title, lesson?.type, lesson?.anchor, file, resolveCourseUrl]); + // With the file known, links resolve course-relative; otherwise relative to the markdown file (the v1 contract). + return
; +} diff --git a/packages/feature-studio/src/ui/preview.tsx b/packages/feature-studio/src/ui/preview.tsx index dfc4faf..0799419 100644 --- a/packages/feature-studio/src/ui/preview.tsx +++ b/packages/feature-studio/src/ui/preview.tsx @@ -1,16 +1,21 @@ -import type { ComponentType } from "react"; +import { lazy, Suspense, type ComponentType } from "react"; import { renderMarkdown } from "./markdown"; /** - * Live-preview adapter. v1 renders with a small built-in Markdown renderer; Phase 2 swaps in the learner - * package's `LessonView` via `setLessonPreview` (docs/contract-requests/studio.md). + * Live-preview adapter. The default is the learner's `LessonView` (./learnerPreview, loaded lazily so the + * lesson renderer is fetched only when an editor opens); `setLessonPreview` can replace it, and the small built-in + * Markdown renderer stays as `SimpleMarkdownPreview` and as the loading fallback. */ export interface LessonPreviewProps { courseId: string; markdown: string; lesson?: { id: string; title: string; type: string; anchor?: string } | null; - /** Maps a course-relative path (images, attachments) to a URL that serves the draft file. */ + /** Maps a path relative to the markdown file (images, attachments) to a URL that serves the draft file. */ resolveUrl: (rel: string) => string; + /** Course-relative path of the markdown file. */ + file?: string; + /** Maps a course-relative path to a URL that serves the draft file (preferred over `resolveUrl` when given). */ + resolveCourseUrl?: (courseRel: string) => string; } export type LessonPreviewComponent = ComponentType; @@ -18,11 +23,12 @@ export function SimpleMarkdownPreview({ markdown, resolveUrl }: LessonPreviewPro return
; } -let current: LessonPreviewComponent = SimpleMarkdownPreview; +const LearnerPreview = lazy(() => import("./learnerPreview")); +let current: LessonPreviewComponent = LearnerPreview; export function setLessonPreview(c: LessonPreviewComponent): void { current = c; } export function LessonPreview(props: LessonPreviewProps) { const C = current; - return ; + return }>; } diff --git a/packages/feature-studio/src/web.ts b/packages/feature-studio/src/web.ts index d60a5a4..8134982 100644 --- a/packages/feature-studio/src/web.ts +++ b/packages/feature-studio/src/web.ts @@ -4,7 +4,7 @@ import type { WebFeature } from "@stc/contracts"; export const studioWeb: WebFeature = { name: "studio", routes: [ - { path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header" } }, + { path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header", roles: ["owner", "author"] } }, { path: "/studio/:courseId", element: () => import("./ui/CourseStudio") }, ], }; diff --git a/packages/feature-studio/test/app-helpers.ts b/packages/feature-studio/test/app-helpers.ts index e7960af..592bbca 100644 --- a/packages/feature-studio/test/app-helpers.ts +++ b/packages/feature-studio/test/app-helpers.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; -import type { AuthUser, ServerContext } from "@stc/contracts"; -import { createApp } from "@stc/core"; +import type { AppConfig, AuthUser, ServerContext } from "@stc/contracts"; +import { createApp, resolveConfig } from "@stc/core"; import { createCourseRepository } from "@stc/course-format"; import { connect, migrate } from "@stc/db"; import { studioFeature } from "../src/server/feature"; @@ -15,7 +15,7 @@ export const users: Record = { }; /** A server context like main.ts builds, with header-selected users (`x-user: ada`). */ -export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void) { +export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial = {}) { const dataDir = tempDir(); prepare?.(dataDir); let dropSchema = async () => {}; @@ -31,7 +31,7 @@ export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite:/ } const db = await connect(dbUrl); await migrate(db); - const config = { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x" }; + const config = resolveConfig({ port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x", ...configOverrides }); const courses = await createCourseRepository(studioCourseDirs(config)); const ctx = { config, db, courses, jobs: null as never, diff --git a/packages/feature-studio/test/preview.test.tsx b/packages/feature-studio/test/preview.test.tsx new file mode 100644 index 0000000..2333f0d --- /dev/null +++ b/packages/feature-studio/test/preview.test.tsx @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { renderToString } from "react-dom/server"; +import LearnerLessonPreview from "../src/ui/learnerPreview"; + +const md = "# Intro\n\nHello ![pic](img/a.png)\n\n## Part two\n\nOnly this part.\n\n## Part three\n\nNot this.\n"; +const draft = (rel: string) => `/api/studio/courses/c1/raw/${rel}`; + +test("the studio preview renders through the learner LessonView, with draft URLs", () => { + const html = renderToString( "/wrong"} />); + expect(html).toContain('class="lr lr-lesson"'); + expect(html).toContain("Lesson one"); + expect(html).toContain('src="/api/studio/courses/c1/raw/lessons/img/a.png"'); + expect(html).not.toContain("/api/media/"); +}); + +test("an anchored lesson previews only its section; a missing anchor says so", () => { + const one = renderToString(); + expect(one).toContain("Only this"); + expect(one).not.toContain("Not this"); + const missing = renderToString(); + expect(missing).toContain("was not found"); +}); + +test("raw HTML in a draft is sanitised by the learner pipeline", () => { + const html = renderToString("} lesson={{ id: "l1", title: "T", type: "text" }} file="a.md" resolveCourseUrl={draft} resolveUrl={draft} />); + expect(html).not.toContain("onerror"); + expect(html).not.toContain("