diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e036ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.worktrees +.superpowers +node_modules +**/node_modules +data +reports +research_notes +docs +**/test +fixtures +*.log +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..932e7dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# STCourses: the server and the SPA in one image. AGPL-3.0-only. +# Build: docker build -t stc-app . +# Run: see docker-compose.yml and docs/operations.md +FROM oven/bun:1.3 + +# Tools the importer and the studio call: ffmpeg/ffprobe (frames, durations), git (studio repos, code imports), +# unzip (Packt and code zips), python3 with PyMuPDF (PDF page images and text; STC_PYTHON points at it). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg git unzip ca-certificates python3 python3-pymupdf \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -c "import pymupdf" + +WORKDIR /app + +# Dependencies first (cached unless a manifest or the lockfile changes). +COPY package.json bun.lock ./ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +COPY packages/contracts/package.json packages/contracts/ +COPY packages/core/package.json packages/core/ +COPY packages/course-format/package.json packages/course-format/ +COPY packages/db/package.json packages/db/ +COPY packages/ui/package.json packages/ui/ +COPY packages/feature-learner/package.json packages/feature-learner/ +COPY packages/feature-import/package.json packages/feature-import/ +COPY packages/feature-studio/package.json packages/feature-studio/ +COPY packages/feature-ai/package.json packages/feature-ai/ +RUN bun install --production --frozen-lockfile + +# Sources (Bun runs TypeScript directly; tsconfig.json carries the @stc/* path mapping). +COPY tsconfig.json ./ +COPY apps apps +COPY packages packages +COPY overlays overlays + +# /data holds the database (SQLite), imported courses, studio repos and caches. /courses is for course repos you mount. +RUN mkdir -p /data /courses && chown bun:bun /data +VOLUME /data + +ENV NODE_ENV=production \ + STC_PORT=3000 \ + STC_DATA_DIR=/data \ + STC_DATABASE_URL=sqlite:///data/stcourses.db \ + STC_COURSE_DIRS=/app/overlays:/courses \ + STC_IMPORT_ROOTS=/import \ + STC_PYTHON=python3 + +USER bun +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD bun -e "fetch('http://127.0.0.1:' + (process.env.STC_PORT || 3000) + '/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +CMD ["bun", "apps/server/src/main.ts"] diff --git a/README.md b/README.md index 8a91f53..b738535 100644 --- a/README.md +++ b/README.md @@ -11,4 +11,9 @@ bun run typecheck # tsc --noEmit bun run dev # start the server with hot reload ``` +## Running it + +`STC_SECRET=... docker compose up -d` (add `--profile postgres` and `STC_DATABASE_URL` for Postgres). Mounts, +configuration and the measured memory budget are in [docs/operations.md](docs/operations.md). + License: AGPL-3.0-only. diff --git a/apps/server/src/features.ts b/apps/server/src/features.ts new file mode 100644 index 0000000..c9a521a --- /dev/null +++ b/apps/server/src/features.ts @@ -0,0 +1,53 @@ +import type { FeatureFactory, ServerContext } from "@stc/contracts"; +import type { PackageMigrations } from "@stc/db"; +import { aiFeature, createFsDraftSink, migrationsDirs as aiMigrations, type AiFeatureOptions, 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; + /** Test seam: replaces AI provider construction (e.g. a canned provider). Production leaves it unset. */ + aiProviderFactory?: AiFeatureOptions["providerFactory"]; +} + +/** + * The product: learner (auth, library, reader), importer, studio and AI, in that order. + * Order matters: the AI factory needs the studio feature (its draft sink writes through the studio store), so the + * studio factory must run first. composeServer runs factories in list order; the AI factory checks this. + */ +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) => { + if (!studio) throw new Error("productFeatures: the studio factory must run before the AI factory"); + return aiFeature(ctx, { draftSink: aiDraftSink(ctx, studio), ...(opts.aiProviderFactory ? { providerFactory: opts.aiProviderFactory } : {}) }); + }, + ]; +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 9b43b71..ad5e6e4 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,44 +1,43 @@ -import type { AppConfig, AuthService, Feature, JobHandler, ServerContext } from "@stc/contracts"; -import { createApp, createJobQueue, loadConfig } from "@stc/core"; -import { createCourseRepository } from "@stc/course-format"; -import { connect, migrate } from "@stc/db"; +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"; -// Temporary: the learner package replaces this with real sessions. -const anonymousAuth: AuthService = { userFromRequest: async () => null }; +export interface StartOptions extends ComposeOptions {} -export async function startServer( - config: AppConfig, - features: Feature[], - _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 = {}; - for (const f of features) Object.assign(handlers, f.jobs); - const jobs = createJobQueue(db, handlers); - const ctx: ServerContext = { config, db, courses, jobs, auth: anonymousAuth }; - const app = createApp(ctx, features); - 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), - }); +/** + * 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; + } return { server, + ctx: composed.ctx, + features: composed.features, async stop() { await server.stop(true); - await jobs.stop(); - await db.close(); + await composed.stop(); }, }; } if (import.meta.main) { const config = loadConfig(); - const { server } = await startServer(config, []); + 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..5cccb3a --- /dev/null +++ b/apps/server/test/e2e.test.ts @@ -0,0 +1,218 @@ +// End-to-end (plan Task I3): the real server with every feature, wired exactly as in production +// (`productFeatures()` order and `packageMigrations`), against the fixtures plus a restricted copy, on SQLite and +// Postgres. It is also the visibility and cross-site regression suite for the composed server. +import { describe, expect, test } from "bun:test"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { AiProvider } from "@stc/contracts"; +import { loadConfig } from "@stc/core"; +import { createBrowserRunner } from "../../../packages/feature-learner/src/web/runner/runner"; +import { packageMigrations, productFeatures } from "../src/features"; +import { startServer } from "../src/main"; +import { PG, pgSchema } from "./pg"; + +const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses"); +const SAMPLE = join(FIXTURES, "sample"); +const ORIGIN = "http://localhost:3000"; +const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1, 0xff, 0xd9]); + +const cookieFrom = (r: Response) => r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; "); + +/** A canned AI provider: chat answers with text; generation answers with one file block. */ +const fakeAi: AiProvider = { + id: "fake", + async *chat(messages) { + const sys = messages[0]?.content ?? ""; + if (sys.includes("=== FILE:")) yield { text: "=== FILE: lessons/generated.md ===\n# Generated\n\nA draft.\n=== END FILE ===\n" }; + else yield { text: "An answer." }; + }, +}; + +/** A restricted copy of the sample course ("secret", titled "Hidden Vault"). */ +function restrictedCourseDir(): string { + const dir = mkdtempSync(join(tmpdir(), "stc-e2e-courses-")); + cpSync(SAMPLE, join(dir, "secret"), { recursive: true }); + const m = readFileSync(join(SAMPLE, "stcourse.yaml"), "utf8"); + writeFileSync(join(dir, "secret/stcourse.yaml"), m.replace("id: sample", "id: secret").replace("title: Sample course", "title: Hidden Vault").replace("visibility: public", "visibility: restricted")); + return dir; +} + +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}:${restrictedCourseDir()}`, STC_PUBLIC_URL: ORIGIN, + }); + // The importer's cover candidate for the restricted course. + mkdirSync(join(config.cacheDir, "covers", "secret"), { recursive: true }); + writeFileSync(join(config.cacheDir, "covers", "secret", "frame-1.jpg"), JPEG); + const s = await startServer(config, productFeatures({ studioWatch: false, aiProviderFactory: () => fakeAi }), { migrations: packageMigrations }); + const base = s.server.url.toString().replace(/\/$/, ""); + type Opts = { cookie?: string; body?: unknown; headers?: Record; origin?: string | null }; + const call = async (method: string, path: string, opts: Opts = {}) => { + const headers: Record = { ...opts.headers }; + if (opts.origin !== null) headers.origin = opts.origin ?? ORIGIN; + if (opts.cookie) headers.cookie = opts.cookie; + if (opts.body !== undefined) headers["content-type"] ??= "application/json"; + const body = opts.body === undefined ? undefined : typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body); + return fetch(base + path, { method, headers, body }); + }; + const json = async (method: string, path: string, opts: Opts = {}) => (await call(method, path, opts)).json() as Promise; + 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); + }; + const until = async (f: () => Promise) => { for (let i = 0; i < 400; i++) { if (await f()) return; await Bun.sleep(25); } throw new Error("timeout"); }; + + try { + // 1. the server is up with every feature, the fixture course and the restricted copy + expect(await json("GET", "/api/health")).toEqual({ ok: true, courses: 2, db: databaseUrl.startsWith("postgres") ? "postgres" : "sqlite" }); + expect((await call("GET", "/c/sample")).headers.get("content-type")).toContain("text/html"); + expect(s.features.map((f) => f.name)).toEqual(["learner", "import", "studio", "ai"]); + + // 2. sign up: the first account is the owner + const owner = await signUp("ada@example.org", "Ada Lovelace"); + const me = (await json("GET", "/api/me", { cookie: owner })) as { user: { id: string; roles: string[] } }; + expect(me.user.roles).toEqual(["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, and a bodyless DELETE over the wire + 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); + const scratch = (await json("POST", "/api/courses/sample/notes", { cookie: owner, body: { lessonId: "m1-intro", body: "scratch" } })) as { id: string }; + expect((await call("DELETE", `/api/notes/${scratch.id}`, { cookie: owner })).status).toBe(200); + + // 5. run the sample exercise with the learner's runner, then save the passing attempt + const lesson = (await json("GET", "/api/courses/sample/lessons/m2-ex-add", { cookie: owner })) as { browser: { starter: string; tests: string } }; + const runner = createBrowserRunner("js"); + expect((await runner.run(lesson.browser.starter, lesson.browser.tests, 5000)).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 json("GET", "/api/courses/sample/progress", { cookie: owner })) as Record; + expect(progress["m1-intro"]?.state).toBe("done"); + expect(progress["m2-ex-add"]?.state).toBe("done"); + const notes = (await json("GET", "/api/courses/sample/notes", { cookie: owner })) 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 json("GET", "/api/courses/sample/progress", { cookie: lea })).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); + + // 8. media: a real Range request gives 206 with the right bytes; nosniff everywhere + const whole = new Uint8Array(readFileSync(join(SAMPLE, "media/clip.mp4"))); + const part = await call("GET", "/api/media/sample/media/clip.mp4", { cookie: lea, headers: { Range: "bytes=0-9" } }); + expect(part.status).toBe(206); + expect(part.headers.get("content-range")).toBe(`bytes 0-9/${whole.length}`); + expect(new Uint8Array(await part.arrayBuffer())).toEqual(whole.slice(0, 10)); + expect(part.headers.get("x-content-type-options")).toBe("nosniff"); + expect((await call("GET", "/api/media/sample/media/clip.mp4", { headers: { Range: `bytes=${whole.length}-` } })).status).toBe(416); + + // 9. AI on (canned provider), so chat and retrieval can be checked + expect((await call("PUT", "/api/ai/admin/config", { cookie: owner, body: { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] } })).status).toBe(200); + + // 10. the restricted course: the owner gets everything; a learner and anonymous users learn nothing + const restricted: [string, string, unknown?][] = [ + ["GET", "/api/courses/secret"], + ["GET", "/api/courses/secret/lessons/m1-intro"], + ["GET", "/api/media/secret/lessons/01-intro.md"], + ["GET", "/api/courses/secret/cover"], + ["GET", "/api/courses/secret/code/m1"], + ["GET", "/api/courses/secret/code/m1/zip"], + ["GET", "/api/courses/secret/notes"], + ["GET", "/api/courses/secret/progress"], + ["PUT", "/api/courses/secret/progress/m1-intro", { state: "done" }], + ["POST", "/api/courses/secret/notes", { lessonId: "m1-intro", body: "x" }], + ["PUT", "/api/courses/secret/lessons/m2-ex-add/attempt", { code: "x", passed: false }], + ["POST", "/api/ai/courses/secret/chat", { message: "What is in this course?" }], + ]; + for (const [method, path, body] of restricted) { + const asOwner = await call(method, path, { cookie: owner, body }); + expect([method, path, asOwner.status >= 200 && asOwner.status < 300]).toEqual([method, path, true]); + await asOwner.arrayBuffer(); + const asLearner = await call(method, path, { cookie: lea, body }); + expect([method, path, asLearner.status]).toEqual([method, path, 404]); + // Anonymous: 404 for content; 401 ("sign in first") on per-user routes, which answer the same for every course id. + const anon = await call(method, path, { body }); + expect([method, path, [401, 404].includes(anon.status)]).toEqual([method, path, true]); + const text = await anon.text(); + expect(text).not.toContain("Hidden Vault"); + } + const ids = async (cookie?: string) => ((await json("GET", "/api/library", { cookie })) as { courses: { id: string }[] }).courses.map((c) => c.id).sort(); + expect(await ids(owner)).toEqual(["sample", "secret"]); + expect(await ids(lea)).toEqual(["sample"]); + expect(await ids()).toEqual(["sample"]); + const search = async (cookie?: string) => JSON.stringify(await json("GET", "/api/library/search?q=vault", { cookie })); + expect(await search(owner)).toContain("secret"); + expect(await search(lea)).toBe("[]"); + expect(await search()).toBe("[]"); + // AI retrieval: the course is unknown to the learner's assistant status, known to the owner's + expect((await json("GET", "/api/ai/status?courseId=secret", { cookie: owner })).course?.id).toBe("secret"); + expect((await json("GET", "/api/ai/status?courseId=secret", { cookie: lea })).course).toBeUndefined(); + expect((await json("GET", "/api/ai/status?courseId=secret")).course).toBeUndefined(); + + // 11. cross-site requests (CSRF): the owner's cookie is not enough + const pwned = join(dataDir, "PWNED"); + const mcp = { transport: "stdio", command: "sh", args: ["-c", `touch ${pwned}`] }; + const forged = await call("POST", "/api/ai/admin/mcp/test", { cookie: owner, origin: "http://evil.example", headers: { "content-type": "text/plain" }, body: JSON.stringify(mcp) }); + expect(forged.status).toBe(403); + const forgedJson = await call("POST", "/api/ai/admin/mcp/test", { cookie: owner, origin: "http://evil.example", body: mcp }); + expect(forgedJson.status).toBe(403); + const noOrigin = await call("POST", "/api/ai/admin/mcp/test", { cookie: owner, origin: null, body: mcp }); + expect(noOrigin.status).toBe(403); + await Bun.sleep(200); + expect(existsSync(pwned)).toBe(false); + expect((await call("POST", "/api/courses/sample/notes", { cookie: owner, origin: "http://evil.example", headers: { "content-type": "text/plain" }, body: JSON.stringify({ lessonId: "m1-intro", body: "forged" }) })).status).toBe(403); + expect((await call("POST", "/api/courses/sample/notes", { cookie: owner, origin: null, headers: { referer: `${ORIGIN}/c/sample/m1-intro` }, body: { lessonId: "m1-intro", body: "via referer" } })).status).toBe(201); + expect((await call("POST", "/api/auth/sign-in/email", { origin: "http://evil.example", body: { email: "ada@example.org", password: "correct horse battery" } })).status).toBe(403); + expect((await json("GET", "/api/courses/sample/notes", { cookie: owner })).map((n: { body: string }) => n.body)).not.toContain("forged"); + + // 12. AI drafts through the real wiring: a studio course gets a commit on the author's draft branch, as the + // author; a course the studio does not manage gets files under /drafts + expect((await call("POST", "/api/studio/courses", { cookie: owner, body: { id: "gen-target", title: "Generated" } })).status).toBe(201); + const generate = async (courseId: string, lessonId: string) => { + const r = await call("POST", "/api/ai/studio/generate", { cookie: owner, body: { courseId, kind: "document", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId }] } }); + expect(r.status).toBe(202); + const { jobId } = (await r.json()) as { jobId: string }; + let job: any = null; + await until(async () => { job = await json("GET", `/api/ai/studio/jobs/${jobId}`, { cookie: owner }); return job.status === "done" || job.status === "failed"; }); + expect([job.status, job.error]).toEqual(["done", null]); + return job.result as { branch?: string; location: string; paths: string[] }; + }; + const onStudio = await generate("gen-target", "welcome"); + expect(onStudio.branch).toMatch(/^draft\//); + const repo = join(config.studioReposDir, "gen-target.git"); + expect((await Bun.$`git --git-dir=${repo} log -1 ${"--format=%an <%ae>"} ${onStudio.branch!}`.text()).trim()).toBe("Ada Lovelace "); + expect(await Bun.$`git --git-dir=${repo} show ${`${onStudio.branch}:lessons/generated.md`}`.text()).toContain("# Generated"); + const onFiles = await generate("sample", "m1-intro"); + expect(onFiles.branch).toBeUndefined(); + expect(onFiles.location.startsWith(resolve(dataDir, "drafts", "sample"))).toBe(true); + expect(readFileSync(join(onFiles.location, "lessons/generated.md"), "utf8")).toContain("# Generated"); + } finally { + await s.stop(); + } +} + +describe("end to end", () => { + test("sign up, mark done, slide note, exercise, progress, visibility, CSRF, AI drafts [sqlite]", () => run("sqlite://:memory:"), 90_000); + const pg = PG; + if (pg) { + test("sign up, mark done, slide note, exercise, progress, visibility, CSRF, AI drafts [postgres]", async () => { + const { url, drop } = await pgSchema(pg); + try { await run(url); } finally { await drop(); } + }, 90_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/index.html b/apps/web/index.html index 2d79c9a..cd1895c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,10 @@ + + STCourses 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 8b077c9..076a7bd 100644 --- a/apps/web/src/features.ts +++ b/apps/web/src/features.ts @@ -1,4 +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 "@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[] = []; +export const webFeatures: WebFeature[] = [learnerWeb, importWeb, studioWeb, aiWeb]; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a9afe98..12bec53 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,21 +1,41 @@ 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, NavLink, 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 - + {nav.length ? ( + + ) : null} ); return ( 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/bun.lock b/bun.lock index 15e30fe..9b52941 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,16 @@ "packages/feature-learner": { "name": "@stc/feature-learner", "version": "0.0.0", + "dependencies": { + "@better-auth/sso": "^1.7.14", + "better-auth": "^1.7.14", + "marked": "^18.1.2", + "pdfjs-dist": "^6.4.299", + "sanitize-html": "^2", + }, + "devDependencies": { + "@types/sanitize-html": "^2.16.2", + }, }, "packages/feature-studio": { "name": "@stc/feature-studio", @@ -68,14 +78,68 @@ }, }, "packages": { + "@authenio/xml-encryption": ["@authenio/xml-encryption@6.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.13", "escape-html": "^1.0.3" } }, "sha512-/jv5qO935fH4Ma+fxh5rJLRYZvUM57fd/I74IiSoR8cFm+sgjszXFEEn1PkqZhhBD4MVkH2YT+/UDY+pnW0S0Q=="], + + "@better-auth/core": ["@better-auth/core@1.7.14", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.4", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ByhdrGVwp/el+hoTYKSVOHIH23/lQj9TQ1txdGcWOg22tnfr3hJ6ukzySooeYj/R36XsQgBhkJqIp8mKsNeErA=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-5RC8Ec5Ur15FWsS4As9/VrlRbB3+9EfShxIpA5A1FcD6IgPxFvzQNkAm2+FYx9LUSqx47NPf9hBTG2GUCMP9Ig=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-4wkqj8a8JVpWDYLlgzfbk7eOaWCFkL6HHQd6CLamFKaC8vNsXew1x4v5dE5O6dGr8j4g5qMISS+BAvOuIvBevg=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0" } }, "sha512-Ojv9BcmFyHuTHha9ZM/NUW1ut9J84wLngta+07Es0Ejn8kekqP5aHBpZd5Z8AvADs5iJUOWvQGFmGlPWAKJgBg=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-6IA0RSPZFMdRwwnLZ9oWnDIiD0y0o0azTHUfa01BNU2AFJgMDkeiASUV3bKcJBskzQoyQrff74eFYTsTSeeVUQ=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mF6t5xqeZNWzUe0ErmrSdzQmtslJscmu9X8Mp5QmROlpnnAzvSc70RILu8lHms8CePt7N34+KRH1QvYjGFrVGQ=="], + + "@better-auth/sso": ["@better-auth/sso@1.7.14", "", { "dependencies": { "@xmldom/xmldom": "^0.9.12", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.14", "better-call": "1.4.4" } }, "sha512-sfUzpm6GO5l5MSUA17qRO/E4INthqF+KRmymtj4Hhm+Xsx6CoRgr0BVIDfAic3tSqVhObcVYiiiZ38dXXhkCCw=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3" } }, "sha512-PtKFzsH7gje/WJuUPDjckOoNFazsq8NMt3AdiXIPzHl7tZULcNGTKAX8K7+yn0rG6cO/4mtMgpOjhtilElOnmg=="], + + "@better-auth/utils": ["@better-auth/utils@0.5.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.3", "", {}, "sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg=="], + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.3.0", "", {}, "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q=="], "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.3.0", "", {}, "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w=="], "@fontsource/literata": ["@fontsource/literata@5.3.0", "", {}, "sha512-9JGMT5+YfBK49thoMq9ASaMRJErHT4V4QuMhAEwKHRLreOsIMP2xP6ZRLhPJ0e6YlET+kjWTJuN7LAxt9/gWGQ=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="], + + "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], + + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + + "@nodable/entities": ["@nodable/entities@3.1.0", "", {}, "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ=="], + "@remix-run/router": ["@remix-run/router@1.23.4", "", {}, "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stc/contracts": ["@stc/contracts@workspace:packages/contracts"], "@stc/core": ["@stc/core@workspace:packages/core"], @@ -104,10 +168,72 @@ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + "@types/sanitize-html": ["@types/sanitize-html@2.16.2", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="], + + "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], + + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + + "better-auth": ["better-auth@1.7.14", "", { "dependencies": { "@better-auth/core": "1.7.14", "@better-auth/drizzle-adapter": "1.7.14", "@better-auth/kysely-adapter": "1.7.14", "@better-auth/memory-adapter": "1.7.14", "@better-auth/mongo-adapter": "1.7.14", "@better-auth/prisma-adapter": "1.7.14", "@better-auth/telemetry": "1.7.14", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.4", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0 || ^3.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-QxDCI0pOCCHDYmruisKhKyrvOJ6Icv9xg0RA6ZUysfKFXENYwhNNpwUlkE/NmViH2nDMgkKcymmLr55pnpR9vw=="], + + "better-call": ["better-call@1.4.4", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw=="], + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], + + "domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], + + "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.2", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="], + + "htmlparser2": ["htmlparser2@12.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="], + + "is-plain-object": ["is-plain-object@5.1.0", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="], + + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + + "jose": ["jose@6.2.14", "", {}, "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A=="], + + "kysely": ["kysely@0.29.7", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="], + + "launder": ["launder@1.7.2", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="], + + "marked": ["marked@18.1.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "nanostores": ["nanostores@1.5.5", "", {}, "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA=="], + + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], + + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + + "pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], @@ -116,14 +242,46 @@ "react-router-dom": ["react-router-dom@6.30.6", "", { "dependencies": { "@remix-run/router": "1.23.4", "react-router": "6.30.6" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ=="], + "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], + + "samlify": ["samlify@2.15.1", "", { "dependencies": { "@authenio/xml-encryption": "^6.0.2", "@xmldom/xmldom": "^0.9.12", "xml": "^1.0.1", "xml-crypto": "^6.4.0", "xml-escape": "^1.1.0", "xpath": "^0.0.35" } }, "sha512-peZitMO2cAnf0pY45H0yw0Drqmby7U91yRovH3AH8TdwYCNRnmidVORXIjcK+21cBk3Mjl8EVz8Nvuw/v20rAw=="], + + "sanitize-html": ["sanitize-html@2.18.1", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.1.0", "launder": "^1.7.2", "parse-srcset": "^1.0.2", "postcss": "^8.5.27" } }, "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "set-cookie-parser": ["set-cookie-parser@3.2.1", "", {}, "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "strnum": ["strnum@2.5.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="], + + "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], + + "tldts-core": ["tldts-core@7.4.20", "", {}, "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], + + "xml-crypto": ["xml-crypto@6.4.0", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.15", "xpath": "^0.0.33" } }, "sha512-d21ModhBO/+QWWAmOjyDeQJuHNmsR/HtOxyBchiTbKfGNKuHYwQNd+SlLhwyyrwoAGLp4Qc6BQ7nAtnM47dHSQ=="], + + "xml-escape": ["xml-escape@1.1.0", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="], + + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + + "xpath": ["xpath@0.0.35", "", {}, "sha512-8O+m2N04SwLpGw/1Grz2HD8ejnWDV7M1dMPgKKuOjhYh5W9H4pel/+06kInohIdpS2FeSbL4i8ID8c4tpxwq9Q=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "xml-crypto/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], + + "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..071b70b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,72 @@ +# STCourses with Docker Compose. Project name "stc", so containers are stc-app-1 and stc-postgres-1. +# +# SQLite (default): STC_SECRET=... docker compose up -d +# Postgres: STC_SECRET=... STC_DATABASE_URL=postgres://stc:stc@postgres:5432/stc \ +# docker compose --profile postgres up -d +# +# Course repos go in ./courses (or set STC_COURSES_HOST); files to import go in ./import-inbox (or STC_IMPORT_HOST). +# Both are mounted read-only: originals are never changed. See docs/operations.md. +name: stc + +services: + app: + build: . + image: stc-app + restart: unless-stopped + ports: + - "${STC_HOST_PORT:-3000}:3000" + environment: + STC_SECRET: ${STC_SECRET:?set STC_SECRET to a long random string} + STC_PUBLIC_URL: ${STC_PUBLIC_URL:-http://localhost:3000} + STC_DATABASE_URL: ${STC_DATABASE_URL:-sqlite:///data/stcourses.db} + STC_UPLOAD_MAX_MB: ${STC_UPLOAD_MAX_MB:-100} + STC_AI_ALLOWED_BASE_URLS: ${STC_AI_ALLOWED_BASE_URLS:-} + STC_TRANSCRIBE_URL: ${STC_TRANSCRIBE_URL:-} + STC_TRANSCRIBE_KEY: ${STC_TRANSCRIBE_KEY:-} + STC_TRANSCRIBE_MODEL: ${STC_TRANSCRIBE_MODEL:-} + STC_ALLOWED_ORIGINS: ${STC_ALLOWED_ORIGINS:-} + # Sign-in providers (unset ones stay off). Social: STC_AUTH__ID/_SECRET. + STC_AUTH_GITHUB_ID: ${STC_AUTH_GITHUB_ID:-} + STC_AUTH_GITHUB_SECRET: ${STC_AUTH_GITHUB_SECRET:-} + STC_AUTH_GOOGLE_ID: ${STC_AUTH_GOOGLE_ID:-} + STC_AUTH_GOOGLE_SECRET: ${STC_AUTH_GOOGLE_SECRET:-} + STC_AUTH_MICROSOFT_ID: ${STC_AUTH_MICROSOFT_ID:-} + STC_AUTH_MICROSOFT_SECRET: ${STC_AUTH_MICROSOFT_SECRET:-} + STC_AUTH_GITLAB_ID: ${STC_AUTH_GITLAB_ID:-} + STC_AUTH_GITLAB_SECRET: ${STC_AUTH_GITLAB_SECRET:-} + STC_AUTH_DISCORD_ID: ${STC_AUTH_DISCORD_ID:-} + STC_AUTH_DISCORD_SECRET: ${STC_AUTH_DISCORD_SECRET:-} + STC_AUTH_OIDC_ISSUER: ${STC_AUTH_OIDC_ISSUER:-} + STC_AUTH_OIDC_ID: ${STC_AUTH_OIDC_ID:-} + STC_AUTH_OIDC_SECRET: ${STC_AUTH_OIDC_SECRET:-} + STC_AUTH_OIDC_NAME: ${STC_AUTH_OIDC_NAME:-} + STC_AUTH_OIDC_SCOPES: ${STC_AUTH_OIDC_SCOPES:-} + STC_AUTH_OIDC_DISCOVERY_URL: ${STC_AUTH_OIDC_DISCOVERY_URL:-} + STC_AUTH_GROUPS_CLAIM: ${STC_AUTH_GROUPS_CLAIM:-} + depends_on: + postgres: + condition: service_healthy + required: false # only with --profile postgres + volumes: + - stc-data:/data + - ${STC_COURSES_HOST:-./courses}:/courses:ro + - ${STC_IMPORT_HOST:-./import-inbox}:/import:ro + + postgres: + image: postgres:17 + profiles: ["postgres"] + restart: unless-stopped + environment: + POSTGRES_USER: stc + POSTGRES_PASSWORD: ${STC_PG_PASSWORD:-stc} + POSTGRES_DB: stc + volumes: + - stc-pg:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U stc -d stc"] + interval: 5s + retries: 20 + +volumes: + stc-data: + stc-pg: diff --git a/docs/contract-requests/ai.md b/docs/contract-requests/ai.md new file mode 100644 index 0000000..c29e6af --- /dev/null +++ b/docs/contract-requests/ai.md @@ -0,0 +1,81 @@ +# Contract requests from WP-D (AI) + +Phase 1 worked around each of these inside `packages/feature-ai`. Phase 2 decides. + +## 1. Feature factories in `startServer` (apps/server) + +`aiFeature(ctx, opts?)` needs the `ServerContext` (db, courses, jobs, auth, config), but `startServer(config, features)` builds the +context itself and takes ready-made `Feature` objects. So `apps/server/src/main.ts` is not wired yet (the web side is: +one line in `apps/web/src/features.ts`). Proposal: `startServer(config, factories: ((ctx: ServerContext) => Feature)[])`, +then append `(ctx) => aiFeature(ctx, { draftSink: createDraftWriter(ctx) })`. Jobs must be registered after the +factories run (the job handler `ai.generate` lives on the returned feature). +Local workaround: `packages/feature-ai/scripts/dev.ts` builds the context and composes the feature for the dev server on port 3464. + +## 2. Studio `DraftWriter` as the generator's sink (no adapter needed) + +The studio exports `createDraftWriter(ctx, { identity? })` whose `writeDraftFiles(courseId, authorId, files, message)` +returns `{ branch, commit }` and commits only to `draft/`. The AI package's `DraftSink` has the same method +and returns `DraftWriteResult = { location?, branch?, commit? }`, so the studio writer is assignable as is: + +```ts +aiFeature(ctx, { draftSink: createDraftWriter(ctx, { identity: (id) => /* user name + email */ null }) }) +``` + +`generateDraft` then reports `location: "draft/ @ "` plus `branch` and `commit`; the generate page +says "Committed to your draft branch". Test: `test/generator.test.ts` ("a studio DraftWriter is a DraftSink as is"). +Open point: the studio writer throws 404 for courses it does not manage (overlay courses such as Pure Snake). Phase 2 +can wrap it to fall back to `createFsDraftSink(dataDir)` (drafts under `/drafts///`), or make +generation for unmanaged courses an explicit error. Until wired, the filesystem sink is the default. + +## 3. Tool-call fields on `ChatMessage` + +`ChatMessage` has `toolCallId` and `name` but no structured slot for an assistant turn with several tool calls or with +thinking blocks. The package encodes one tool call per assistant message (`content` = JSON input) and the Anthropic +provider keeps each response's raw content blocks (thinking with signature, text, tool_use) in memory by tool_use id +and replays them unchanged. Proposal: `ChatMessage.toolCalls?: { id, name, input }[]` and an opaque +`providerData?: unknown` the provider can round-trip. + +## 4. Per-course mentor toggle in `ai_settings` + +The frozen key is `(scope, owner_id)` with one `mentor_off` column, so a per-course toggle cannot be a row. The flag +lives in the user row's `config_json.mentorOff[courseId]`; `mentor_off` stays 0 (reserved for "off everywhere"). +Proposal: add `course_id TEXT NOT NULL DEFAULT ''` to the key, or a small `ai_mentor_prefs(user_id, course_id, off)` table. + +## 5. Citations on `ai_messages` + +`ai_messages` has no column for an assistant message's sources. They are stored as an extra row with role `sources` +(JSON) right after the assistant row and folded back when reading. Proposal: `ai_messages.meta_json TEXT`. + +## 6. Derived text cache layout (shared with WP-B) + +Retrieval reads the importer's layout: +- `/cache/decks///text.json` = `[{ page, text }]` (page images alongside); +- `/cache/transcripts//.json` = `{ lessonId, text, segments: [{ start, end, text }] }` + (skipped when the lesson has a `transcript` file in the course, which the job writes too); +- plus plain `.txt/.md/.vtt/.srt` under `/cache//**` (older layout, kept for hand-made caches). +Proposal: export these paths from `@stc/core` (e.g. `cachePaths(dataDir)`) so both packages share one definition. + +## 7. Learner lesson URL + +Citations carry `lessonId`; the assistant links them to `/c//`. Every such link goes through +`lessonUrl(courseId, lessonId)` / `courseUrl(courseId)` in `packages/feature-ai/src/web/urls.ts` (also exported from +`@stc/feature-ai/web`), so a different WP-A route is a one-line change. Proposal: move that helper into +`@stc/contracts/web` so WP-A owns the route shape. + +## 8. A global `author` role in core's `canView` + +Generation and the studio file listing now require an authoring right (owner, the `author` role or a `course_authors` +row) and core's `canView(user, course, acl, isAuthor)`. `canView` only knows per-course authors (`isAuthor`), so a user +with the global `author` role cannot reach a `restricted` course without an ACL grant or a `course_authors` row. +Spec section 6 allows author rights "on all courses or per course", and the studio branch (`feature-studio` +`server/feature.ts:82-85`) currently grants the global role on every course. Proposal: core exports one +`canAuthor(user, course, acl, isAuthor)` (or `canView` treats `roles.includes("author")` like `isAuthor`), and both +the studio and the AI package call it. Until then the AI package follows core's `canView` (the stricter rule). + +## 9. `STC_AI_ALLOWED_BASE_URLS` in `AppConfig` + +Learners' own keys (BYOK) go only to `https://api.anthropic.com`, `https://api.openai.com/v1` or an origin on the +allowlist (SSRF guard). The allowlist is `STC_AI_ALLOWED_BASE_URLS` (comma-separated exact origins) plus the admin +setting `allowedBaseUrls` in the server AI config. `AppConfig` is frozen, so the package reads the variable from +`process.env` (injectable through `aiFeature(ctx, { env })`). Proposal: add `aiAllowedBaseUrls: string[]` to +`AppConfig` and `loadConfig`. diff --git a/docs/contract-requests/import.md b/docs/contract-requests/import.md new file mode 100644 index 0000000..4831f36 --- /dev/null +++ b/docs/contract-requests/import.md @@ -0,0 +1,12 @@ +# Contract requests from WP-B (Importer) + +1. **`loadConfig` should add `/courses` to `courseDirs`.** The importer writes courses there. Worked around in `apps/server/src/main.ts` (the main guard pushes it) and in `packages/feature-import/dev/serve.ts`. `/api/import/info` reports `coursesDirServed` and the UI warns when it is false. +2. **`startServer` accepts feature factories** (`Feature | ((ctx) => Feature)`). A `Feature` needs `ServerContext`, but `ServerContext.jobs` is built from the features' job handlers. `importFeature(ctx)` only reads `ctx` while a job or request runs, so `main.ts` now builds `ctx` first, resolves the factories, then assigns `ctx.jobs`. The edit is about ten lines in `apps/server/src/main.ts`; Phase 2 should keep or replace it. +3. **Web entry for features.** `apps/web` must not pull a package's server code into the browser bundle. `@stc/feature-import` exposes a browser-safe entry as `./web` (package.json `exports`), but the root tsconfig `paths` (`@stc/*` to `packages/*/src`) cannot resolve `@stc/feature-import/web`, so `apps/web/src/features.ts` imports it by relative path. Suggest a tsconfig `paths` entry per feature, or a `src/web.ts` convention. +4. **Derived cache layout (no contract defines one).** Written by the importer, to be read by the learner and AI packages: + - `/cache/decks///p-001.jpg ...` (1280px wide JPEGs) and `text.json` (`[{ page, text }]`); + - `/cache/covers//frame-N.jpg` and `deck-.jpg` (cover candidates); + - `/cache/transcripts//.json` (`{ lessonId, text, segments: [{ start, end, text }] }`). + Suggest a `cacheDir`/`deckPageUrl` helper in core, and a media route for `cache/decks` so the deck viewer can load pages. +5. **Video lessons have no description field.** Packt clips come with a one-line description. It is dropped, because `transcript` is the only Markdown a video lesson can carry and the transcription job writes it. +6. **`STC_IMPORT_ROOTS`** (":"-separated, default `/import-inbox`, created when `importFeature` is built) is read inside the import package. Core config could own it later. diff --git a/docs/contract-requests/learner.md b/docs/contract-requests/learner.md new file mode 100644 index 0000000..6121a3d --- /dev/null +++ b/docs/contract-requests/learner.md @@ -0,0 +1,57 @@ +# Contract requests from WP-A (learner) + +## 1. Feature factories and the auth service (`apps/server/src/main.ts`, `@stc/contracts`) + +**Problem.** Phase 0 passes `Feature[]` to `startServer`, but `ServerContext` is built inside `startServer`, so a feature that needs `ctx` (every Phase 1 package exports `xFeature(ctx)`) cannot be listed. There is also no way for a feature to replace the anonymous `AuthService`. + +**Workaround in `feat/learner` (applied, small):** `startServer` accepts `FeatureEntry = Feature | ((ctx) => Feature & { auth?: AuthService })`. Factories run after `ctx` exists (with `ctx.jobs` set right after they return, so factories must not use `jobs` synchronously), and a feature carrying `auth` replaces `ctx.auth`. The `main` entry lists `learnerFeature`. + +**Proposal.** Move both into the contract: +```ts +export type FeatureFactory = (ctx: ServerContext) => Feature; +export interface Feature { /* … */ auth?: AuthService; migrationsDirs?: string[] } +``` +and let `startServer` take `Array`. + +## 2. Migrations per package + +`migrate()` only reads `packages/db/migrations`. The learner package exports `migrationsDirs` and, until Phase 2 wires it in, runs its own migrations lazily inside `learnerFeature(ctx)` through `runMigrations(db, dirs)`, which uses the same `schema_migrations` table and file-name keys. When `migrate()` learns about `migrationsDirs`, the lazy call becomes a no-op; it can then be removed. + +## 3. Package entry for the browser + +**Problem.** The package index exports both the server (`learnerFeature`, which pulls in Better Auth and `@better-auth/sso`) and the browser half (`learnerWeb`, `LessonView`). A browser bundle of the index fails: `@better-auth/sso` imports `X509Certificate` from `node:crypto`, which has no browser polyfill. The root `tsconfig.json` maps `@stc/*` to `packages/*/src`, so package.json `exports` subpaths are not used either. + +**Workaround in `feat/learner` (applied).** `LessonView` and `learnerWeb` are exported from the index (for server-side and type use) and from a browser-only entry, `@stc/feature-learner/web`. That subpath resolves through the existing mapping to `packages/feature-learner/web/src/index.ts`, a one-line re-export of `src/web`. `apps/web/src/features.ts` imports `learnerWeb` from it, and the studio should import `LessonView` from it too. + +**Proposal.** Add `"@stc/*/web": ["packages/*/src/web"]` ahead of the general mapping in `tsconfig.json` (Bun honours tsconfig paths), and let every feature package expose `src/web/index.ts`. The shim directory can then be deleted. This also settles the importer's relative import in `features.ts`. + +## 4. LessonView for the studio preview + +`import { LessonView } from "@stc/feature-learner/web"`. Props: `lesson: LessonPayload` (the JSON from `GET /api/courses/:id/lessons/:lessonId`), plus optional `look`, `track`, `onTrackChange`, `preview`. With `preview` set, it makes no notes, attempt or progress requests; it still loads code listings and decks through the normal media routes. It injects its own stylesheet (scoped under `.lr`) and works with or without a router. + +## 5. Content-Security-Policy on the SPA (review C1, ruling R16) + +**Applied in `feat/learner`.** The SPA's CSP is a `` tag in `apps/web/index.html`, ahead of every script. `Bun.serve` serves the HTML import directly, as in Phase 0; there is no proxy and no change to proxy environment variables. The policy: + +``` +default-src 'self'; script-src 'self' 'sha256-'; style-src 'self' 'unsafe-inline'; +img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; +worker-src 'self' blob:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self' +``` + +- **No inline script.** + - The one hash is the inline helper Bun's dev server injects (the `/_bun/unref` beacon). Production serving does not inject it. + - If a Bun upgrade changes the helper, the only effect is a dev-only CSP console error; `test/csp.test.ts` (dev mode) will then fail and name it. +- **Dev HMR works:** the HMR websocket `ws:///_bun/hmr` is same-origin, and Chrome matches it with `connect-src 'self'` (checked: handshake 101, no violations). +- **The runner frame** (`/api/learner/runner-frame`) keeps its own header-based CSP, including `sandbox allow-scripts` and `frame-ancestors 'self'`. + +**Proposal for core (Phase 2).** +- A meta tag cannot carry `frame-ancestors`, `sandbox` or `report-uri`. Core should additionally send `Content-Security-Policy: frame-ancestors 'self'` (or `X-Frame-Options: SAMEORIGIN`), plus `X-Content-Type-Options: nosniff`, on HTML responses. That is possible if Bun ever allows headers on HTML imports, or by serving a production static build (`bun build apps/web/index.html`) through a normal route that sets headers. +- Ideally core then sends the whole policy as a header and the meta tag stays as defence in depth. +- Every feature's web code must keep to this policy: no inline scripts or event handlers, and no `eval`. The learner and studio preview, through `LessonView`, comply. + +## 6. `canView` and the global `author` role (review I1) + +**Ruling applied.** Learner routes now call core `canView` with per-course authorship only (`course_authors`), exactly like core's `/api/media`. A user with only the global `author` role gets a 404 for a restricted course they are not listed on, on every route. + +**Proposal for Phase 2.** Extend core `canView` so the global `author` role can view every course (the studio and AI need the same rule), for example `canView(user, course, acl, isCourseAuthor)` returning true when `user.roles.includes("author")`. The learner then needs no change: it already delegates to `canView`. diff --git a/docs/contract-requests/studio.md b/docs/contract-requests/studio.md new file mode 100644 index 0000000..6fb10c8 --- /dev/null +++ b/docs/contract-requests/studio.md @@ -0,0 +1,89 @@ +# Contract requests from WP-C (studio) + +Each item says what is missing, what the studio does locally for now, and what Phase 2 should change. + +## 1. Features that need the ServerContext (apps/server) + +`startServer(config, features: Feature[])` builds the `ServerContext` internally, so a feature factory such as `studioFeature(ctx)` cannot be put into the feature list. The studio therefore does **not** touch `apps/server/src/main.ts`. Locally it runs through `packages/feature-studio/dev/serve.ts`, which mirrors `startServer`. + +Proposal: +- `startServer(config, features: (Feature | ((ctx: ServerContext) => Feature))[])`, and main.ts adds `studioFeature`. +- An optional `stop?(): void | Promise` on `Feature`, called from `stop()`. The studio uses it to stop its published-worktree watcher (an unref'd interval, so it never blocks exit). + +## 2. Learners must read studio courses from the published worktree (config / main.ts) + +Layout after the review (controller ruling on I4): +- `/studio/repos/.git`: studio bare repos. They contain no `stcourse.yaml`, so discovery never loads them. +- `/worktrees//published`: the published branch, which is what learners read. +- `/worktrees//drafts/`: drafts. They sit at depth 3, so the depth-2 discovery never loads them. +- `/courses/`: the importer's plain folders (WP-B), untouched by the studio. + +`createCourseRepository(config.courseDirs)` scans neither `/worktrees` nor `/courses`. The package exports `studioCourseDirs(config)`, which returns the configured dirs plus `/courses` and `/worktrees`. Tests and the dev entry use it. + +Proposal: main.ts calls `createCourseRepository(studioCourseDirs(config))`, or `loadConfig` appends both dirs. + +Imported courses (`source.kind` other than `authored`, with their root under `/courses`) are read-only in the studio for v1, with the message "Imported course: read-only in the studio for now". There is a `TODO(studio)` in `feature.ts` (`readOnlyMessage`) for converting one into a studio-managed repo. The studio refuses to create a course whose id already exists as `/courses/`. WP-B should likewise refuse an import id that `/studio/repos/.git` already uses. + +## 3. Live preview: swap in the learner `LessonView` + +The studio preview sits behind an adapter in `@stc/feature-studio/web`: + +```ts +export interface LessonPreviewProps { + courseId: string; + markdown: string; + lesson?: { id: string; title: string; type: string; anchor?: string } | null; + resolveUrl: (rel: string) => string; // course-relative path -> URL of the draft file +} +export function setLessonPreview(c: React.ComponentType): void; +``` + +v1 uses a small, safe built-in Markdown renderer. Proposal: `@stc/feature-learner` exports a `LessonView` that can render from a Markdown string plus a URL resolver, without fetching the published course. Phase 2 then calls `setLessonPreview(adapter)` in `packages/feature-studio/src/web.ts`, so drafts are previewed with the real renderer. + +## 4. Web entry points (tsconfig paths) + +Phase 1 packages have server code (`Bun.$`, `node:fs`) that must not enter the browser bundle, so the studio has a separate browser entry, `src/web.ts`. The `@stc/*` → `packages/*/src` path maps `@stc/feature-studio/web` to a directory that does not exist, and the workspace has no `node_modules/@stc` links. For now, `apps/web/src/features.ts` imports it with a relative path. + +Proposal: add `"@stc/*/web": ["packages/*/src/web.ts"]` to the root tsconfig `paths`, and switch the line to `import { studioWeb } from "@stc/feature-studio/web"`. + +## 5. Role-aware navigation (contracts/web.ts) + +`WebRoute.nav` cannot be limited by role, so the header "Studio" link shows for everyone. The page itself tells learners and anonymous users that the studio is for authors, and the API returns 401/403. + +Proposal: add `nav.roles?: Role[]`, and have the shell show the link only to matching users. This needs the learner package's session in the web shell. + +## 6. Draft API for WP-D (AI) + +Exported from `@stc/feature-studio`: + +```ts +export interface DraftWriter { + writeDraftFiles(courseId: string, authorId: string, files: { path: string; content: string }[], message: string): Promise<{ branch: string; commit: string }>; +} +export function createDraftWriter(ctx: { config: Pick }, opts?: { + identity?: (authorId: string) => Promise<{ name: string; email: string } | null> | { name: string; email: string } | null; +}): DraftWriter; +``` + +- It writes and commits on `draft/` and never publishes. +- Without `identity`, commits are authored as ` `. WP-D should pass the signed-in user's name and email. +- It throws for courses not managed by the studio, and for paths that are absolute, contain `..` or touch `.git`. +- `studioFeature(ctx).draftWriter` is the same API, already bound to `ctx`. + +## 7. Authors and roles (decision to confirm) + +Studio access goes to: +- `owner` and `author` roles: all studio courses, and they can create courses; +- users with a `course_authors` row: that course only. + +Creating a course inserts the creator into `course_authors`. No new tables are added, so `migrationsDirs` is `[]`. + +## 8. External pushes straight into the bare repo (no action needed) + +The platform reloads when the published worktree's HEAD changes: an external commit in that worktree, a studio publish, or a Sync. A direct `git push` into `/courses/` is refused by git itself ("branch is currently checked out", checked with git 2.47), so the published files cannot silently fall behind `main`. Outside tools should commit in the published worktree, or push to a configured remote that the author then Syncs. + +## 9. Core media route serves SVG/HTML inline (core, Phase 2) + +The studio's draft `raw` route now sends `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`. The learner-facing `/api/media/:courseId/*` in `packages/core` serves author-supplied SVG inline from the app's origin and sets neither header. + +Proposal: add the same two headers in `serveMedia`. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..673de68 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,100 @@ +# Operating STCourses + +One server process (Bun) serves the API and the web app. Course content lives in folders; the database holds user +data only (accounts, progress, notes, attempts, settings), so deleting the database loses user data but never a +course. + +## Run with Docker Compose + +```sh +# SQLite in the data volume (the default) +STC_SECRET="$(openssl rand -base64 48)" docker compose up -d + +# Postgres instead +STC_SECRET="$(openssl rand -base64 48)" \ +STC_DATABASE_URL=postgres://stc:stc@postgres:5432/stc \ + docker compose --profile postgres up -d +``` + +The first account that signs up becomes the owner. Open `http://localhost:3000` (or `STC_HOST_PORT`). + +Mounts (all course mounts are read-only; STCourses never changes your originals): + +| Host | Container | What | +|---|---|---| +| volume `stc-data` | `/data` | database (SQLite), imported courses (`/data/courses`), studio repos (`/data/studio/repos`) and their published worktrees (`/data/worktrees`), caches (`/data/cache`), AI drafts for non-studio courses (`/data/drafts`) | +| `STC_COURSES_HOST` (default `./courses`) | `/courses` | course repos, one folder with a `stcourse.yaml` per course | +| `STC_IMPORT_HOST` (default `./import-inbox`) | `/import` | files the owner may import (Packt zips, video folders, PDFs, code zips) | + +An overlay in `overlays/` (baked into the image) names its course repo by absolute path in `root:`. Mount that repo +read-only at the same path inside the container, for example +`/home/me/functional-programming-course:/home/me/functional-programming-course:ro`. If the repo is missing, the +course shows as broken with a diagnostic and every other course keeps working. + +Back up the `stc-data` volume (and the Postgres volume `stc-pg` when you use Postgres). + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `STC_SECRET` | none (required in production) | encrypts users' own AI keys and signs sessions; use 32+ random characters | +| `STC_PUBLIC_URL` | `http://localhost:` | the URL users open; sign-in checks the request origin against it | +| `STC_PORT` | `3000` | listen port | +| `STC_DATABASE_URL` | `sqlite://./data/stcourses.db` (image: `sqlite:///data/stcourses.db`) | `sqlite://` or `postgres://…` | +| `STC_DATA_DIR` | `./data` (image: `/data`) | see the table above | +| `STC_COURSE_DIRS` | `./overlays:./courses` (image: `/app/overlays:/courses`) | `:`-separated folders scanned for courses; `/courses` and `/worktrees` are always added | +| `STC_IMPORT_ROOTS` | `/import-inbox` (image: `/import`) | `:`-separated folders the importer may read | +| `STC_UPLOAD_MAX_MB` | `100` | studio upload cap | +| `STC_PYTHON` | `python3` | Python with PyMuPDF, for PDF page images and text (installed in the image) | +| `STC_TRANSCRIBE_URL`, `STC_TRANSCRIBE_KEY`, `STC_TRANSCRIBE_MODEL` | unset | Whisper-compatible endpoint for video transcripts; unset disables transcription | +| `STC_AI_ALLOWED_BASE_URLS` | unset | `,`-separated origins learners' own AI keys may use besides api.anthropic.com and api.openai.com | +| `STC_AUTH__ID`, `_SECRET` | unset | social sign-in | +| `STC_AUTH_OIDC_ISSUER`, `_ID`, `_SECRET`, `_NAME`, `_SCOPES`, `_DISCOVERY_URL`, `STC_AUTH_GROUPS_CLAIM` | unset | generic OIDC sign-in; group claims drive restricted visibility | +| `STC_ALLOWED_ORIGINS` | unset | extra `,`-separated origins (e.g. `http://192.168.1.5:3000`) allowed to send writes and sign in, besides `STC_PUBLIC_URL`'s origin. `STC_TRUSTED_ORIGINS` is read as an alias | + +Without Docker: `bun install`, then `STC_SECRET=… NODE_ENV=production bun apps/server/src/main.ts`. The importer needs +`ffmpeg`, `ffprobe`, `git`, `unzip` and Python with PyMuPDF on the host. + +## Cross-site requests and the session cookie + +- Every state-changing request (POST, PUT, PATCH, DELETE), sign-in included, must carry an `Origin` (or, without + one, a `Referer`) equal to `STC_PUBLIC_URL`'s origin or one in `STC_ALLOWED_ORIGINS`. Anything else gets 403. + Open STCourses at exactly that URL. If people reach it by several names (a LAN IP and a hostname), list the others + in `STC_ALLOWED_ORIGINS`. +- A write with a body must be `application/json` (uploads: `multipart/form-data`). HTML forms and `text/plain` + bodies, which another page can send without a CORS preflight, are refused. +- Exempt: the identity-provider callbacks (SAML ACS and SLO, OAuth/OIDC callbacks under `/api/auth`). The provider + posts those from its own origin, and Better Auth verifies the signed assertion or the OAuth state itself. +- The session cookie stays `SameSite=Lax` (Better Auth's default), not `Strict`. OAuth/OIDC sign-in returns from + the provider by a cross-site top-level navigation, and Better Auth needs its state cookie on that request. Under + `Strict` the browser withholds it, and social, OIDC and SAML sign-in break. The Origin check above covers the + same-site cases that `Lax` allows (other ports on the same host, sibling subdomains). + +## Security headers + +- The web app carries its Content-Security-Policy as a `` tag in `apps/web/index.html` (no inline script). + A meta tag cannot set `frame-ancestors`; if you need clickjacking protection, add + `Content-Security-Policy: frame-ancestors 'self'` (or `X-Frame-Options: SAMEORIGIN`) in your reverse proxy. +- Course media (`/api/media/…`) is sent with `X-Content-Type-Options: nosniff`, and SVG, HTML and JavaScript files + with `Content-Security-Policy: sandbox`, so author content cannot run script on the app's origin. + +## Memory budget + +The deployment must stay under 2 GB idle. Measured with this compose file (`--profile postgres`), the image built +from this repository, and four courses mounted read-only: the sample course, Pure Snake and Systemic Design (both +overlays, repos mounted at their own paths), and the imported Packt course "Mastering Docker Essentials" (68 videos, +9 modules, a 59-slide deck, code files; about 800 MB of media). Before idling, one owner signed up and opened the +library, a video lesson with its deck, a Pure Snake lesson and the sample exercise in headless Chrome. Then nothing +for 5 minutes, and `docker stats --no-stream`: + +| Container | Memory (idle, after 5 min) | CPU | +|---|---|---| +| `stc-app-1` (Bun server, SPA bundle built and cached) | 84.7 MiB | 1.00% | +| `stc-postgres-1` (postgres:17) | 16.67 MiB | 3.52% | +| **Total** | **101.4 MiB** (about 5% of the 2 GB budget) | | + +Measured 2026-10-30 on Linux 6.12 (Docker Compose v5.5.1, `oven/bun:1.3` = Bun 1.3.14). With SQLite there is +no Postgres container, so the total is the app's figure alone. + +Video and PDF files are streamed from disk with Range requests; they never sit in memory. The SPA bundle is built +once, on the first page request, and cached. diff --git a/docs/superpowers/plans/2026-10-30-ai.md b/docs/superpowers/plans/2026-10-30-ai.md new file mode 100644 index 0000000..14c457d --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-ai.md @@ -0,0 +1,178 @@ +# WP-D AI Implementation Plan + +> **For agentic workers:** executed inline by the WP-D agent with TDD (failing test first), one commit per task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Opt-in AI for STCourses: a course-aware learner assistant (retrieval, course tools, MCP tools, mentor mode) and a studio generator that writes house-style drafts. AI is off until an admin configures a server provider or a user saves their own key (BYOK). + +**Architecture:** +- `packages/feature-ai` exports `aiFeature(ctx, opts?): Feature`, `aiWeb: WebFeature` and `migrationsDirs`. +- Providers implement the frozen `AiProvider` contract with plain `fetch` (no SDK): Anthropic Messages API (streaming SSE, tool use, default model `claude-opus-5-5`) and OpenAI-compatible chat completions (covers Ollama). +- Settings live in the existing `ai_settings` and `user_api_keys` tables (no new tables). Secrets are AES-GCM encrypted with a key derived by HKDF-SHA-256 from `ctx.config.secret`; they never leave the server. +- Retrieval is an in-process BM25 index per loaded course (course Markdown, transcripts, code, deck/transcript text under `/cache//`), plus the requesting user's notes at query time. Solution files are never indexed. +- The assistant runs a small tool loop over `search_course`, `read_lesson`, `list_notes` and admin-configured MCP servers (stdio and streamable HTTP), and streams NDJSON events to the browser. +- The generator builds house-style prompts (Pure Snake module shape, Systemic Design beats), parses `=== FILE: … ===` blocks from the model output, and hands them to a `DraftSink`. The default sink writes to `/drafts///`; Phase 2 swaps in the studio's `createDraftWriter(ctx)`. + +**Tech Stack:** Bun 1.3, TypeScript strict, WebCrypto, `Bun.SQL`, `Bun.spawn`, React 19 + React Router 6, `@stc/ui` primitives. + +**Spec:** sections 9.2, 7, 2 (anti-goals). Brief: `.superpowers/sdd/2026-10-30-stcourses/phase-1-brief.md` (WP-D). + +## Global Constraints + +Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain CSS with tokens, both databases, AGPL, never write into the reference course repos, never commit licensed content, keyboard rules, commit trailer). Package-specific: + +- **AI is off by default.** No provider configured and no BYOK key means every AI route answers `enabled: false` / 409, and the UI says how to turn it on. +- **Keys never go to the client.** API responses carry `hasApiKey: boolean`, never ciphertext or plaintext. +- **No real network in tests.** Providers take an injectable `fetch`; MCP tests use a local fake server (stdio child process and `Bun.serve` on port 0). +- **Mentor mode is on by default** and its rules go into the system prompt; when off, the UI shows "Mentor mode off: I'll give full answers". +- **Generated material is never published.** The generator only writes through a `DraftSink`. + +## Review Focus + +1. **Secrets:** BYOK and server keys are encrypted at rest (AES-GCM, random IV, HKDF key from `STC_SECRET`), a wrong secret fails to decrypt, and no route returns key material. Tests: Tasks 1, 2, 9. +2. **Mentor anti-goal:** mentor rules are in the system prompt unless the learner switched them off for that course; solution files (`browser.solution`, `solutions/`, `reference/`, `expansion/solution/`) are never retrieved or readable by tools. Tests: Tasks 5, 7. +3. **Generation never publishes:** drafts land only under the sink location, paths are sanitised (no `..`, no absolute paths). Test: Task 8. +4. **Access control:** assistant routes require that the user can view the course (`canView`); generator routes require owner, the `author` role or a `course_authors` row; admin routes require owner. Test: Task 9. +5. **Thinking blocks:** the Anthropic provider replays an assistant turn's raw content blocks (thinking, text, tool_use) unchanged inside one tool loop. Test: Task 3. + +--- + +### Task 1: Package scaffold and secret box + +**Files:** `packages/feature-ai/{package.json,src/index.ts,src/crypto.ts}`, test `packages/feature-ai/test/crypto.test.ts` + +**Interfaces (Produces):** +- `createSecretBox(secret: string): { encrypt(plain: string): Promise; decrypt(token: string): Promise }`. HKDF-SHA-256 (salt `"stcourses/byok"`, info `"aes-gcm-256/v1"`) → AES-GCM-256; token `v1..`. +- `migrationsDirs: string[]` (empty: the package adds no tables). + +- [x] Tests: round trip; two encryptions differ (random IV); a box with another secret rejects; a tampered token rejects. +- [x] Implement; commit `feat(ai): package scaffold and AES-GCM secret box`. + +### Task 2: Settings repository + +**Files:** `src/settings.ts`, test `test/settings.test.ts` (uses `forEachDb` from `packages/db/test/helpers.ts`) + +**Interfaces:** +- `ProviderKind = "anthropic" | "openai"`; `ProviderConfig = { kind, model?, baseUrl?, effort? }`. +- `McpServerConfig = { id, name, transport: "stdio" | "http", command?, args?, env?, url?, headers?, courseIds?: string[] }` (no `courseIds` = global). +- `aiSettingsRepo(db, box)`: + - `getServer(): Promise<{ provider: (ProviderConfig & { hasApiKey }) | null; mcpServers }>`, `saveServer({ provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null, mcpServers })`, `serverApiKey(): Promise`. + - `getUser(userId): Promise<{ byok: (ProviderConfig & { hasApiKey }) | null; mentorOff: Record }>`, `saveUserKey(userId, cfg & { apiKey })`, `removeUserKey(userId)`, `userApiKey(userId, kind)`, `setMentorOff(userId, courseId, off)`, `isMentorOff(userId, courseId)`. +- Storage: `ai_settings` row `('server','')` holds `{ provider, apiKeyCipher, mcpServers }`; row `('user', userId)` holds `{ byok, mentorOff }`. The per-course flag lives in `config_json` because the frozen primary key is `(scope, owner_id)`; the `mentor_off` column mirrors "off for every course" (always 0 for now). Contract request filed. Keys go in `user_api_keys` encrypted. Upserts are select-then-insert-or-update in a transaction. + +- [x] Tests (both DBs): empty server config is `null` provider; saving a key stores ciphertext only (raw row does not contain the key) and `serverApiKey()` returns it; omitting `apiKey` on save keeps the old key; BYOK save/remove; mentor toggle per course is independent. +- [x] Implement; commit `feat(ai): settings repository for server config, BYOK keys and mentor toggles`. + +### Task 3: SSE parsing and the Anthropic provider + +**Files:** `src/providers/sse.ts`, `src/providers/anthropic.ts`, test `test/anthropic.test.ts` + +**Interfaces:** +- `readSse(body: ReadableStream): AsyncIterable<{ event: string; data: string }>`. +- `createAnthropicProvider({ apiKey, model = "claude-opus-5-5", baseUrl = "https://api.anthropic.com", effort?, maxTokens = 64000, fetch? }): AiProvider`. + - POST `/v1/messages` with `stream: true`, headers `x-api-key`, `anthropic-version: 2023-06-01`. No `thinking`, `temperature` or forced `tool_choice` (Opus 5.5 rejects them). On the first-party endpoint, `fallbacks: "default"` with beta header `server-side-fallback-2026-07-01`. + - Message mapping: `system` messages join into `system`; an assistant `ChatMessage` with `toolCallId` and `name` is a tool call (content = JSON input); `tool` messages become `tool_result` blocks; consecutive same-role blocks merge. + - The provider remembers each response's full content blocks (thinking with signature, text, tool_use) by tool_use id and replays them unchanged for the matching assistant tool-call messages. + - Streams `{ text }` per `text_delta`, `{ toolCall }` per finished `tool_use` block; HTTP errors and `error` events throw with the API message; `stop_reason: "refusal"` yields a plain sentence. + +- [x] Tests with a fake `fetch` that returns canned SSE: text streams in order; a tool_use with split `input_json_delta` yields one parsed toolCall; request body has model, system, tools as `input_schema`, no `thinking`/`temperature`; a second call after a tool round replays the thinking block with its signature unchanged before `tool_result`; non-2xx throws with the error message. +- [x] Implement; commit `feat(ai): Anthropic provider over fetch with streaming tool use`. + +### Task 4: OpenAI-compatible provider + +**Files:** `src/providers/openai.ts`, `src/providers/index.ts`, test `test/openai.test.ts` + +**Interfaces:** +- `createOpenAiProvider({ baseUrl, apiKey?, model, fetch? }): AiProvider`. POST `/chat/completions`, `stream: true`, tools as `{ type: "function", function: { name, description, parameters } }`. Assistant tool-call messages map to `tool_calls`; `tool` messages to `{ role: "tool", tool_call_id }`. Accumulates `delta.tool_calls` by index and emits on finish or `[DONE]`. +- `createProvider(cfg: ProviderConfig, apiKey: string | null, fetchImpl?): AiProvider`. + +- [x] Tests: text deltas; fragmented tool-call arguments; no `Authorization` header without a key (Ollama); request mapping of a tool round. +- [x] Implement; commit `feat(ai): OpenAI-compatible provider (covers Ollama)`. + +### Task 5: Retrieval + +**Files:** `src/retrieval/{bm25.ts,course-index.ts}`, test `test/retrieval.test.ts` + +**Interfaces:** +- `Bm25` with `add(text, item)`, `search(query, k): { item, score }[]`. +- `Chunk = { path, kind: "lesson" | "transcript" | "markdown" | "code" | "deck" | "note", text, heading?, lessonId?, lessonTitle? }`. +- `buildCourseIndex(course: LoadedCourse, dataDir: string): Promise`; `CourseIndex.search(query, k, extra?: Chunk[]): Chunk[]`; `isSolutionPath(course, rel): boolean`. +- `getCourseIndex(course, dataDir)` caches per `LoadedCourse` object (a repository reload yields new objects) with a 5-minute TTL. +- Sources: every `.md` under the course root (depth-limited, skipping `.git`, `node_modules`), manifest code dirs and common code extensions (size cap 200 KB per file, 2 000 files per course), `.md/.txt/.vtt/.srt/.json` text under `/cache//`. Markdown splits at headings; long chunks split at ~1 500 chars. Paths are mapped to lessons through `file`, `transcript` and `hints.file` (anchor heading when it matches). +- Never indexed: `browser.solution` files and any path segment `solutions`, `solution`, `reference` under `expansion/`, or `reference/` in Systemic-Design style modules (see `isSolutionPath`). + +- [x] Tests on the `sample` fixture: "sum of its arguments" hits `lessons/04-exercise.md` with lessonId `m2-text` or `m2-ex-add`; `exercises/add/solution.js` never appears; a deck text file in a temp `cache/sample/decks/d1/page-1.txt` is found with kind `deck`; note chunks passed as `extra` rank. +- [x] Implement; commit `feat(ai): BM25 course retrieval over lessons, transcripts, code, deck text and notes`. + +### Task 6: MCP client + +**Files:** `src/mcp/client.ts`, test `test/mcp.test.ts`, fixture `test/fixtures/fake-mcp.ts` + +**Interfaces:** +- `connectMcp(cfg: McpServerConfig, opts?: { fetch?, timeoutMs? }): Promise`; `McpClient = { tools(): Promise<{ name, description, inputSchema }[]>; call(name, args): Promise; close(): Promise }`. +- JSON-RPC 2.0: `initialize` (protocolVersion `2025-06-18`), `notifications/initialized`, `tools/list`, `tools/call`. stdio = newline-delimited JSON over `Bun.spawn`; streamable HTTP = POST with `Accept: application/json, text/event-stream`, JSON or SSE replies, `Mcp-Session-Id` echoed. +- `mcpServersFor(courseId, servers)` selects global servers plus ones listing the course. + +- [x] Tests: the fake server over stdio lists `echo` and `tools/call` returns its text; the same over HTTP (fake via `Bun.serve` on port 0, replying with SSE for `tools/call`) with the session id echoed; a server that exits gives a rejected promise, not a hang. +- [x] Implement; commit `feat(ai): MCP client over stdio and streamable HTTP`. + +### Task 7: Assistant core + +**Files:** `src/assistant/{prompt.ts,tools.ts,loop.ts}`, test `test/assistant.test.ts` + +**Interfaces:** +- `buildSystemPrompt({ courseTitle, mentorText: string | null, mentorOn, sources: Chunk[], lessonTitle? }): string`. With mentor on and text present: a `## Course mentor rules` section with the file's text. With mentor off: a line saying full answers are allowed. Sources are numbered `[n] path (lesson title)` with their text; the prompt asks for citations by path. +- `courseTools({ ctx, course, userId, index })`: `ToolSpec[]` plus `run(name, input)` for `search_course`, `read_lesson` (refuses solution files) and `list_notes`. +- `runAssistant({ provider, messages, tools, signal, maxSteps = 6 }): AsyncIterable` where `AssistantEvent = { type: "text", text } | { type: "tool", name } | { type: "error", message } | { type: "done", text }`. Tool calls become assistant messages with `toolCallId`/`name` and `tool` result messages; unknown tools and tool errors are fed back as error text. +- Citations: `citationsFor(chunks): { path, lessonId?, lessonTitle?, kind }[]` deduplicated. + +- [x] Tests: mentor on puts the sample `MENTOR.md` text in the system prompt; mentor off omits it and states full answers; a fake provider that calls `search_course` then answers gets the tool result and the loop ends with text; `read_lesson` on the exercise returns markdown; solution paths are refused. +- [x] Implement; commit `feat(ai): assistant prompt, course tools and tool loop`. + +### Task 8: Generator and draft sink + +**Files:** `src/generator/{templates.ts,parse.ts,generate.ts,draft-sink.ts}`, test `test/generator.test.ts` + +**Interfaces:** +- `DraftSink = { writeDraftFiles(courseId, authorId, files: { path, content }[], message): Promise<{ location: string; paths: string[] }> }`; `createFsDraftSink(dataDir)` writes to `/drafts///` plus `DRAFT.json` (author, message, time). Same signature as the studio's `createDraftWriter(ctx).writeDraftFiles`. +- `GenerateRequest = { courseId, kind: "document" | "exercise" | "questions", style: "pure-snake" | "systemic-design", topic, sources: ({ kind: "lesson", lessonId } | { kind: "file", path } | { kind: "text", label, text })[], instructions? }`. +- `buildGeneratorPrompt(req, material, course)`: house-style system prompt (module shape: why, objectives, concepts, puzzle with "Think about it first" fold, everyday-programming bridge, exercises, hints as nudge / stronger hint / near-solution folds, check your understanding, in production, further reading; or Observe → Predict (+Discussion fold) → Spec → Build (Hint 1 — nudge / Hint 2 — concept / Hint 3 — pseudo-code) → Name it → Expansion Drop (+what this drop measures) → Further reading). Exercises: `README.md`, `starter.js`, `tests.js` (`export const tests` per the `ExerciseTest` contract), `solution.js`, shared fixture JSON across tracks when the course has tracks. Questions: check-your-understanding list with at least one predict and one "what would break if". +- `parseFiles(text): { files, warnings }` reads `=== FILE: ===` … `=== END FILE ===`; rejects absolute and `..` paths. +- `checkHouseStyle(req, files): string[]` warnings for missing beats/tiers. +- `generateDraft({ provider, req, course, ctx, sink, authorId }): Promise<{ location, paths, warnings }>`; every Markdown file starts with a draft marker comment. + +- [x] Tests: prompt for each style contains its beats; parse handles two files and rejects `../x`; fake provider output written by the fs sink lands under `/drafts/sample/…` and nothing changes in the course folder; missing tiers give warnings. +- [x] Implement; commit `feat(ai): house-style generator writing drafts through a DraftSink`. + +### Task 9: Feature routes and jobs + +**Files:** `src/feature.ts`, test `test/feature.test.ts` + +**Interfaces:** `aiFeature(ctx, opts?: { fetch?; draftSink?; providerFactory?(cfg, key): AiProvider }): Feature` with: +- `GET /api/ai/status?courseId=` → `{ enabled, source: "byok" | "server" | null, mentor?: { available, on } }`. +- `GET|PUT /api/ai/admin/config` (owner), `POST /api/ai/admin/mcp/test` (owner; lists a server's tools). +- `GET /api/ai/account`, `PUT /api/ai/account/key`, `DELETE /api/ai/account/key` (signed in). +- `PUT /api/ai/courses/:courseId/mentor` `{ off }`. +- `POST /api/ai/courses/:courseId/chat` `{ message, conversationId? }` → `application/x-ndjson` stream: `{type:"meta", conversationId, mentorOn, citations}`, `text`, `tool`, `done`, `error`. Stores user and assistant turns in `ai_conversations`/`ai_messages`. +- `GET /api/ai/courses/:courseId/conversations`, `GET|DELETE /api/ai/conversations/:id`. +- `GET /api/ai/studio/sources?courseId=`, `POST /api/ai/studio/generate` (enqueues job `ai.generate`), `GET /api/ai/studio/jobs/:id`. +- Job `ai.generate` runs `generateDraft` with the requesting user's provider. + +- [x] Tests through `createApp` with a fake `AuthService`: status disabled by default; admin config hides the key; non-owner gets 403; BYOK enables the assistant for that user only; chat with a fake provider streams text and `meta.citations` include `lessons/04-exercise.md`; system prompt holds mentor rules, and not after `PUT mentor {off:true}`; anonymous on a restricted course gets 404; generate as a learner gets 403; the job handler writes drafts under `/drafts/sample`. +- [x] Implement; commit `feat(ai): HTTP routes, chat streaming and generation job`. + +### Task 10: Web UI + +**Files:** `src/web/{index.ts,api.ts,AssistantPage.tsx,AdminAiPage.tsx,AccountAiPage.tsx,GeneratePage.tsx,ai.css}`, `apps/web/src/features.ts` (one line), test `test/web.test.tsx` + +- `aiWeb: WebFeature` routes `/c/:courseId/assistant`, `/admin/ai` (nav admin "AI"), `/account/ai`, `/studio/generate`. +- Assistant: course name, mentor segmented toggle, the plain banner "Mentor mode off: I'll give full answers" when off, conversation rows, sources as rows with paths, composer with a primary Send pill. Disabled state explains how to enable AI. +- Admin: provider kind, model, base URL, write-only key, MCP server rows. Account: BYOK form and remove. Generate: course, kind, style, sources checklist, topic, instructions, job progress, list of draft files with location. +- `readNdjson(stream)` client helper. + +- [x] Tests: `readNdjson` splits across chunk boundaries; `renderToString` of the banner component shows the exact sentence when off and nothing when on. +- [x] Implement, check in headless Chrome via `packages/feature-ai/scripts/dev.ts` on port 3464; commit `feat(ai): assistant panel, AI settings and studio generator UI`. + +### Task 11: Contract requests and report + +- [x] `docs/contract-requests/ai.md`: feature factories in `startServer`; tool-call fields on `ChatMessage`; per-course `ai_settings` key; studio `createDraftWriter` wiring; cache layout for deck text and transcripts; learner lesson URL. +- [x] Run `bun test`, `bun run typecheck`, the Postgres run; write `wp-ai-report.md`. diff --git a/docs/superpowers/plans/2026-10-30-import.md b/docs/superpowers/plans/2026-10-30-import.md new file mode 100644 index 0000000..ba21859 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-import.md @@ -0,0 +1,56 @@ +# WP-B Importer Implementation Plan + +> Executed inline by the WP-B agent, task by task, TDD. Spec: `docs/superpowers/specs/2026-10-30-stcourses-design.md` sections 5, 8, 13. + +**Goal:** `packages/feature-import` turns a Packt video zip, a companion code repo, a slide PDF or a folder of videos into an ordinary course folder under `/courses//`, as background jobs with visible progress, plus an owner-only admin UI at `/admin/import`. + +**Architecture:** +- `importFeature(ctx)` returns a `Feature` with job handlers `import.packt-video`, `import.code-repo`, `import.pdf-deck`, `import.video-folder`, `import.transcribe` and owner-only `/api/import/*` routes. `ctx` is read lazily (inside handlers), because the job queue is built from the handlers. +- Courses are built in `/tmp/` and renamed into `/courses/` in one step. Attach jobs (code, deck) only touch courses under `/courses` and write the manifest last, through temp file + rename. +- Media: **copied** (`cp --reflink=auto`, so instant on CoW filesystems), or hard-linked with `options.media = "link"` (falls back to copy across filesystems). Symlinks out of the course are not an option: the media route 404s on paths that escape the course root. The originals are only ever read. +- PDFs: PyMuPDF through `py/pdf_tool.py` run as `python -I` in a subprocess (`STC_PYTHON`, default `python3`). Chosen over pdfjs/mupdf-wasm because it rasterises and extracts text natively, is already installed, is AGPL-compatible and costs no idle memory in the server. If it is missing, the deck is still attached and the job reports a warning. +- Derived data goes to `/cache/`: `decks///p-001.jpg…` + `text.json`, `covers//*.jpg`, `transcripts//.json`. + +## Global Constraints + +Inherited unchanged from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun conventions, AGPL, design tokens, no licensed content in the repo, commit trailer). + +## Review Focus + +1. Original files are never modified; attach jobs refuse courses outside `/courses` (overlays, authored repos). +2. Paths with spaces/non-ASCII (`S08 - Containerizing Applications`) survive unzip, copy and manifest round trip. +3. Packt parsing is structural (chapter blocks plus the section summary lists), not regexes on "Video N.M:" prefixes. +4. Failure leaves no half-built course visible; a failed PDF/ffmpeg step degrades to a warning where the course is still valid. +5. Routes: 401 without a user, 403 without the owner role. + +## Tasks + +### Task 1: Utilities (slug, HTML entities, natural sort, exec, manifest IO, sources) +- Files: `src/util/*.ts`, `test/util.test.ts` +- Interfaces: `slugify`, `uniqueCourseId`, `decodeEntities`, `naturalSort`, `run`, `readRawManifest`, `writeManifest` (validates with `ManifestSchema`), `openSource(path)` (zip via `unzip`, or directory). +- [ ] Failing tests for each helper; [ ] implement; [ ] commit. + +### Task 2: Fixtures and media tooling +- Files: `test/fixtures.ts` (generates fake Packt zip, repo zip, PDF, video folder in a temp dir), `src/media.ts` (ffprobe duration, ffmpeg frame, copy/link), `src/pdf.ts`, `py/pdf_tool.py`. +- [ ] Failing tests (frame extraction, duration, PDF pages+text); [ ] implement; [ ] commit. + +### Task 3: Packt parser and `import.packt-video` +- Files: `src/packt.ts`, `src/jobs/packt-video.ts`, `test/packt.test.ts` +- [ ] Parser test on the synthetic index (summary-only titles, missing chapter); [ ] job test (2 sections, 3 clips, restricted manifest, source filled, course loads `ok`, cover candidates); [ ] implement; [ ] commit. + +### Task 4: `import.video-folder`, `import.pdf-deck` +- [ ] Tests: natural order, deck cache pages/text, attach to lesson and module, refuses unmanaged course; [ ] implement; [ ] commit. + +### Task 5: `import.code-repo` +- [ ] Tests: folder `S02 - …` attaches to module 2 by number, by name when no number, PDFs become deck/attachment, git URL validation; [ ] implement; [ ] commit. + +### Task 6: `import.transcribe` +- [ ] Tests with a fake Whisper server; skipped when `STC_TRANSCRIBE_URL` unset; [ ] implement; [ ] commit. + +### Task 7: Routes and `importFeature` +- [ ] Tests: 401/403/owner, enqueue validation, queue integration (SQLite and Postgres), covers; [ ] implement; wire `main.ts`; [ ] commit. + +### Task 8: Web UI `importWeb` +- [ ] Page with form, job list with progress, cover picker; register in `apps/web/src/features.ts`; screenshot check; [ ] commit. + +### Task 9: Manual check against `/home/user/example_course_content`, report. diff --git a/docs/superpowers/plans/2026-10-30-learner.md b/docs/superpowers/plans/2026-10-30-learner.md new file mode 100644 index 0000000..e6c1e8b --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-learner.md @@ -0,0 +1,147 @@ +# WP-A Learner App Implementation Plan + +> **For agentic workers:** executed inline by the WP-A agent (no subagents), task by task, TDD, one commit per task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Build `packages/feature-learner`: sign-in (Better Auth), roles/groups admin API, the library, the lesson reader for every lesson type, notes, progress, gating, and the in-browser JS/TS exercise runner. It must render `sample`, `pure-snake` and `systemic-design`. + +**Architecture:** +- Server side (`src/server/*`): `learnerFeature(ctx): Feature & { auth: AuthService }` returns the JSON API routes and the `AuthService`. It runs its own migrations (`migrations/0001_learner_auth.sql`) lazily on first use until Phase 2 wires `migrationsDirs` into `migrate()`. +- Auth: Better Auth with a **thin custom adapter over `Bun.SQL`** (`src/server/auth/adapter.ts`). One adapter serves both dialects because `@stc/db` already gives a `Bun.SQL` handle for SQLite and Postgres. +- Web side (`src/web/*`): React routes `/`, `/c/:courseId`, `/c/:courseId/:lessonId`, `/c/:courseId/notes`, `/account`, `/account/admin`. Styles are plain CSS loaded as text and injected once, so the same module graph is safe to import on the server. +- `LessonView` is a presentational lesson renderer that takes a `LessonPayload`. The studio uses it for preview. +- The browser runner (`src/web/runner/*`) runs learner code and tests as ES modules from blob URLs inside a module Web Worker, with a hard timeout (the worker is terminated). TS is transpiled by the server's `Bun.Transpiler` (`POST /api/learner/transpile`). +- Decks render client-side with pdfjs-dist. The worker script is served from `/api/learner/pdf.worker.mjs`. + +**Tech Stack:** Bun 1.3, TypeScript, React 19, React Router 6, better-auth 1.7 (+ `@better-auth/sso` for SAML/OIDC SSO, `generic-oauth` for a configured OIDC provider), marked + DOMPurify, pdfjs-dist. + +**Spec:** sections 4.1–4.4, 6, 7, 12, 13. Visual reference: `docs/design/mockups/platform-v1.html` minus its `[` / `]` / `/` / Ctrl+Enter shortcuts. + +## Global Constraints + +Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain CSS, no course content in the DB, both dialects, AGPL, tokens, keyboard rules, commit trailer). Package-specific: +- Owned paths: `packages/feature-learner/**`, `docs/superpowers/plans/2026-10-30-learner.md`, `docs/contract-requests/learner.md`. Shared touches: one line in `apps/web/src/features.ts`, the feature wiring in `apps/server/src/main.ts`. +- Frozen: `packages/contracts`, `packages/course-format`, `packages/db` (src + 0001), `packages/core`, `packages/ui`. +- No new colours: course accent (manifest) and the tokens only. Literata only for lesson titles and reading text. + +## Review Focus + +1. **Auth adapter correctness on both dialects** (where operators, AND/OR grouping, dates and booleans as text/int). Test: Task 2 runs the adapter suite under `forEachDb`. +2. **Owner bootstrap is race-safe**: exactly one owner even with concurrent sign-ups (unique key claim). Test: Task 3. +3. **Visibility**: lesson, outline, code and zip routes all apply `canView` and return 404 for hidden courses; gating returns `locked` without content. Test: Task 5/7. +4. **Zip download** handles spaces and non-ASCII names (UTF-8 flag) and never follows symlinks out of the root. Test: Task 7. +5. **Runner isolation**: learner code runs only inside the worker; an infinite loop is killed by the timeout. Test: Task 8. + +--- + +### Task 1: Package scaffold, migrations runner, test helper + +**Files:** `packages/feature-learner/{package.json,src/index.ts,src/server/migrate.ts,migrations/0001_learner_auth.sql,test/helpers.ts,test/migrate.test.ts}` + +**Interfaces (Produces):** +- `migrationsDirs: string[]` (absolute path of `packages/feature-learner/migrations`). +- `runMigrations(db: Db, dirs?: string[]): Promise` — same bookkeeping table and semantics as `@stc/db` `migrate()`. +- Tables: `auth_user`, `auth_session`, `auth_account`, `auth_verification`, `auth_sso_provider` (Better Auth, snake_case columns, ISO text dates, integer booleans), `learner_meta (key PK, value)`, `learner_group_claims (user_id, group_id)`. +- test helper `forEachDb(name, fn)` = `@stc/db`'s helper plus the learner migrations. + +- [x] Step 1: failing test: after `runMigrations` the tables exist on both dialects and a second run applies nothing. +- [x] Step 2: implement; Step 3: green on SQLite and Postgres; commit. + +### Task 2: Better Auth adapter over Bun.SQL + +**Files:** `src/server/auth/adapter.ts`, `test/adapter.test.ts` + +**Interfaces:** `bunSqlAdapter(db: Db)` → Better Auth `DBAdapterInstance`. Model `x` → table `auth_`, field `camelCase` → `snake_case`. `supportsDates/Booleans/JSON: false`, no transactions (SQLite has one connection; a nested query in a transaction would deadlock). +- [x] Failing tests (both dialects): create/findOne/findMany (eq, ne, in, not_in, contains, starts_with, ends_with, lt/gt, insensitive mode, OR grouping, sort, limit, offset), update, updateMany, delete, deleteMany, count, consumeOne, incrementOne. +- [x] Implement, green, commit. + +### Task 3: AuthService, owner bootstrap, claim groups, feature wiring + +**Files:** `src/server/auth/index.ts`, `src/server/auth/groups.ts`, `src/server/feature.ts`, `test/auth.test.ts`, `apps/server/src/main.ts` (wiring), `docs/contract-requests/learner.md` + +**Interfaces:** +- `createAuth(ctx, opts?)` → `{ handler(req), service: AuthService, providers(): ProviderInfo[] }`. +- Email/password always on; social providers (GitHub, Google, Microsoft, GitLab, Discord) from `STC_AUTH__ID/SECRET`; one generic OIDC provider from `STC_AUTH_OIDC_*`; SAML and per-domain OIDC through `@better-auth/sso` (owner-only registration). +- First user to sign up becomes `owner` (claimed via `INSERT` into `learner_meta` key `owner`, PK conflict loses); others get `learner`. +- `syncClaimGroups(db, userId, names)` creates groups by name and replaces the user's claim-sourced memberships; manual memberships are untouched. +- `learnerFeature(ctx)` returns `Feature & { auth }`; `startServer` accepts feature factories `(ctx) => Feature` and installs a feature's `auth` (contract request filed; two-line change in `main.ts`). +- [x] Failing tests: sign-up → session cookie → `userFromRequest` returns roles `[owner]`; second user `[learner]`; concurrent first sign-ups give one owner; sign-out clears; claim sync keeps manual memberships. +- [x] Implement, green, commit. + +### Task 4: Admin API (roles, groups, course ACL, library curation) + +**Files:** `src/server/admin.ts`, `test/admin.test.ts` +- Routes (owner only, 403 otherwise): `GET /api/admin/users`, `PUT /api/admin/users/:id/roles`, `GET|POST /api/admin/groups`, `DELETE /api/admin/groups/:id`, `PUT /api/admin/groups/:id/members`, `GET|PUT /api/admin/courses/:id/acl`, `GET|PUT /api/admin/library` (featured + collections). +- The last owner cannot drop their own owner role. +- [x] Tests first; implement; commit. + +### Task 5: Library, outline and lesson API + +**Files:** `src/server/catalog.ts`, `src/server/lesson.ts`, `src/server/gating.ts`, `test/catalog.test.ts`, `test/lesson.test.ts` +- `GET /api/library` → `{ featured, continue[], newest[], collections[], courses[] }` filtered by `canView`. Each course: id, title, pitch, authors, coverUrl, accent, formats (`text|video|slides|exercises`), lessonCount, minutes (video duration, text estimated at 220 wpm, exercises 15 min), collections, progress, status. +- `GET /api/library/search?q=` → courses and lessons matching title/pitch/lesson titles. +- `GET /api/courses/:id` → outline, tracks, gating, `locked` per module, progress, diagnostics (errors only for owner/authors). +- `GET /api/courses/:id/lessons/:lessonId` → `LessonPayload` (markdown, hints, decks, video, transcript, code, attachments, browser exercise, local command, prev/next) or `{ locked: true }`. +- Gating: module *i* locked when any lesson of an earlier module is not done; owners and course authors bypass. +- [x] Tests against the sample fixture plus temp courses; implement; commit. + +### Task 6: Progress, notes, exercise attempts API + +**Files:** `src/server/userdata.ts`, `test/userdata.test.ts` +- `PUT /api/courses/:id/progress/:lessonId` `{state, positionSeconds?}`; notes CRUD (`GET/POST /api/courses/:id/notes`, `PATCH/DELETE /api/notes/:id`); attempts `GET/PUT /api/courses/:id/lessons/:lessonId/attempt?track=`. A passing attempt marks the lesson done. Anonymous → 401. Hidden course → 404. +- [x] Tests on both dialects (acceptance: notes persist on SQLite and Postgres); implement; commit. + +### Task 7: Code files and zip download + +**Files:** `src/server/zip.ts`, `src/server/code.ts`, `test/zip.test.ts`, `test/code.test.ts` +- `GET /api/courses/:id/code/:moduleId` → `{ dir, command, files: {path,size}[] }` (skips `.git`, `node_modules`, symlinks escaping root; caps 2000 files). +- `GET /api/courses/:id/code/:moduleId/zip` → `application/zip`, `Content-Disposition` via `contentDisposition(".zip")`. +- `zip(entries)`: store/deflate, CRC32, UTF-8 name flag. +- [x] Tests: zip round-trip via `unzip -l`, non-ASCII names, symlink escape skipped; commit. + +### Task 8: Browser exercise runner + +**Files:** `src/web/runner/{assert.ts,worker-source.ts,runner.ts}`, `src/server/transpile.ts`, `test/runner.test.ts` +- `createBrowserRunner(language, { transpile })`: `ExerciseRunner`. Worker disables `fetch`, `XMLHttpRequest`, `WebSocket`, `importScripts` before importing code. Results carry expected/actual for failed asserts. +- [x] Tests (Bun supports module workers and blob URLs): starter fails, solution passes, a syntax error is reported, `while(true)` times out, TS is transpiled. +- [x] commit. + +### Task 9: Markdown pipeline + +**Files:** `src/web/markdown.ts`, `test/markdown.test.ts` +- `renderLessonHtml(md, { courseId, baseFile, tracks, lessonFiles })` → sanitized HTML: heading ids, code blocks tagged with their track (`data-t`), bold track labels tagged, `
` turned into folds; consecutive hint folds become a tier group (`data-tier`, `data-tiers`), relative images/links rewritten to `/api/media/...` or lesson routes. +- [x] Tests with Pure Snake-style input; commit. + +### Task 10: Web shell and library + +**Files:** `src/web/{routes.tsx,api.ts,styles.css,shell/*,library/*}` +- Header extras (course switcher, search, Reading/Work, account) portalled into the Phase 0 header; course outline sidebar (mobile overlay, Escape closes); library: featured hero, continue row, New row, collection rows of covers, format/length/in-progress filters, search. +- [x] Component smoke tests with `react-dom/server` render; screenshot check later; commit. + +### Task 11: Lesson views and `LessonView` + +**Files:** `src/web/lesson/*` +- Text (track switcher, Reading/Work, folds, hint tiers), video (range-served `