feat: resolve Phase 1 contract requests and compose every feature

Contracts and core:
- Feature gains auth and stop; FeatureFactory; core composeServer builds the
  context, runs the factories, then the job queue from the union of their jobs
  (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI.
- AppConfig/loadConfig own the layout and settings: courseDirs include
  <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir,
  importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python.
  resolveConfig derives the same for tests; cachePaths shares the importer's
  cache layout.
- canView: the global author role views every course (R14); the learner,
  studio and AI packages all call it.
- migrate(db, packages): core first, then packages in name order.
- Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on
  an empty file is 416.
- courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts.

Packages:
- import, studio and AI read AppConfig instead of process.env; the importer
  refuses ids used by studio/repos.
- learner migrations run through core migrate; library covers fall back to
  the importer's cached candidate behind a canView-checked route.
- studio preview renders through the learner LessonView (anchors honoured,
  draft URLs); AI drafts go to the studio draft branch for studio courses (as
  the requesting user) and to <dataDir>/drafts otherwise, and the generator
  page says which.

Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts
imports them by package name; nav.roles hides links by role.

Tests: compose, config, canView, media headers, migrations order, covers,
preview, nav, SPA bundle without server code, and an end-to-end flow on
SQLite and Postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:02:46 +01:00
co-authored by Claude Opus 5.5
parent 427eda6e93
commit 777ae2cc37
72 changed files with 1066 additions and 244 deletions
+44
View File
@@ -0,0 +1,44 @@
import type { FeatureFactory, ServerContext } from "@stc/contracts";
import type { PackageMigrations } from "@stc/db";
import { aiFeature, createFsDraftSink, migrationsDirs as aiMigrations, type DraftSink } from "@stc/feature-ai";
import { importFeature, migrationsDirs as importMigrations } from "@stc/feature-import";
import { learnerFeature, migrationsDirs as learnerMigrations, userIdentity } from "@stc/feature-learner";
import { createDraftWriter, migrationsDirs as studioMigrations, studioFeature, type StudioFeature } from "@stc/feature-studio";
/** Every package's migration folders, keyed by package name (core runs first, then these in name order). */
export const packageMigrations: PackageMigrations = {
"feature-ai": aiMigrations,
"feature-import": importMigrations,
"feature-learner": learnerMigrations,
"feature-studio": studioMigrations,
};
/**
* Where the AI generator writes drafts: studio-managed courses get a commit on the requesting author's
* `draft/<userId>` branch, authored as that user's name and email; overlay and imported courses (which the studio
* does not manage) fall back to files under `<dataDir>/drafts/<courseId>/<run>/`. The generator page says which.
*/
export function aiDraftSink(ctx: ServerContext, studio: Pick<StudioFeature, "store">): DraftSink {
const writer = createDraftWriter(ctx, { identity: (userId) => userIdentity(ctx.db, userId) });
const files = createFsDraftSink(ctx.config.dataDir);
return {
writeDraftFiles: (courseId, authorId, list, message) =>
(studio.store.isManaged(courseId) ? writer : files).writeDraftFiles(courseId, authorId, list, message),
};
}
export interface FeatureSetOptions {
/** Studio published-worktree polling (default on). */
studioWatch?: boolean;
}
/** The product: learner (auth, library, reader), importer, studio and AI, in that order. */
export function productFeatures(opts: FeatureSetOptions = {}): FeatureFactory[] {
let studio: StudioFeature | null = null;
return [
learnerFeature,
(ctx) => importFeature(ctx),
(ctx) => (studio = studioFeature(ctx, { watch: opts.studioWatch ?? true })),
(ctx) => aiFeature(ctx, { draftSink: aiDraftSink(ctx, studio!) }),
];
}
+26 -46
View File
@@ -1,62 +1,42 @@
import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerContext } from "@stc/contracts";
import { createApp, createJobQueue, loadConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { importFeature } from "@stc/feature-import";
import { learnerFeature } from "@stc/feature-learner";
import { resolve } from "node:path";
import type { AppConfig, Feature, FeatureFactory } from "@stc/contracts";
import { composeServer, loadConfig, type ComposeOptions } from "@stc/core";
import indexHtml from "../../web/index.html";
import { packageMigrations, productFeatures } from "./features";
// Used until a feature supplies an `auth` service (the learner package does).
const anonymousAuth: AuthService = { userFromRequest: async () => null };
export interface StartOptions extends ComposeOptions {}
/** A feature, or a factory that needs the server context. A feature may carry the `AuthService`. */
export type FeatureEntry = (Feature & { auth?: AuthService }) | ((ctx: ServerContext) => Feature & { auth?: AuthService });
export async function startServer(
config: AppConfig,
features: FeatureEntry[],
_webFeatures?: unknown,
): Promise<{ server: Bun.Server<undefined>; stop(): Promise<void> }> {
const db = await connect(config.databaseUrl);
await migrate(db);
const courses = await createCourseRepository(config.courseDirs);
const handlers: Record<string, JobHandler> = {};
// Factories get the context before the job queue exists; ctx.jobs is set right after they run.
const ctx: ServerContext = { config, db, courses, jobs: null as unknown as JobQueue, auth: anonymousAuth };
const built = features.map((f) => (typeof f === "function" ? f(ctx) : f));
for (const f of built) {
Object.assign(handlers, f.jobs);
if (f.auth) ctx.auth = f.auth;
/**
* Composes the features (core `composeServer`) and serves them with the SPA. Bun routes win over `fetch`;
* "/api/*" falls through to the composed app, so unknown API paths are 404 rather than the SPA.
*/
export async function startServer(config: AppConfig, features: (Feature | FeatureFactory)[], opts: StartOptions = {}) {
const composed = await composeServer(config, features, opts);
const { app } = composed;
let server: Bun.Server<undefined>;
try {
server = Bun.serve({
port: config.port,
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
fetch: (req) => app.fetch(req),
// Production (NODE_ENV=production, as in the Docker image) bundles the SPA once, minified, without HMR.
development: process.env.NODE_ENV !== "production",
});
} catch (e) {
await composed.stop();
throw e;
}
const jobs = createJobQueue(db, handlers);
ctx.jobs = jobs;
const app = createApp(ctx, built);
jobs.start();
const server = Bun.serve({
port: config.port,
// Bun routes win over fetch; "/api/*" falls through to the composed app (404 for unknown API paths).
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
fetch: (req) => app.fetch(req),
});
return {
server,
ctx: composed.ctx,
async stop() {
await server.stop(true);
await jobs.stop();
await db.close();
await composed.stop();
},
};
}
if (import.meta.main) {
const config = loadConfig();
// Contract request (import.md): core config should include <dataDir>/courses itself.
const generated = resolve(config.dataDir, "courses");
if (!config.courseDirs.some((c) => resolve(c) === generated)) config.courseDirs.push(generated);
const { server } = await startServer(config, [
learnerFeature,
(ctx) => importFeature(ctx),
]);
const { server } = await startServer(config, productFeatures(), { migrations: packageMigrations });
console.log(`STCourses listening on ${server.url}`);
}
+117
View File
@@ -0,0 +1,117 @@
// End-to-end (plan Task I3): the real server with every feature, against the fixtures, on SQLite and Postgres.
import { describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { loadConfig } from "@stc/core";
import { studioFeature } from "@stc/feature-studio";
import { createBrowserRunner } from "../../../packages/feature-learner/src/web/runner/runner";
import { aiDraftSink, packageMigrations, productFeatures } from "../src/features";
import { startServer } from "../src/main";
import { PG, pgSchema } from "./pg";
const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses");
const ORIGIN = "http://localhost:3000";
const cookieFrom = (r: Response) => r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; ");
async function run(databaseUrl: string) {
const dataDir = mkdtempSync(join(tmpdir(), "stc-e2e-"));
const config = loadConfig({
STC_PORT: "0", STC_DATABASE_URL: databaseUrl, STC_SECRET: "e2e-secret-0123456789abcdef0123456789",
STC_DATA_DIR: dataDir, STC_COURSE_DIRS: FIXTURES, STC_PUBLIC_URL: ORIGIN,
});
const s = await startServer(config, productFeatures({ studioWatch: false }), { migrations: packageMigrations });
const base = s.server.url.toString().replace(/\/$/, "");
const call = async (method: string, path: string, opts: { cookie?: string; body?: unknown } = {}) => {
const headers: Record<string, string> = { origin: ORIGIN };
if (opts.cookie) headers.cookie = opts.cookie;
if (opts.body !== undefined) headers["content-type"] = "application/json";
return fetch(base + path, { method, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) });
};
const signUp = async (email: string, name: string) => {
const r = await call("POST", "/api/auth/sign-up/email", { body: { email, name, password: "correct horse battery" } });
expect(r.status).toBe(200);
return cookieFrom(r);
};
try {
// 1. the server is up with every feature and the fixture course
expect(await (await call("GET", "/api/health")).json()).toEqual({ ok: true, courses: 1, db: databaseUrl.startsWith("postgres") ? "postgres" : "sqlite" });
expect((await call("GET", "/c/sample")).headers.get("content-type")).toContain("text/html");
// 2. sign up: the first account is the owner
const owner = await signUp("[email protected]", "Ada Lovelace");
const me = (await (await call("GET", "/api/me", { cookie: owner })).json()) as { user: { id: string; roles: string[] } };
expect(me.user.roles).toContain("owner");
// 3. mark a lesson done
expect((await call("PUT", "/api/courses/sample/progress/m1-intro", { cookie: owner, body: { state: "done" } })).status).toBe(200);
// 4. a slide note on the video lesson's deck
const note = await call("POST", "/api/courses/sample/notes", { cookie: owner, body: { lessonId: "m1-video", anchorKind: "slide", anchorValue: 2, deckId: "d1", body: "Slide two matters" } });
expect(note.status).toBe(201);
// 5. run the sample exercise with the learner's runner, then save the passing attempt
const lesson = (await (await call("GET", "/api/courses/sample/lessons/m2-ex-add", { cookie: owner })).json()) as { browser: { starter: string; tests: string } };
const runner = createBrowserRunner("js");
const failing = await runner.run(lesson.browser.starter, lesson.browser.tests, 5000);
expect(failing.some((t) => !t.passed)).toBe(true);
const code = lesson.browser.starter.replace("a - b", "a + b");
const results = await runner.run(code, lesson.browser.tests, 5000);
expect(results.length).toBeGreaterThan(0);
expect(results.every((t) => t.passed)).toBe(true);
expect((await call("PUT", "/api/courses/sample/lessons/m2-ex-add/attempt", { cookie: owner, body: { code, passed: true, result: results, track: "js" } })).status).toBe(200);
// 6. read progress and notes back
const progress = (await (await call("GET", "/api/courses/sample/progress", { cookie: owner })).json()) as Record<string, { state: string }>;
expect(progress["m1-intro"]?.state).toBe("done");
expect(progress["m2-ex-add"]?.state).toBe("done");
const notes = (await (await call("GET", "/api/courses/sample/notes", { cookie: owner })).json()) as { body: string; anchorKind: string; deckId: string }[];
expect(notes.map((n) => [n.body, n.anchorKind, n.deckId])).toEqual([["Slide two matters", "slide", "d1"]]);
// 7. a second account is a plain learner: own data only, and the owner/author features refuse it
const lea = await signUp("[email protected]", "Lea");
expect(await (await call("GET", "/api/courses/sample/progress", { cookie: lea })).json()).toEqual({});
expect((await call("GET", "/api/import/info", { cookie: lea })).status).toBe(403);
expect((await call("GET", "/api/studio/courses", { cookie: lea })).status).toBe(403);
expect((await call("GET", "/api/ai/admin/config", { cookie: lea })).status).toBe(403);
expect((await call("GET", "/api/import/info", { cookie: owner })).status).toBe(200);
expect((await call("GET", "/api/studio/courses", { cookie: owner })).status).toBe(200);
expect((await call("GET", "/api/ai/status", { cookie: owner })).status).toBe(200);
// 8. media: Range, nosniff
const clip = await call("GET", "/api/media/sample/media/clip.mp4", { cookie: lea });
expect(clip.headers.get("x-content-type-options")).toBe("nosniff");
// 9. the AI draft sink: studio-managed course -> the author's draft branch, as that user; other courses -> files
const studio = studioFeature(s.ctx, { watch: false });
try {
await studio.store.createCourse({ id: "gen-target", title: "Generated" }, { name: "Ada Lovelace", email: "[email protected]" });
const sink = aiDraftSink(s.ctx, studio);
const onStudio = await sink.writeDraftFiles("gen-target", me.user.id, [{ path: "lessons/q.md", content: "# Q\n" }], "AI draft");
expect(onStudio.branch).toMatch(/^draft\//);
const repo = join(config.studioReposDir, "gen-target.git");
const author = (await Bun.$`git --git-dir=${repo} log -1 ${"--format=%an <%ae>"} ${onStudio.branch!}`.text()).trim();
expect(author).toBe("Ada Lovelace <[email protected]>");
const onFiles = await sink.writeDraftFiles("sample", me.user.id, [{ path: "q.md", content: "# Q\n" }], "AI draft");
expect(onFiles.branch).toBeUndefined();
expect(onFiles.location!.startsWith(resolve(dataDir, "drafts", "sample"))).toBe(true);
expect(readFileSync(join(onFiles.location!, "q.md"), "utf8")).toBe("# Q\n");
} finally {
studio.stop();
}
} finally {
await s.stop();
}
}
describe("end to end", () => {
test("sign up, mark done, slide note, exercise, progress [sqlite]", () => run("sqlite://:memory:"), 60_000);
const pg = PG;
if (pg) {
test("sign up, mark done, slide note, exercise, progress [postgres]", async () => {
const { url, drop } = await pgSchema(pg);
try { await run(url); } finally { await drop(); }
}, 60_000);
}
});
+13
View File
@@ -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<void> }> {
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(); } };
}
+4 -2
View File
@@ -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<ReturnType<typeof startServer>>;
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, []);
+25
View File
@@ -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);
+5 -4
View File
@@ -1,8 +1,9 @@
import type { WebFeature } from "@stc/contracts";
// Browser-only entries ("@stc/<package>/web", mapped in tsconfig paths and package.json exports). Never import a
// package's server index here: it pulls Better Auth, git and node:fs into the bundle.
import { learnerWeb } from "@stc/feature-learner/web";
import { importWeb } from "../../../packages/feature-import/src/web";
import { studioWeb } from "../../../packages/feature-studio/src/web";
import { aiWeb } from "../../../packages/feature-ai/src/web";
import { importWeb } from "@stc/feature-import/web";
import { studioWeb } from "@stc/feature-studio/web";
import { aiWeb } from "@stc/feature-ai/web";
// Phase 1 packages append one import line each and push into this list.
export const webFeatures: WebFeature[] = [learnerWeb, importWeb, studioWeb, aiWeb];
+21 -3
View File
@@ -1,15 +1,33 @@
import { createRoot } from "react-dom/client";
import { lazy, Suspense } from "react";
import { createBrowserRouter, Link, Outlet, RouterProvider, type RouteObject } from "react-router-dom";
import { lazy, Suspense, useEffect, useState } from "react";
import { createBrowserRouter, Link, Outlet, RouterProvider, useLocation, type RouteObject } from "react-router-dom";
import type { AuthUser } from "@stc/contracts";
import { AppShell } from "@stc/ui";
import { webFeatures } from "./features";
import { visibleNav } from "./nav";
function Home() {
return <p className="work">No features installed yet</p>;
}
/** 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<AuthUser | null>(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 = (
<>
<Link to="/" style={{ fontWeight: 600, color: "var(--head)", textDecoration: "none" }}>STCourses</Link>
+21
View File
@@ -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);
});
+15
View File
@@ -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<AuthUser, "roles"> | 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))];
}
+1
View File
@@ -3,3 +3,4 @@ export * from "./exercise";
export * from "./ai";
export * from "./import";
export * from "./web";
export * from "./routes";
+14
View File
@@ -0,0 +1,14 @@
// The learner's public page routes. The learner package registers these patterns; every other package (AI
// citations, studio links) builds links with the helpers, so a link always matches a real route.
// Pure functions with no dependencies, the only runtime code in @stc/contracts.
export const COURSE_ROUTE = "/c/:courseId";
export const LESSON_ROUTE = "/c/:courseId/:lessonId";
export function courseUrl(courseId: string): string {
return `/c/${encodeURIComponent(courseId)}`;
}
export function lessonUrl(courseId: string, lessonId: string): string {
return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`;
}
+26 -2
View File
@@ -7,7 +7,12 @@ export interface Feature {
name: string; // "learner" | "import" | "studio" | "ai" | "core"
routes: Record<string, Partial<Record<"GET"|"POST"|"PUT"|"PATCH"|"DELETE", RouteHandler>>>; // paths start with /api/
jobs?: Record<string, JobHandler>; // job kind -> handler
auth?: AuthService; // replaces the anonymous AuthService (the learner package supplies it)
stop?(): void | Promise<void>; // called when the server stops (timers, watchers)
}
/** Features are built after the ServerContext exists. The job queue is built from the union of their `jobs`, so a
* factory must not use `ctx.jobs` synchronously (only while a request or job runs). */
export type FeatureFactory = (ctx: ServerContext) => Feature;
export interface JobContext { reportProgress(fraction: number, message?: string): Promise<void>; signal: AbortSignal }
export type JobHandler = (payload: unknown, jc: JobContext) => Promise<unknown>;
export type JobStatus = "queued" | "running" | "done" | "failed";
@@ -20,8 +25,27 @@ export interface ServerContext {
jobs: JobQueue; auth: AuthService;
}
export interface AppConfig {
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, media/, worktrees/
courseDirs: string[]; // folders scanned for stcourse.yaml (course repos and overlays/)
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, courses/, studio/, worktrees/
/** Folders scanned for stcourse.yaml: STC_COURSE_DIRS, plus importedCoursesDir and studioWorktreesDir (deduplicated). */
courseDirs: string[];
secret: string; // STC_SECRET, used to encrypt BYOK keys
publicUrl: string;
/** `<dataDir>/courses`: the importer's plain course folders. */
importedCoursesDir: string;
/** `<dataDir>/studio/repos`: studio bare repos (`<id>.git`), never scanned. */
studioReposDir: string;
/** `<dataDir>/worktrees`: studio published worktrees (`<id>/published`); drafts sit one level deeper and are never scanned. */
studioWorktreesDir: string;
/** `<dataDir>/cache`: derived files (decks/, covers/, transcripts/). */
cacheDir: string;
/** STC_IMPORT_ROOTS (":"-separated), default `<dataDir>/import-inbox`: the only folders the importer reads from. */
importRoots: string[];
/** STC_UPLOAD_MAX_MB, default 100. */
uploadMaxMb: number;
/** STC_AI_ALLOWED_BASE_URLS (comma-separated origins) that learners' own AI keys may use besides the official endpoints. */
aiAllowedBaseUrls: string[];
/** STC_TRANSCRIBE_URL: Whisper-compatible endpoint; null disables transcription. */
transcribeUrl: string | null;
/** STC_PYTHON, default python3: Python with PyMuPDF, used for PDF page images and text. */
python: string;
}
+3 -1
View File
@@ -1,4 +1,6 @@
import type React from "react";
import type { Role } from "./server";
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin" } }
/** `nav.roles`: the shell shows the link only to signed-in users holding one of these roles (absent: everyone). */
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin"; roles?: Role[] } }
export interface WebFeature { name: string; routes: WebRoute[] }
+3
View File
@@ -25,6 +25,9 @@ test("contract interfaces accept well-formed literals", async () => {
const config: AppConfig = {
port: 3000, databaseUrl: "sqlite://./data/stcourses.db", dataDir: "./data", courseDirs: ["./overlays"],
secret: "s", publicUrl: "http://localhost:3000",
importedCoursesDir: "./data/courses", studioReposDir: "./data/studio/repos", studioWorktreesDir: "./data/worktrees",
cacheDir: "./data/cache", importRoots: ["./data/import-inbox"], uploadMaxMb: 100, aiAllowedBaseUrls: [],
transcribeUrl: null, python: "python3",
};
const ctx: Pick<ServerContext, "config" | "jobs" | "auth"> = { config, jobs: queue, auth };
+6 -1
View File
@@ -6,12 +6,17 @@ import { serveMedia } from "./media";
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export type Routes = Record<string, Partial<Record<Method, (req: Request) => Response | Promise<Response>>>>;
/**
* The one visibility rule (ruling R14). Every package calls this; none keeps a local variant.
* public: everyone; signed-in: any signed-in user; restricted: the owner, the global `author` role, a per-course
* author (`isAuthor`, a course_authors row), or an ACL grant to the user or one of their groups.
*/
export function canView(user: AuthUser | null, course: LoadedCourse, acl: { principal_type: string; principal_id: string }[], isAuthor = false): boolean {
const vis = course.manifest?.visibility ?? "restricted";
if (vis === "public") return true;
if (!user) return false;
if (vis === "signed-in") return true;
if (user.roles.includes("owner") || isAuthor) return true;
if (user.roles.includes("owner") || user.roles.includes("author") || isAuthor) return true;
return acl.some((a) => (a.principal_type === "user" && a.principal_id === user.id) || (a.principal_type === "group" && user.groups.includes(a.principal_id)));
}
+75
View File
@@ -0,0 +1,75 @@
import type { AppConfig, AuthService, Feature, FeatureFactory, JobHandler, JobQueue, ServerContext } from "@stc/contracts";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate, type PackageMigrations } from "@stc/db";
import { createApp } from "./app";
import { createJobQueue } from "./jobs";
/** Used until a feature supplies an `auth` service (the learner package does). */
const anonymousAuth: AuthService = { userFromRequest: async () => null };
export interface ComposeOptions {
/** Each package's migration folders, keyed by package name; run after core's, in name order. */
migrations?: PackageMigrations;
/** Start polling the job queue (default true). */
startJobs?: boolean;
}
export interface ComposedServer {
ctx: ServerContext;
features: Feature[];
app: ReturnType<typeof createApp>;
stop(): Promise<void>;
}
/**
* Builds the server context and the features (rulings R8/R14). Order:
* 1. connect and migrate (core, then packages);
* 2. load courses from `config.courseDirs`;
* 3. run each factory with the context. `ctx.jobs` already exists as a handle, but it forwards to the real queue,
* which is built in step 4 from the union of the features' `jobs`, so factories may keep it but must not
* enqueue while they run;
* 4. a feature carrying `auth` replaces the anonymous AuthService (at most one may).
*/
export async function composeServer(config: AppConfig, entries: (Feature | FeatureFactory)[], opts: ComposeOptions = {}): Promise<ComposedServer> {
const db = await connect(config.databaseUrl);
try {
await migrate(db, opts.migrations ?? {});
const courses = await createCourseRepository(config.courseDirs);
let queue: (JobQueue & { start(): void; stop(): Promise<void> }) | null = null;
const notYet = (): never => { throw new Error("the job queue is not ready while features are being built"); };
const jobs: JobQueue = {
enqueue: (kind, payload) => (queue ?? notYet()).enqueue(kind, payload),
get: (id) => (queue ?? notYet()).get(id),
list: (kind) => (queue ?? notYet()).list(kind),
};
const ctx: ServerContext = { config, db, courses, jobs, auth: anonymousAuth };
const features = entries.map((f) => (typeof f === "function" ? f(ctx) : f));
const handlers: Record<string, JobHandler> = {};
let authFrom: string | null = null;
for (const f of features) {
for (const [kind, h] of Object.entries(f.jobs ?? {})) {
if (handlers[kind]) throw new Error(`Duplicate job kind "${kind}" (feature "${f.name}")`);
handlers[kind] = h;
}
if (f.auth) {
if (authFrom) throw new Error(`Both "${authFrom}" and "${f.name}" supply an AuthService`);
authFrom = f.name;
ctx.auth = f.auth;
}
}
queue = createJobQueue(db, handlers);
const app = createApp(ctx, features);
if (opts.startJobs !== false) queue.start();
return {
ctx, features, app,
async stop() {
for (const f of features) { try { await f.stop?.(); } catch (e) { console.error(`[stc] stopping ${f.name}`, e); } }
await queue!.stop();
await db.close();
},
};
} catch (e) {
await db.close().catch(() => {});
throw e;
}
}
+72 -3
View File
@@ -1,7 +1,48 @@
import { resolve } from "node:path";
import type { AppConfig } from "@stc/contracts";
const DEV_SECRET = "stc-dev-secret-change-me";
/** The settings a deployment chooses; everything else in AppConfig is derived by `resolveConfig`. */
export type ConfigInput = Pick<AppConfig, "dataDir"> & Partial<Omit<AppConfig, "dataDir">>;
// Path lists are split on ":" only (paths may contain spaces); URL lists are trimmed.
const paths = (raw: string | undefined) => (raw ?? "").split(":").filter(Boolean);
const urls = (raw: string | undefined) => (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
/**
* Fills in the derived parts of the configuration. Course discovery always covers the importer's folders and the
* studio's published worktrees, after the configured dirs, without duplicates (compared as absolute paths).
* Tests and dev entries use this to build an AppConfig the same way `loadConfig` does.
*/
export function resolveConfig(input: ConfigInput): AppConfig {
const dataDir = input.dataDir;
const importedCoursesDir = input.importedCoursesDir ?? resolve(dataDir, "courses");
const studioWorktreesDir = input.studioWorktreesDir ?? resolve(dataDir, "worktrees");
const courseDirs: string[] = [];
for (const d of [...(input.courseDirs ?? []), importedCoursesDir, studioWorktreesDir]) {
if (!courseDirs.some((c) => resolve(c) === resolve(d))) courseDirs.push(d);
}
const port = input.port ?? 3000;
return {
port,
databaseUrl: input.databaseUrl ?? "sqlite://./data/stcourses.db",
dataDir,
courseDirs,
secret: input.secret ?? DEV_SECRET,
publicUrl: input.publicUrl ?? `http://localhost:${port}`,
importedCoursesDir,
studioReposDir: input.studioReposDir ?? resolve(dataDir, "studio", "repos"),
studioWorktreesDir,
cacheDir: input.cacheDir ?? resolve(dataDir, "cache"),
importRoots: input.importRoots ?? [resolve(dataDir, "import-inbox")],
uploadMaxMb: input.uploadMaxMb ?? 100,
aiAllowedBaseUrls: input.aiAllowedBaseUrls ?? [],
transcribeUrl: input.transcribeUrl ?? null,
python: input.python ?? "python3",
};
}
export function loadConfig(env: Record<string, string | undefined> = process.env): AppConfig {
const port = env.STC_PORT !== undefined && env.STC_PORT !== "" ? Number(env.STC_PORT) : 3000;
if (!Number.isInteger(port) || port < 0) throw new Error(`Invalid STC_PORT: ${env.STC_PORT}`);
@@ -11,12 +52,40 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
console.warn("[stc] STC_SECRET is not set; using an insecure development default");
secret = DEV_SECRET;
}
return {
let uploadMaxMb = 100;
if (env.STC_UPLOAD_MAX_MB) {
uploadMaxMb = Number(env.STC_UPLOAD_MAX_MB);
if (!Number.isFinite(uploadMaxMb) || uploadMaxMb <= 0) throw new Error(`Invalid STC_UPLOAD_MAX_MB: ${env.STC_UPLOAD_MAX_MB}`);
}
const dataDir = env.STC_DATA_DIR || "./data";
const roots = paths(env.STC_IMPORT_ROOTS);
return resolveConfig({
port,
databaseUrl: env.STC_DATABASE_URL || "sqlite://./data/stcourses.db",
dataDir: env.STC_DATA_DIR || "./data",
courseDirs: (env.STC_COURSE_DIRS || "./overlays:./courses").split(":").filter(Boolean),
dataDir,
courseDirs: paths(env.STC_COURSE_DIRS || "./overlays:./courses"),
secret,
publicUrl: env.STC_PUBLIC_URL || `http://localhost:${port}`,
importRoots: roots.length ? roots.map((r) => resolve(r)) : undefined,
uploadMaxMb,
aiAllowedBaseUrls: urls(env.STC_AI_ALLOWED_BASE_URLS),
transcribeUrl: env.STC_TRANSCRIBE_URL || null,
python: env.STC_PYTHON || "python3",
});
}
/** The importer's derived-file layout under `cacheDir` (decks, cover candidates, transcripts), shared by every package. */
export function cachePaths(config: Pick<AppConfig, "cacheDir">) {
const root = config.cacheDir;
const seg = (s: string) => {
if (!s || s === "." || s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0")) throw new Error(`invalid cache path segment "${s}"`);
return s;
};
return {
root,
deckDir: (courseId: string, deckId: string) => resolve(root, "decks", seg(courseId), seg(deckId)),
coversDir: (courseId: string) => resolve(root, "covers", seg(courseId)),
transcriptFile: (courseId: string, lessonId: string) => resolve(root, "transcripts", seg(courseId), `${seg(lessonId)}.json`),
transcriptsDir: (courseId: string) => resolve(root, "transcripts", seg(courseId)),
};
}
+3 -2
View File
@@ -1,4 +1,5 @@
export { loadConfig } from "./config";
export { loadConfig, resolveConfig, cachePaths, type ConfigInput } from "./config";
export { createJobQueue } from "./jobs";
export { serveMedia, contentType, contentDisposition } from "./media";
export { serveMedia, contentType, contentDisposition, mediaSecurityHeaders, MEDIA_SANDBOX_CSP } from "./media";
export { createApp, canView, type Routes } from "./app";
export { composeServer, type ComposeOptions, type ComposedServer } from "./compose";
+19 -2
View File
@@ -1,9 +1,23 @@
const TYPES: Record<string, string> = {
mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
svg: "image/svg+xml", md: "text/markdown; charset=utf-8", txt: "text/plain; charset=utf-8", zip: "application/zip",
js: "text/javascript; charset=utf-8", ts: "text/plain; charset=utf-8",
js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8", ts: "text/plain; charset=utf-8",
html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", xhtml: "application/xhtml+xml", xml: "application/xml",
};
/** Types a browser could run script from. Course media is author content on the app origin, so these get a sandbox. */
const ACTIVE = new Set(["svg", "html", "htm", "xhtml", "xml", "js", "mjs"]);
/** Opaque origin, no script, no plugins: an svg still renders as an image, an html page as inert markup. */
export const MEDIA_SANDBOX_CSP = "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'";
/** Headers every media response carries; plus a sandbox CSP for script-capable types. */
export function mediaSecurityHeaders(path: string): Record<string, string> {
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
const h: Record<string, string> = { "X-Content-Type-Options": "nosniff" };
if (ACTIVE.has(ext)) h["Content-Security-Policy"] = MEDIA_SANDBOX_CSP;
return h;
}
export function contentType(path: string): string {
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
return TYPES[ext] ?? "application/octet-stream";
@@ -42,11 +56,14 @@ export async function serveMedia(req: Request, absPath: string, opts: { download
const headers: Record<string, string> = {
"Content-Type": contentType(absPath),
"Accept-Ranges": "bytes",
...mediaSecurityHeaders(absPath),
};
if (opts.download) headers["Content-Disposition"] = contentDisposition(name);
const range = req.headers.get("range");
const parsed = range ? parseRange(range, size) : null;
if (parsed === "invalid") return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes" } });
if (parsed === "invalid" || (parsed && size === 0)) {
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes", "X-Content-Type-Options": "nosniff" } });
}
if (!parsed) {
headers["Content-Length"] = String(size);
return new Response(req.method === "HEAD" ? null : file, { status: 200, headers });
+8
View File
@@ -97,5 +97,13 @@ test("canView matrix", async () => {
const pub = ctx.courses.get("sample")!, priv = ctx.courses.get("secret")!;
expect(canView(null, pub, [])).toBe(true);
expect(canView(null, priv, [])).toBe(false);
const u = (roles: AuthUser["roles"], groups: string[] = []): AuthUser => ({ id: "u1", email: "u@x", name: "U", roles, groups });
expect(canView(u(["learner"]), priv, [])).toBe(false);
expect(canView(u(["owner"]), priv, [])).toBe(true);
expect(canView(u(["author"]), priv, [])).toBe(true); // R14: the global author role views every course
expect(canView(u(["learner"]), priv, [], true)).toBe(true); // per-course author
expect(canView(u(["learner"]), priv, [{ principal_type: "user", principal_id: "u1" }])).toBe(true);
expect(canView(u(["learner"], ["g"]), priv, [{ principal_type: "group", principal_id: "g" }])).toBe(true);
expect(canView(u(["learner"], ["h"]), priv, [{ principal_type: "group", principal_id: "g" }])).toBe(false);
await db.close();
});
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
import { composeServer, resolveConfig } from "../src";
const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses");
const config = () => resolveConfig({ dataDir: mkdtempSync(join(tmpdir(), "stc-compose-")), databaseUrl: "sqlite://:memory:", courseDirs: [FIXTURES] });
test("factories get the context; the queue is built from the union of their jobs; auth and stop are honoured", async () => {
const seen: string[] = [];
let stopped = 0;
const user: AuthUser = { id: "u", email: "u@x", name: "U", roles: ["owner"], groups: [] };
let early: unknown = null;
const a = (ctx: ServerContext): Feature => {
// the handle exists while factories run, but enqueuing must wait for the real queue
try { void ctx.jobs.enqueue("b.work", {}); } catch (e) { early = e; }
return {
name: "a",
routes: { "/api/a": { GET: async (_r, rc) => Response.json({ who: rc.user?.id ?? null, courses: ctx.courses.list().length }) } },
jobs: { "a.work": async (p) => { seen.push(`a:${JSON.stringify(p)}`); return null; } },
auth: { userFromRequest: async () => user },
stop: () => { stopped++; },
};
};
const b = (ctx: ServerContext): Feature => ({
name: "b",
routes: { "/api/b": { POST: async () => Response.json({ id: await ctx.jobs.enqueue("a.work", { n: 1 }) }) } },
jobs: { "b.work": async () => null },
});
const s = await composeServer(config(), [a, b]);
try {
expect(String(early)).toMatch(/not ready/);
expect(await (await s.app.fetch(new Request("http://x/api/a"))).json()).toEqual({ who: "u", courses: 1 });
const { id } = (await (await s.app.fetch(new Request("http://x/api/b", { method: "POST" }))).json()) as { id: string };
for (let i = 0; i < 200 && (await s.ctx.jobs.get(id))?.status !== "done"; i++) await Bun.sleep(10);
expect((await s.ctx.jobs.get(id))?.status).toBe("done");
expect(seen).toEqual(['a:{"n":1}']);
} finally {
await s.stop();
}
expect(stopped).toBe(1);
});
test("duplicate job kinds and two auth services are refused", async () => {
const f = (name: string, extra: Partial<Feature>) => (): Feature => ({ name, routes: {}, ...extra });
await expect(composeServer(config(), [f("x", { jobs: { k: async () => null } }), f("y", { jobs: { k: async () => null } })])).rejects.toThrow(/Duplicate job kind "k"/);
const auth = { userFromRequest: async () => null };
await expect(composeServer(config(), [f("x", { auth }), f("y", { auth })])).rejects.toThrow(/AuthService/);
});
+43 -4
View File
@@ -1,5 +1,6 @@
import { expect, test, spyOn } from "bun:test";
import { loadConfig } from "../src";
import { resolve } from "node:path";
import { cachePaths, loadConfig, resolveConfig } from "../src";
test("defaults", () => {
const warn = spyOn(console, "warn").mockImplementation(() => {});
@@ -7,21 +8,59 @@ test("defaults", () => {
expect(c.port).toBe(3000);
expect(c.databaseUrl).toBe("sqlite://./data/stcourses.db");
expect(c.dataDir).toBe("./data");
expect(c.courseDirs).toEqual(["./overlays", "./courses"]);
// configured dirs first, then the importer's folders and the studio's published worktrees
expect(c.courseDirs).toEqual(["./overlays", "./courses", resolve("./data/courses"), resolve("./data/worktrees")]);
expect(c.importedCoursesDir).toBe(resolve("./data/courses"));
expect(c.studioReposDir).toBe(resolve("./data/studio/repos"));
expect(c.studioWorktreesDir).toBe(resolve("./data/worktrees"));
expect(c.cacheDir).toBe(resolve("./data/cache"));
expect(c.importRoots).toEqual([resolve("./data/import-inbox")]);
expect(c.uploadMaxMb).toBe(100);
expect(c.aiAllowedBaseUrls).toEqual([]);
expect(c.transcribeUrl).toBeNull();
expect(c.python).toBe("python3");
expect(c.publicUrl).toBe("http://localhost:3000");
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
test("colon splitting and overrides", () => {
const c = loadConfig({ STC_PORT: "8080", STC_COURSE_DIRS: "/a b:/c", STC_SECRET: "s", STC_PUBLIC_URL: "https://x.test" });
const c = loadConfig({
STC_PORT: "8080", STC_COURSE_DIRS: "/a b:/c", STC_SECRET: "s", STC_PUBLIC_URL: "https://x.test", STC_DATA_DIR: "/d",
STC_IMPORT_ROOTS: "/in box:/other", STC_UPLOAD_MAX_MB: "5", STC_AI_ALLOWED_BASE_URLS: "http://gw.lan:8080, https://x.test",
STC_TRANSCRIBE_URL: "http://whisper.lan/v1/audio/transcriptions", STC_PYTHON: "/opt/py/bin/python",
});
expect(c.port).toBe(8080);
expect(c.courseDirs).toEqual(["/a b", "/c"]);
expect(c.courseDirs).toEqual(["/a b", "/c", "/d/courses", "/d/worktrees"]);
expect(c.secret).toBe("s");
expect(c.publicUrl).toBe("https://x.test");
expect(c.importRoots).toEqual(["/in box", "/other"]);
expect(c.uploadMaxMb).toBe(5);
expect(c.aiAllowedBaseUrls).toEqual(["http://gw.lan:8080", "https://x.test"]);
expect(c.transcribeUrl).toBe("http://whisper.lan/v1/audio/transcriptions");
expect(c.python).toBe("/opt/py/bin/python");
});
test("configured dirs that already name the derived dirs are not repeated", () => {
const c = loadConfig({ STC_SECRET: "s", STC_DATA_DIR: "/d", STC_COURSE_DIRS: "/d/courses:/x" });
expect(c.courseDirs).toEqual(["/d/courses", "/x", "/d/worktrees"]);
expect(resolveConfig({ dataDir: "/d", courseDirs: ["/d/worktrees/"] }).courseDirs).toEqual(["/d/worktrees/", "/d/courses"]);
});
test("invalid upload cap throws", () => {
expect(() => loadConfig({ STC_SECRET: "s", STC_UPLOAD_MAX_MB: "lots" })).toThrow(/STC_UPLOAD_MAX_MB/);
});
test("production without a secret throws", () => {
expect(() => loadConfig({ NODE_ENV: "production" })).toThrow(/STC_SECRET/);
expect(loadConfig({ NODE_ENV: "production", STC_SECRET: "x" }).secret).toBe("x");
});
test("cachePaths: the importer's layout, no escaping segments", () => {
const p = cachePaths({ cacheDir: "/d/cache" });
expect(p.deckDir("c", "deck1")).toBe("/d/cache/decks/c/deck1");
expect(p.coversDir("c")).toBe("/d/cache/covers/c");
expect(p.transcriptFile("c", "m1-l1")).toBe("/d/cache/transcripts/c/m1-l1.json");
expect(() => p.coversDir("..")).toThrow();
expect(() => p.deckDir("c", "a/b")).toThrow();
});
+33
View File
@@ -57,3 +57,36 @@ test("non-ASCII filename gets a valid filename*", async () => {
test("missing file is 404", async () => {
expect((await serveMedia(req(), join(dir, "nope.mp4"))).status).toBe(404);
});
test("every response is nosniff; script-capable types get a sandbox CSP", async () => {
const d = mkdtempSync(join(tmpdir(), "stc-media-csp-"));
const { MEDIA_SANDBOX_CSP } = await import("../src");
for (const [name, body, sandboxed] of [
["x.svg", "<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>", true],
["page.html", "<script>alert(1)</script>", true],
["Page.HTM", "<p>x</p>", true],
["code.js", "alert(1)", true],
["mod.mjs", "export {}", true],
["notes.md", "# hi", false],
["clip.mp4", "x", false],
] as const) {
writeFileSync(join(d, name), body);
for (const r of [await serveMedia(req(), join(d, name)), await serveMedia(req("bytes=0-0"), join(d, name))]) {
expect(r.headers.get("x-content-type-options")).toBe("nosniff");
expect([name, r.headers.get("content-security-policy")]).toEqual([name, sandboxed ? MEDIA_SANDBOX_CSP : null]);
}
}
expect(MEDIA_SANDBOX_CSP).toMatch(/^sandbox;/);
expect(MEDIA_SANDBOX_CSP).not.toContain("allow-scripts");
});
test("a range on an empty file is 416, never a negative Content-Range", async () => {
const d = mkdtempSync(join(tmpdir(), "stc-media-empty-"));
writeFileSync(join(d, "empty.txt"), "");
for (const range of ["bytes=0-", "bytes=-5", "bytes=0-0"]) {
const r = await serveMedia(req(range), join(d, "empty.txt"));
expect(r.status).toBe(416);
expect(r.headers.get("content-range")).toBe("bytes */0");
}
expect((await serveMedia(req(), join(d, "empty.txt"))).status).toBe(200);
});
+4
View File
@@ -6,5 +6,9 @@
"license": "AGPL-3.0-only",
"dependencies": {
"zod": "^4.6.7"
},
"exports": {
".": "./src/index.ts",
"./markdown": "./src/markdown.ts"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
export { connect, type Db } from "./connect";
export { migrate } from "./migrate";
export { migrate, type PackageMigrations } from "./migrate";
export { progressRepo, type LessonProgress, type ProgressState } from "./repos/progress";
export { notesRepo, type Note, type NoteInput } from "./repos/notes";
export { attemptsRepo, type Attempt } from "./repos/attempts";
+25 -10
View File
@@ -9,19 +9,34 @@ function statements(text: string): string[] {
return noComments.split(";").map((s) => s.trim()).filter(Boolean);
}
export async function migrate(db: Db): Promise<string[]> {
/** Migration folders contributed by feature packages, keyed by package name. */
export type PackageMigrations = Record<string, string[]>;
/**
* Applies pending migrations: core (`packages/db/migrations`) first, then each package's folders in alphabetical
* order of package name, files in name order within a folder. `schema_migrations` is keyed by file name, so file
* names must be unique across packages (each package prefixes its files, e.g. `0001_learner_auth.sql`).
*/
export async function migrate(db: Db, packages: PackageMigrations = {}): Promise<string[]> {
await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name));
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")).sort();
const dirs = [MIGRATIONS_DIR, ...Object.keys(packages).sort().flatMap((k) => packages[k]!)];
const seen = new Map<string, string>();
const applied: string[] = [];
for (const name of files) {
if (done.has(name)) continue;
const stmts = statements(readFileSync(join(MIGRATIONS_DIR, name), "utf8"));
await db.sql.begin(async (tx) => {
for (const s of stmts) await tx.unsafe(s);
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`;
});
applied.push(name);
for (const dir of dirs) {
for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
const other = seen.get(name);
if (other && other !== dir) throw new Error(`migration ${name} exists in both ${other} and ${dir}`);
seen.set(name, dir);
if (done.has(name)) continue;
const stmts = statements(readFileSync(join(dir, name), "utf8"));
await db.sql.begin(async (tx) => {
for (const s of stmts) await tx.unsafe(s);
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`;
});
done.add(name);
applied.push(name);
}
}
return applied;
}
+19
View File
@@ -30,3 +30,22 @@ forEachDb("all tables exist", async (db) => {
await one("collection_courses", s`INSERT INTO collection_courses VALUES ('co','c',0)`);
await one("jobs", s`INSERT INTO jobs VALUES ('j','k','queued',0,NULL,'{}',NULL,NULL,${t},${t})`);
});
forEachDb("package migrations run after core, in package-name order, once", async (db) => {
const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs");
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const root = mkdtempSync(join(tmpdir(), "stc-mig-"));
const a = join(root, "a"), z = join(root, "z");
mkdirSync(a); mkdirSync(z);
// z's table is referenced by a's second file only through ordering by package name: "alpha" runs before "zeta".
writeFileSync(join(z, "0001_zeta.sql"), "CREATE TABLE zeta_t (id TEXT PRIMARY KEY);");
writeFileSync(join(a, "0001_alpha.sql"), "CREATE TABLE alpha_t (id TEXT PRIMARY KEY);");
writeFileSync(join(a, "0002_alpha.sql"), "INSERT INTO alpha_t VALUES ('x');");
expect(await migrate(db, { zeta: [z], alpha: [a] })).toEqual(["0001_alpha.sql", "0002_alpha.sql", "0001_zeta.sql"]);
expect(await migrate(db, { zeta: [z], alpha: [a] })).toEqual([]);
expect((await db.sql`SELECT id FROM alpha_t`).length).toBe(1);
// the same file name in two packages is refused rather than silently skipped
writeFileSync(join(z, "0001_alpha.sql"), "SELECT 1;");
await expect(migrate(db, { zeta: [z], alpha: [a] })).rejects.toThrow(/exists in both/);
});
+5 -7
View File
@@ -24,8 +24,6 @@ export interface AiFeatureOptions {
/** Overrides provider construction (tests use a fake AiProvider). */
providerFactory?: (cfg: ProviderConfig, apiKey: string | null) => AiProvider;
mcpTimeoutMs?: number;
/** Environment to read STC_AI_ALLOWED_BASE_URLS from. Default: process.env. */
env?: Record<string, string | undefined>;
/** Server-side log for provider errors that are not shown to users. Default: console.error. */
log?: (message: string) => void;
}
@@ -147,8 +145,9 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
const progress = progressRepo(ctx.db);
const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir);
const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`));
const env = opts.env ?? process.env;
const allowedOrigins = async () => [...new Set([...parseAllowedOrigins(env.STC_AI_ALLOWED_BASE_URLS ?? ""), ...(await settings.getServer()).allowedBaseUrls])];
// AppConfig.aiAllowedBaseUrls (STC_AI_ALLOWED_BASE_URLS) plus the admin's list in the server AI settings.
const configOrigins = parseAllowedOrigins((ctx.config.aiAllowedBaseUrls ?? []).join(","));
const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.getServer()).allowedBaseUrls])];
/**
* Providers for a user's own key never follow redirects, and their errors reach the user only as the HTTP
* status plus a fixed sentence (the full text is logged here). Server-scope providers are the admin's own.
@@ -186,9 +185,8 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
};
/**
* Generation and the studio file listing: the user must hold an authoring right (owner, the `author` role, or a
* `course_authors` row) AND see the course under core's `canView`, the same visibility rule as everywhere else.
* A global `author` therefore cannot reach a restricted course without an ACL grant or a course_authors row
* (see contract request 8).
* `course_authors` row) AND see the course under core's `canView`, the one visibility rule (R14; it lets the
* global `author` role view every course).
*/
const canAuthor = async (user: AuthUser, course: LoadedCourse) => {
const isAuthorRow = await acl.isAuthor(course.id, user.id);
+5 -1
View File
@@ -178,7 +178,11 @@ export default function GeneratePage() {
{job.status === "failed" ? <p className="ai-error">{job.error}</p> : null}
{job.result ? (
<>
<span className="ai-hint">{job.result.branch ? "Committed to your draft branch " : "Saved as a draft at "}<span className="ai-mono">{job.result.location}</span>. Review it in the studio before publishing.</span>
{job.result.branch ? (
<span className="ai-hint">Committed to your draft branch <span className="ai-mono">{job.result.location}</span>. Review it in the studio before publishing.</span>
) : (
<span className="ai-hint">This course is not edited in the studio, so the draft was saved as files on the server at <span className="ai-mono">{job.result.location}</span>. Nothing in the course changed; copy what you want into it yourself.</span>
)}
<ul className="ai-rows">{job.result.paths.map((p) => <li key={p}><span className="ai-path">{p}</span><span className="ai-meta">draft</span></li>)}</ul>
{job.result.warnings.length ? (
<div className="ai-section">
+1 -1
View File
@@ -4,7 +4,7 @@ export const aiWeb: WebFeature = {
name: "ai",
routes: [
{ path: "/c/:courseId/assistant", element: () => import("./AssistantPage") },
{ path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin" } },
{ path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin", roles: ["owner"] } },
{ path: "/account/ai", element: () => import("./AccountAiPage") },
{ path: "/studio/generate", element: () => import("./GeneratePage") },
],
+2 -11
View File
@@ -1,11 +1,2 @@
/**
* Learner URLs the AI pages link to. The learner app (WP-A) owns these routes; keep every builder here so
* Phase 2 can change the shape in one place (contract request 7).
*/
export function courseUrl(courseId: string): string {
return `/c/${encodeURIComponent(courseId)}`;
}
export function lessonUrl(courseId: string, lessonId: string): string {
return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`;
}
// Learner URLs the AI pages link to; the learner owns the routes (shared helpers in @stc/contracts, request 7).
export { courseUrl, lessonUrl } from "@stc/contracts";
+11 -15
View File
@@ -2,8 +2,8 @@ import { expect } from "bun:test";
import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AuthUser, JobRecord, ServerContext } from "@stc/contracts";
import { createApp, createJobQueue } from "@stc/core";
import type { AppConfig, AuthUser, JobRecord, ServerContext } from "@stc/contracts";
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import type { Db } from "@stc/db";
import { forEachDb } from "../../db/test/helpers";
@@ -18,7 +18,7 @@ const USERS: Record<string, AuthUser> = {
auth: { id: "auth", email: "w@x", name: "Writer", roles: ["author"], groups: [] },
};
async function setup(db: Db, turns: Parameters<typeof fakeProvider>[0] = [() => ({ text: "ok" })], extra: Partial<AiFeatureOptions> = {}) {
async function setup(db: Db, turns: Parameters<typeof fakeProvider>[0] = [() => ({ text: "ok" })], extra: Partial<AiFeatureOptions> = {}, configOverrides: Partial<AppConfig> = {}) {
const dir = mkdtempSync(join(tmpdir(), "stc-ai-feat-"));
cpSync(SAMPLE, join(dir, "courses/sample"), { recursive: true });
cpSync(SAMPLE, join(dir, "courses/secret"), { recursive: true });
@@ -29,7 +29,7 @@ async function setup(db: Db, turns: Parameters<typeof fakeProvider>[0] = [() =>
const keys: (string | null)[] = [];
const ctx = {
db, courses,
config: { port: 0, databaseUrl: "", dataDir: join(dir, "data"), courseDirs: [], secret: "test-secret", publicUrl: "" },
config: resolveConfig({ port: 0, databaseUrl: "", dataDir: join(dir, "data"), courseDirs: [], secret: "test-secret", publicUrl: "", ...configOverrides }),
auth: { userFromRequest: async (req: Request) => USERS[req.headers.get("x-user") ?? ""] ?? null },
} as unknown as ServerContext;
const feature = aiFeature(ctx, { providerFactory: (_cfg, key) => { keys.push(key); return fake.provider; }, ...extra });
@@ -159,7 +159,7 @@ forEachDb("generation is for authors and writes drafts, not course files", async
// SSRF guard: a learner's own key (BYOK) may only go to the official endpoints or admin-allowed origins.
forEachDb("BYOK base URLs are limited to official endpoints and allowed origins", async (db) => {
const { call } = await setup(db, undefined, { env: { STC_AI_ALLOWED_BASE_URLS: "http://ollama.internal:11434, https://llm.example.org" } });
const { call } = await setup(db, undefined, {}, { aiAllowedBaseUrls: ["http://ollama.internal:11434", "https://llm.example.org"] });
const put = (body: unknown) => call("PUT", "/api/ai/account/key", "ann", body);
// Internal or arbitrary hosts are refused for either kind.
for (const baseUrl of ["http://127.0.0.1:8080/v1", "http://169.254.169.254/latest", "http://localhost:11434/v1", "https://api.anthropic.com.evil.test", "http://ollama.internal:11435/v1", "https://ollama.internal:11434/v1", "file:///etc/passwd"]) {
@@ -203,7 +203,7 @@ forEachDb("provider errors on a user's own key show the status and a fixed messa
seen.push({ url: String(input), redirect: init?.redirect });
return new Response(JSON.stringify({ error: { message: "INTERNAL-SECRET-BODY from upstream" } }), { status: 502, headers: { "content-type": "application/json" } });
}) as typeof fetch;
const { call } = await setup(db, undefined, { providerFactory: undefined, fetch: fakeFetch, log: (m) => logs.push(m), env: { STC_AI_ALLOWED_BASE_URLS: "http://ollama.internal:11434" } });
const { call } = await setup(db, undefined, { providerFactory: undefined, fetch: fakeFetch, log: (m) => logs.push(m) }, { aiAllowedBaseUrls: ["http://ollama.internal:11434"] });
expect((await call("PUT", "/api/ai/account/key", "ann", { kind: "openai", model: "m", apiKey: "k", baseUrl: "http://ollama.internal:11434/v1" })).status).toBe(200);
const text = await (await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" })).text();
expect(text).not.toContain("INTERNAL-SECRET-BODY");
@@ -219,22 +219,18 @@ forEachDb("provider errors on a user's own key show the status and a fixed messa
expect(t2).toContain("502: INTERNAL-SECRET-BODY");
});
forEachDb("authoring uses core canView: a global author needs a grant for a restricted course", async (db) => {
forEachDb("authoring uses core canView: the global author role reaches restricted courses (R14)", async (db) => {
const { call } = await setup(db);
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] });
const body = { courseId: "secret", kind: "questions", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId: "m2-text" }] };
const courses = async (u: string) => ((await (await call("GET", "/api/ai/studio/courses", u)).json()) as { id: string }[]).map((c) => c.id).sort();
// Public course: the author role is enough. Restricted course: not without a grant.
expect(await courses("auth")).toEqual(["sample"]);
expect((await call("GET", "/api/ai/studio/sources?courseId=secret", "auth")).status).toBe(403);
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(403);
// The global author role views every course under core canView, so it may author the restricted one too.
expect(await courses("auth")).toEqual(["sample", "secret"]);
expect((await call("GET", "/api/ai/studio/sources?courseId=secret", "auth")).status).toBe(200);
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(202);
// The owner sees everything.
expect(await courses("owner")).toEqual(["sample", "secret"]);
expect((await call("POST", "/api/ai/studio/generate", "owner", body)).status).toBe(202);
// An ACL grant (core canView) lets the global author in.
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'auth')`;
expect(await courses("auth")).toEqual(["sample", "secret"]);
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(202);
// A course_authors row makes a learner an author of that course only.
expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(403);
await db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'bob')`;
+11 -1
View File
@@ -32,7 +32,7 @@ test("markdown renders code and text without raw HTML", () => {
test("web routes", () => {
expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]);
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin" });
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin", roles: ["owner"] });
});
test("lesson links go through one builder", async () => {
@@ -44,3 +44,13 @@ test("lesson links go through one builder", async () => {
expect(src).toContain("lessonUrl(courseId, c.lessonId)");
expect(src).not.toMatch(/`\/c\//);
});
test("citation links use the learner's registered lesson route", async () => {
const { learnerWeb } = await import("@stc/feature-learner/web");
const { matchPath } = await import("react-router-dom");
const { lessonUrl } = await import("../src/web/urls");
const url = lessonUrl("pure snake", "m1-ü/x");
const hit = learnerWeb.routes.map((r) => matchPath(r.path, url)).find((m) => m && m.pattern.path === "/c/:courseId/:lessonId");
// matchPath leaves params encoded (the router decodes the pathname before matching).
expect(Object.fromEntries(Object.entries(hit?.params ?? {}).map(([k, v]) => [k, decodeURIComponent(v!)]))).toEqual({ courseId: "pure snake", lessonId: "m1-ü/x" });
});
+13 -3
View File
@@ -6,17 +6,24 @@ import { slugify, uniqueId } from "./util/text";
import { extractFrame, type Tools } from "./media";
export interface ImportEnv { [k: string]: string | undefined }
/** `env` supplies only tool paths and transcription credentials (STC_FFMPEG, STC_FFPROBE, STC_TRANSCRIBE_KEY, _MODEL). */
export interface ImportOptions { env?: ImportEnv }
export interface Deps {
ctx: ServerContext;
env: ImportEnv;
tools: Tools;
/** AppConfig.python (STC_PYTHON). */
python: string;
/** AppConfig.importRoots (STC_IMPORT_ROOTS). */
importRoots: string[];
/** AppConfig.transcribeUrl (STC_TRANSCRIBE_URL). */
transcribeUrl: string | null;
}
export const coursesDir = (d: Deps) => resolve(d.ctx.config.dataDir, "courses");
export const cacheDir = (d: Deps) => resolve(d.ctx.config.dataDir, "cache");
export const coursesDir = (d: Deps) => resolve(d.ctx.config.importedCoursesDir ?? resolve(d.ctx.config.dataDir, "courses"));
export const cacheDir = (d: Deps) => resolve(d.ctx.config.cacheDir ?? resolve(d.ctx.config.dataDir, "cache"));
const studioRepo = (d: Deps, id: string) => join(d.ctx.config.studioReposDir ?? resolve(d.ctx.config.dataDir, "studio", "repos"), `${id}.git`);
export const deckCacheDir = (d: Deps, courseId: string, deckId: string) => join(cacheDir(d), "decks", courseId, deckId);
export const coverCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "covers", courseId);
export const transcriptCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "transcripts", courseId);
@@ -27,11 +34,14 @@ export const isPlainId = (s: string) => /^[a-z0-9][a-z0-9-]*$/.test(s);
export async function freshCourseId(d: Deps, wanted: string | undefined, title: string): Promise<string> {
if (wanted !== undefined) {
if (!isPlainId(wanted)) throw new Error(`invalid courseId "${wanted}" (use lowercase letters, digits and dashes)`);
if (await exists(join(coursesDir(d), wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`);
// Studio repos share the id space (ruling R9): refuse an id the studio already uses, even before it is published.
if (await exists(join(coursesDir(d), wanted)) || await exists(studioRepo(d, wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`);
return wanted;
}
const taken = new Set<string>(d.ctx.courses.list().map((c) => c.id));
for (const e of await readdir(coursesDir(d)).catch(() => [] as string[])) taken.add(e);
const repos = d.ctx.config.studioReposDir ?? resolve(d.ctx.config.dataDir, "studio", "repos");
for (const e of await readdir(repos).catch(() => [] as string[])) if (e.endsWith(".git")) taken.add(e.slice(0, -4));
return uniqueId(slugify(title), (x) => taken.has(x));
}
+14 -3
View File
@@ -1,3 +1,4 @@
import { resolve } from "node:path";
import type { Feature, JobHandler, ServerContext } from "@stc/contracts";
import type { Deps, ImportOptions } from "./context";
import { importCodeRepo } from "./jobs/code-repo";
@@ -14,15 +15,22 @@ export { deckCacheDir, coverCacheDir, transcriptCacheDir } from "./context";
/**
* `ctx` is only read when a job runs or a request arrives, never while this function runs, so the server can pass
* its context object before the job queue (which needs these handlers) exists.
* Environment: STC_PYTHON (Python with PyMuPDF, default python3), STC_FFMPEG / STC_FFPROBE, STC_TRANSCRIBE_URL (+ _KEY, _MODEL).
* Config: AppConfig.python, importRoots, transcribeUrl. Environment: STC_FFMPEG / STC_FFPROBE, STC_TRANSCRIBE_KEY / _MODEL.
*/
export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Feature {
const deps = (): Deps => {
const env = opts.env ?? process.env;
return { ctx, env, tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" }, python: env.STC_PYTHON || "python3" };
const c = ctx.config;
return {
ctx, env,
tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" },
python: c.python || "python3",
importRoots: c.importRoots?.length ? c.importRoots : [resolve(c.dataDir, "import-inbox")],
transcribeUrl: c.transcribeUrl ?? null,
};
};
const job = (fn: (d: Deps, p: unknown, jc: Parameters<JobHandler>[1]) => Promise<unknown>): JobHandler => (p, jc) => fn(deps(), p, jc);
ensureRoots({ ctx, env: opts.env ?? process.env });
ensureRoots(deps());
return {
name: "import",
routes: importRoutes(deps),
@@ -37,3 +45,6 @@ export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Fea
}
export { importWeb } from "./web";
/** The importer adds no tables. */
export const migrationsDirs: string[] = [];
+1 -1
View File
@@ -37,7 +37,7 @@ export async function adoptCover(d: Deps, courseId: string, courseDir: string, c
export async function maybeQueueTranscription(d: Deps, courseId: string, wanted: boolean, warnings: string[]): Promise<void> {
if (!wanted) return;
if (!d.env.STC_TRANSCRIBE_URL) {
if (!d.transcribeUrl) {
warnings.push("transcription requested but STC_TRANSCRIBE_URL is not set; skipped");
return;
}
@@ -33,7 +33,7 @@ export function segmentsToMarkdown(title: string, segments: Segment[], fallbackT
*/
export async function transcribeCourse(d: Deps, payload: unknown, jc: JobContext): Promise<TranscribeResult> {
const p = (payload ?? {}) as Record<string, unknown>;
const url = d.env.STC_TRANSCRIBE_URL;
const url = d.transcribeUrl;
const courseId = optStr(p, "courseId") ?? "";
if (!url) return { courseId, skipped: true, reason: "STC_TRANSCRIBE_URL is not set", transcribed: 0, warnings: [] };
const course = await managedCourse(d, courseId);
+5 -7
View File
@@ -5,19 +5,17 @@ import type { Deps } from "./context";
export const OUTSIDE = "path is outside the allowed import folders";
/** STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
export function importRoots(d: Pick<Deps, "ctx" | "env">): string[] {
const raw = d.env.STC_IMPORT_ROOTS;
const list = raw ? raw.split(":").filter(Boolean) : [resolve(d.ctx.config.dataDir, "import-inbox")];
return list.map((r) => resolve(r));
/** AppConfig.importRoots: STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
export function importRoots(d: Pick<Deps, "importRoots">): string[] {
return d.importRoots.map((r) => resolve(r));
}
export function ensureRoots(d: Pick<Deps, "ctx" | "env">): void {
export function ensureRoots(d: Pick<Deps, "importRoots">): void {
for (const r of importRoots(d)) { try { mkdirSync(r, { recursive: true }); } catch { /* reported when used */ } }
}
/** Real path (symlinks followed) must lie inside the real path of one root. Throws OUTSIDE otherwise. */
export async function assertAllowedPath(d: Pick<Deps, "ctx" | "env">, path: string): Promise<string> {
export async function assertAllowedPath(d: Pick<Deps, "importRoots">, path: string): Promise<string> {
let real: string;
try { real = await realpath(resolve(path)); } catch { throw new Error(OUTSIDE); }
for (const root of importRoots(d)) {
+2 -2
View File
@@ -26,7 +26,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
const d = deps();
const dir = coursesDir(d);
const served = d.ctx.config.courseDirs.some((c) => resolve(c) === dir);
return json({ transcribe: Boolean(d.env.STC_TRANSCRIBE_URL), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
return json({ transcribe: Boolean(d.transcribeUrl), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
}),
},
"/api/import/jobs": {
@@ -42,7 +42,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
const options: Record<string, unknown> = {};
for (const k of OPTION_KEYS) if (body?.options?.[k] !== undefined && body.options[k] !== "") options[k] = body.options[k];
if (kind === "transcribe") {
if (!d.env.STC_TRANSCRIBE_URL) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409);
if (!d.transcribeUrl) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409);
if (typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400);
return json({ id: await d.ctx.jobs.enqueue("import.transcribe", { courseId: options.courseId }) }, 202);
}
+1 -1
View File
@@ -3,5 +3,5 @@ import type { WebFeature } from "@stc/contracts";
/** Browser-safe entry (no server imports): apps/web imports this as "@stc/feature-import/web". */
export const importWeb: WebFeature = {
name: "import",
routes: [{ path: "/admin/import", element: () => import("./web/ImportPage"), nav: { label: "Import", area: "admin" } }],
routes: [{ path: "/admin/import", element: () => import("./web/ImportPage"), nav: { label: "Import", area: "admin", roles: ["owner"] } }],
};
+7 -2
View File
@@ -2,6 +2,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { JobContext, ServerContext } from "@stc/contracts";
import { createCourseRepository } from "@stc/course-format";
import { resolveConfig } from "@stc/core";
import type { Deps } from "../src/context";
import { PYTHON, tmp } from "./fixtures";
@@ -13,10 +14,14 @@ export async function makeEnv(env: Record<string, string | undefined> = {}): Pro
const queued: TestEnv["queued"] = [];
const progress: TestEnv["progress"] = [];
const ctx = {
config: { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "http://x" },
config: resolveConfig({ databaseUrl: "sqlite://:memory:", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "http://x" }),
courses,
jobs: { enqueue: async (kind: string, payload: unknown) => { queued.push({ kind, payload }); return "j1"; }, get: async () => null, list: async () => [] },
} as unknown as ServerContext;
const jc: JobContext = { signal: new AbortController().signal, reportProgress: async (f, m) => { progress.push([f, m]); } };
return { deps: { ctx, env: { STC_IMPORT_ROOTS: tmpdir(), ...env }, tools: { ffmpeg: "ffmpeg", ffprobe: "ffprobe" }, python: PYTHON }, dataDir, queued, jc, progress };
const deps: Deps = {
ctx, env, tools: { ffmpeg: "ffmpeg", ffprobe: "ffprobe" }, python: PYTHON,
importRoots: (env.STC_IMPORT_ROOTS ?? tmpdir()).split(":"), transcribeUrl: env.STC_TRANSCRIBE_URL ?? null,
};
return { deps, dataDir, queued, jc, progress };
}
@@ -58,3 +58,13 @@ test("transcribe option queues the transcription job when a URL is configured",
const r = await importPacktVideo(env.deps, { path: zip, options: { transcribe: true } }, env.jc);
expect(env.queued).toEqual([{ kind: "import.transcribe", payload: { courseId: r.courseId } }]);
});
test("an id the studio already uses (studio/repos/<id>.git) is refused, and never picked automatically", async () => {
const { mkdir } = await import("node:fs/promises");
const env = await makeEnv();
await mkdir(join(env.deps.ctx.config.studioReposDir, "taken.git"), { recursive: true });
const zip = await makePacktZip(await tmp());
await expect(importPacktVideo(env.deps, { path: zip, options: { title: "X", courseId: "taken" } }, env.jc)).rejects.toThrow(/already exists/);
const r = await importPacktVideo(env.deps, { path: zip, options: { title: "Taken" } }, env.jc);
expect(r.courseId).toBe("taken-2");
});
+8 -3
View File
@@ -2,7 +2,7 @@ import { expect } from "bun:test";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AuthUser, ServerContext } from "@stc/contracts";
import { createApp, createJobQueue } from "@stc/core";
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { forEachDb } from "../../db/test/helpers";
import { importFeature } from "../src";
@@ -16,11 +16,16 @@ async function setup(db: any, env: Record<string, string | undefined> = {}) {
const dataDir = await tmp("stc-data-");
const courses = await createCourseRepository([join(dataDir, "courses")]);
const ctx = {
config: { port: 0, databaseUrl: "", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "" },
config: resolveConfig({
databaseUrl: "", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "",
python: process.env.STC_PYTHON || "python3",
importRoots: "STC_IMPORT_ROOTS" in env ? (env.STC_IMPORT_ROOTS ? [env.STC_IMPORT_ROOTS] : undefined) : [tmpdir()],
transcribeUrl: env.STC_TRANSCRIBE_URL ?? null,
}),
db, courses,
auth: { userFromRequest: async (req: Request) => ({ owner, learner }[req.headers.get("x-user") ?? ""] ?? null) },
} as unknown as ServerContext;
const feature = importFeature(ctx, { env: { STC_PYTHON: process.env.STC_PYTHON, STC_IMPORT_ROOTS: tmpdir(), ...env } });
const feature = importFeature(ctx);
const queue = createJobQueue(db, feature.jobs!, { pollMs: 10 });
ctx.jobs = queue;
const app = createApp(ctx, [feature]);
+1 -1
View File
@@ -13,7 +13,7 @@
},
"exports": {
".": "./src/index.ts",
"./web": "./web/src/index.ts"
"./web": "./src/web/index.ts"
},
"devDependencies": {
"@types/sanitize-html": "^2.16.2"
+1
View File
@@ -1,6 +1,7 @@
// Server
export { migrationsDirs, runMigrations } from "./server/migrate";
export { learnerFeature } from "./server/feature";
export { userIdentity } from "./server/identity";
// Browser (also available without the server half as "@stc/feature-learner/web")
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
// Shared JSON shapes
@@ -30,8 +30,8 @@ export function canEdit(user: AuthUser | null, courseId: string, idx: AccessInde
/**
* Learners see courses with a manifest that core `canView` allows; owners also see broken ones. The author input
* is per-course authorship only, the same as core's media route, so every route applies one rule. (The global
* `author` role grants viewing only once Phase 2 extends `canView`; contract request learner §6.)
* is per-course authorship (course_authors), the same as core's media route; core `canView` itself lets the owner
* and the global `author` role view every course (R14), so every route applies one rule.
*/
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
if (!c.manifest) return isOwner(user);
@@ -7,6 +7,7 @@ import type {
} from "../shared/types";
import { mediaUrl } from "../shared/types";
import { accessIndex, courseFor, isOwner, visible } from "./access";
import { coverUrlFor } from "./covers";
import { moduleLocks } from "./gating";
import { json, notFound, param } from "./http";
@@ -93,7 +94,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
const doneCount = lessons.filter((l) => progress[l.id]?.state === "done").length;
return {
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [],
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
coverUrl: coverUrlFor(ctx, m),
accent: m.accent ?? null, formats: formatsOf(m), lessonCount: lessons.length, minutes,
collections: m.collections ?? [], tracks: (m.tracks ?? []).map((t) => t.label), source: m.source?.kind ?? null,
addedAt: addedAt(c), status: c.status, progress: lessons.length ? doneCount / lessons.length : 0, doneCount,
@@ -204,7 +205,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
}));
const payload: CoursePayload = {
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [], accent: m.accent ?? null,
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
coverUrl: coverUrlFor(ctx, m),
tracks: m.tracks ?? [], gating: m.gating, source: m.source?.kind ?? null, status: course.status,
modules, progress, canEdit: found.canEdit, signedIn: !!rc.user,
diagnostics: found.canEdit ? course.diagnostics.map((d) => ({ level: d.level, lessonId: d.lessonId, path: d.path, message: d.message })) : [],
@@ -0,0 +1,47 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import type { Feature, ServerContext } from "@stc/contracts";
import { cachePaths, serveMedia } from "@stc/core";
import type { Manifest } from "@stc/course-format";
import { mediaUrl } from "../shared/types";
import { courseFor } from "./access";
import { notFound } from "./http";
const CANDIDATE = /^(frame-\d+|deck-[A-Za-z0-9._-]+)\.jpg$/;
/** The importer's first cover candidate (`<cacheDir>/covers/<courseId>/frame-N.jpg | deck-<id>.jpg`), if any. */
export function coverCandidate(ctx: ServerContext, courseId: string): string | null {
let dir: string;
try { dir = cachePaths(ctx.config).coversDir(courseId); } catch { return null; }
let names: string[];
try { names = readdirSync(dir).filter((n) => CANDIDATE.test(n)); } catch { return null; }
if (!names.length) return null;
// Prefer video frames (the importer's own first choice), then deck covers; numeric order within frames.
names.sort((a, b) => (a.startsWith("frame-") === b.startsWith("frame-") ? a.localeCompare(b, "en", { numeric: true }) : a.startsWith("frame-") ? -1 : 1));
return join(dir, names[0]!);
}
export const coverRoute = (courseId: string) => `/api/courses/${encodeURIComponent(courseId)}/cover`;
/** The manifest's cover if it resolves inside the course; else the importer's cached candidate; else null. */
export function coverUrlFor(ctx: ServerContext, m: Manifest): string | null {
if (m.cover && ctx.courses.resolvePath(m.id, m.cover)) return mediaUrl(m.id, m.cover);
return coverCandidate(ctx, m.id) ? coverRoute(m.id) : null;
}
/** Serves the cached cover candidate to users who may view the course (core canView; 404 otherwise). */
export function coverRoutes(ctx: ServerContext): Feature["routes"] {
return {
"/api/courses/:courseId/cover": {
GET: async (req, rc) => {
const found = await courseFor(ctx, rc.user, rc.params.courseId ?? "");
if (!found) return notFound();
const file = coverCandidate(ctx, found.course.id);
if (!file) return notFound();
const res = await serveMedia(req, file);
res.headers.set("Cache-Control", "private, max-age=300");
return res;
},
},
};
}
+5 -11
View File
@@ -1,9 +1,9 @@
import type { AuthService, Feature, RouteHandler, ServerContext } from "@stc/contracts";
import type { AuthService, Feature, ServerContext } from "@stc/contracts";
import { createAuth } from "./auth";
import { runMigrations } from "./migrate";
import { json } from "./http";
import { adminRoutes } from "./admin";
import { libraryRoutes } from "./catalog";
import { coverRoutes } from "./covers";
import { lessonRoutes } from "./lesson";
import { userDataRoutes } from "./userdata";
import { codeRoutes } from "./code";
@@ -16,9 +16,8 @@ import { runnerFrameRoutes } from "./runnerFrame";
* Returns the routes plus the `AuthService` that replaces the server's anonymous one.
*/
export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthService } {
const ready = runMigrations(ctx.db);
ready.catch((e) => console.error("[learner] migrations failed", e));
const auth = createAuth(ctx, ready);
// The server runs this package's migrations (migrationsDirs) in core migrate() before any feature is built.
const auth = createAuth(ctx, Promise.resolve());
const routes: Feature["routes"] = {
"/api/auth/*": {
GET: (req, rc) => auth.handler(req, rc.user),
@@ -30,6 +29,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
},
...adminRoutes(ctx, auth.service.invalidate),
...libraryRoutes(ctx),
...coverRoutes(ctx),
...lessonRoutes(ctx),
...userDataRoutes(ctx),
...codeRoutes(ctx),
@@ -37,11 +37,5 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
...assetRoutes(),
...runnerFrameRoutes(),
};
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
for (const methods of Object.values(routes)) {
for (const [m, h] of Object.entries(methods)) {
(methods as Record<string, RouteHandler>)[m] = async (req, rc) => { await ready; return h!(req, rc); };
}
}
return { name: "learner", routes, auth: auth.service };
}
@@ -0,0 +1,9 @@
import type { Db } from "@stc/db";
/** A user's display name and email (for commit authorship, e.g. the AI generator's studio drafts); null if unknown. */
export async function userIdentity(db: Db, userId: string): Promise<{ name: string; email: string } | null> {
const rows = await db.sql`SELECT name, email FROM auth_user WHERE id = ${userId}`;
const r = rows[0] as { name: string | null; email: string } | undefined;
if (!r) return null;
return { name: r.name?.trim() || r.email, email: r.email };
}
+5 -28
View File
@@ -1,33 +1,10 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Db } from "@stc/db";
import { migrate, type Db } from "@stc/db";
/** Folders holding this package's migrations; Phase 2 wires these into `migrate()`. */
/** Folders holding this package's migrations; the server passes them to core `migrate()` (contract request 2). */
export const migrationsDirs: string[] = [join(import.meta.dir, "..", "..", "migrations")];
function statements(text: string): string[] {
const noComments = text.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n");
return noComments.split(";").map((s) => s.trim()).filter(Boolean);
}
/**
* Applies the learner migrations with the same bookkeeping as `@stc/db`'s `migrate()`
* (table `schema_migrations`, keyed by file name). Safe to call repeatedly.
*/
export async function runMigrations(db: Db, dirs: string[] = migrationsDirs): Promise<string[]> {
await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name));
const applied: string[] = [];
for (const dir of dirs) {
for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
if (done.has(name)) continue;
const stmts = statements(readFileSync(join(dir, name), "utf8"));
await db.sql.begin(async (tx) => {
for (const s of stmts) await tx.unsafe(s);
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`;
});
applied.push(name);
}
}
return applied;
/** Core migrations plus the learner's, through core `migrate()` (tests and tools; the server migrates once at startup). */
export function runMigrations(db: Db): Promise<string[]> {
return migrate(db, { "feature-learner": migrationsDirs });
}
+1 -2
View File
@@ -182,8 +182,7 @@ export function fmtSize(n: number | null | undefined): string {
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`;
export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`;
export { lessonUrl as lessonHref, courseUrl as courseHref } from "@stc/contracts";
export async function copyText(text: string): Promise<boolean> {
try { await navigator.clipboard.writeText(text); return true; } catch {
+4 -3
View File
@@ -1,6 +1,7 @@
import type { WebFeature } from "@stc/contracts";
import { COURSE_ROUTE, LESSON_ROUTE, type WebFeature } from "@stc/contracts";
export { LessonView, type LessonViewProps } from "./lesson/LessonView";
export type { LessonPayload } from "../shared/types";
export { ensureStyles as ensureLearnerStyles } from "./api";
/** Learner routes: library, course pages, lessons, notes and the account pages. */
@@ -8,9 +9,9 @@ export const learnerWeb: WebFeature = {
name: "learner",
routes: [
{ path: "/", element: () => import("./library/LibraryPage") },
{ path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
{ path: COURSE_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
{ path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) },
{ path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
{ path: LESSON_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
{ path: "/account", element: () => import("./account/AccountPage") },
{ path: "/account/admin", element: () => import("./account/AdminPage") },
],
@@ -24,6 +24,8 @@ export interface LessonViewProps {
signedIn?: boolean;
/** Studio preview: no notes, no saved attempts, no progress reports. */
preview?: boolean;
/** URL for a course-relative file in the lesson text (the studio preview points these at draft files). */
mediaUrl?: (courseRel: string) => string;
onProgress?: (state: ProgressState, positionSeconds?: number) => void;
/** An exercise attempt was saved (the server already updated progress). */
onSaved?: () => void;
@@ -85,12 +87,12 @@ function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track
);
}
function useLessonHtml(lesson: LessonPayload, track: string | null): string | null {
function useLessonHtml(lesson: LessonPayload, track: string | null, media?: (courseRel: string) => string): string | null {
return useMemo(() => {
const md = (track && lesson.trackMarkdown[track]) || lesson.markdown;
if (!md) return null;
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true });
}, [lesson, track]);
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true, mediaUrl: media });
}, [lesson, track, media]);
}
function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) {
@@ -109,7 +111,7 @@ function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayloa
function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const html = useLessonHtml(lesson, track, p.mediaUrl);
const cmd = localCommandFor(lesson.localCommand, track);
const minutes = lesson.lesson.minutes;
const hasMeta = !!minutes || lesson.tracks.length > 1;
@@ -228,9 +230,9 @@ function useVideoProgress(v: React.RefObject<HTMLVideoElement | null>, p: Lesson
function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const html = useLessonHtml(lesson, track, p.mediaUrl);
const hintsHtml = useMemo(() => (lesson.hints.length
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles })
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, mediaUrl: p.mediaUrl })
: null), [lesson, track]);
const cmd = localCommandFor(lesson.localCommand, track);
return (
+3 -1
View File
@@ -24,6 +24,8 @@ export interface RenderOptions {
lessonFiles?: Record<string, string>;
/** Drop a leading heading (the page shows the lesson title itself). */
stripFirstHeading?: boolean;
/** URL for a course-relative file; default `/api/media/<course>/<path>` (the studio preview serves draft files). */
mediaUrl?: (courseRel: string) => string;
}
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -226,7 +228,7 @@ export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "i
if (rel === null) return null;
const lessonId = kind === "link" ? opts.lessonFiles?.[rel] : undefined;
if (lessonId) return { url: `/c/${encodeURIComponent(opts.courseId)}/${encodeURIComponent(lessonId)}${hash ? `#${hash}` : ""}`, external: false };
return { url: mediaUrl(opts.courseId, rel) + (hash ? `#${hash}` : ""), external: false };
return { url: (opts.mediaUrl ? opts.mediaUrl(rel) : mediaUrl(opts.courseId, rel)) + (hash ? `#${hash}` : ""), external: false };
}
export function renderLessonHtml(md: string, opts: RenderOptions): string {
+3 -3
View File
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AppConfig, JobQueue, ServerContext } from "@stc/contracts";
import { createApp } from "@stc/core";
import { createApp, resolveConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import type { Db } from "@stc/db";
import { learnerFeature } from "../src/server/feature";
@@ -13,10 +13,10 @@ export const ORIGIN = "http://localhost:3000";
const fakeJobs: JobQueue = { enqueue: async () => "j", get: async () => null, list: async () => [] };
export async function makeApp(db: Db, courseDirs: string[] = [FIXTURES]) {
const config: AppConfig = {
const config: AppConfig = resolveConfig({
port: 0, databaseUrl: "", dataDir: mkdtempSync(join(tmpdir(), "stc-learner-")), courseDirs,
secret: "test-secret-0123456789abcdef0123456789", publicUrl: ORIGIN,
};
});
const ctx: ServerContext = {
config, db, courses: await createCourseRepository(courseDirs), jobs: fakeJobs,
auth: { userFromRequest: async () => null },
@@ -0,0 +1,40 @@
import { expect } from "bun:test";
import { join } from "node:path";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1, 0xff, 0xd9]);
forEachDb("the library falls back to the importer's cover candidate, served only to users who can view the course", async (db) => {
const dir = await extraCourses();
const { call, signUp, ctx } = await makeApp(db, [FIXTURES, dir]);
const covers = (id: string) => join(ctx.config.cacheDir, "covers", id);
await Bun.write(join(covers("sample"), "deck-d1.jpg"), JPEG);
await Bun.write(join(covers("sample"), "frame-2.jpg"), JPEG);
await Bun.write(join(covers("sample"), "frame-10.jpg"), new Uint8Array([1]));
await Bun.write(join(covers("secret"), "frame-1.jpg"), JPEG);
await Bun.write(join(covers("sample"), "notes.txt"), "not a cover");
const lib = async (cookie?: string) => ((await (await call("GET", "/api/library", { cookie })).json()) as { courses: { id: string; coverUrl: string | null }[] }).courses;
const sample = (await lib()).find((c) => c.id === "sample")!;
expect(sample.coverUrl).toBe("/api/courses/sample/cover");
const r = await call("GET", "/api/courses/sample/cover");
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toBe("image/jpeg");
expect(r.headers.get("x-content-type-options")).toBe("nosniff");
expect(new Uint8Array(await r.arrayBuffer())).toEqual(JPEG); // frame-2 (frames first, numeric order), not frame-10 or the deck
expect((await (await call("GET", "/api/courses/sample")).json()).coverUrl).toBe("/api/courses/sample/cover");
// restricted: 404 for anonymous users and learners without access, 200 for the owner
const owner = await signUp("[email protected]");
const learner = await signUp("[email protected]");
expect((await call("GET", "/api/courses/secret/cover")).status).toBe(404);
expect((await call("GET", "/api/courses/secret/cover", { cookie: learner })).status).toBe(404);
expect((await call("GET", "/api/courses/secret/cover", { cookie: owner })).status).toBe(200);
expect((await lib(owner)).find((c) => c.id === "secret")!.coverUrl).toBe("/api/courses/secret/cover");
// no candidate: no cover, and the route is 404
expect((await lib(owner)).find((c) => c.id === "gated")!.coverUrl).toBeNull();
expect((await call("GET", "/api/courses/gated/cover", { cookie: owner })).status).toBe(404);
expect((await call("GET", "/api/courses/..%2Fsample/cover", { cookie: owner })).status).toBe(404);
});
@@ -3,12 +3,13 @@ import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
forEachDb("one visibility rule: the global author role alone does not open a restricted course (I1)", async (db) => {
forEachDb("one visibility rule (core canView, R14): owner, the global author role and per-course authors open a restricted course; learners do not", async (db) => {
const dir = await extraCourses();
const { call, signUp } = await makeApp(db, [FIXTURES, dir]);
const owner = await signUp("[email protected]");
const author = await signUp("[email protected]");
const coAuthor = await signUp("[email protected]");
const learner = await signUp("[email protected]");
const users = (await (await call("GET", "/api/admin/users", { cookie: owner })).json()) as { id: string; email: string }[];
const id = (e: string) => users.find((u) => u.email === e)!.id;
expect((await call("PUT", `/api/admin/users/${id("[email protected]")}/roles`, { cookie: owner, body: { roles: ["author"] } })).status).toBe(200);
@@ -16,12 +17,15 @@ forEachDb("one visibility rule: the global author role alone does not open a res
const paths = ["/api/courses/secret", "/api/courses/secret/lessons/s1", "/api/media/secret/a.md"];
for (const p of paths) {
expect([p, (await call("GET", p, { cookie: author })).status]).toEqual([p, 404]);
expect([p, (await call("GET", p, { cookie: author })).status]).toEqual([p, 200]);
expect([p, (await call("GET", p, { cookie: coAuthor })).status]).toEqual([p, 200]);
expect([p, (await call("GET", p, { cookie: learner })).status]).toEqual([p, 404]);
expect([p, (await call("GET", p)).status]).toEqual([p, 404]);
}
const lib = async (c: string) => ((await (await call("GET", "/api/library", { cookie: c })).json()) as { courses: { id: string }[] }).courses.map((x) => x.id);
expect(await lib(author)).not.toContain("secret");
expect(await lib(author)).toContain("secret");
expect(await lib(coAuthor)).toContain("secret");
const search = (await (await call("GET", "/api/library/search?q=hidden", { cookie: author })).json()) as unknown[];
expect(await lib(learner)).not.toContain("secret");
const search = (await (await call("GET", "/api/library/search?q=hidden", { cookie: learner })).json()) as unknown[];
expect(search).toEqual([]);
});
@@ -1,4 +0,0 @@
// Browser entry "@stc/feature-learner/web". The repo maps "@stc/*" to "packages/*/src", so this subpath
// resolves here (tsc and Bun alike). It re-exports the web half only: the package index also carries the
// server (Better Auth, SSO), which cannot be bundled for the browser.
export * from "../../src/web";
@@ -1,4 +1,5 @@
import type { AppConfig } from "@stc/contracts";
import { storeDirs } from "./paths";
import { createStudioStore, StudioError, safeAuthor, type Identity } from "./store";
/** Programmatic draft API for other packages (WP-D's studio generator). Writes only to `draft/<authorId>`, never publishes. */
@@ -16,8 +17,11 @@ export interface DraftWriterOptions {
identity?: (authorId: string) => Promise<Identity | null> | Identity | null;
}
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, opts: DraftWriterOptions = {}): DraftWriter {
const store = createStudioStore({ dataDir: ctx.config.dataDir });
export function createDraftWriter(
ctx: { config: Pick<AppConfig, "dataDir"> & Partial<Pick<AppConfig, "studioReposDir" | "importedCoursesDir" | "studioWorktreesDir">> },
opts: DraftWriterOptions = {},
): DraftWriter {
const store = createStudioStore(storeDirs(ctx.config));
return {
async writeDraftFiles(courseId, authorId, files, message) {
if (!store.isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404);
+20 -11
View File
@@ -1,21 +1,20 @@
import { existsSync, realpathSync } from "node:fs";
import { basename, extname, resolve, sep } from "node:path";
import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
import { serveMedia } from "@stc/core";
import { canView, serveMedia } from "@stc/core";
import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format";
import { aclRepo } from "@stc/db";
import { createDraftWriter, type DraftWriter } from "./draft-writer";
import { studioWorktreesDir } from "./paths";
import { storeDirs, studioWorktreesDir } from "./paths";
import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioError, type FileWrite, type StudioStore } from "./store";
import { createPublishedWatcher, type PublishedWatcher } from "./watch";
export const OUTSIDE_MESSAGE = "This course is managed outside STCourses";
export const IMPORTED_MESSAGE = "Imported course: read-only in the studio for now";
/** Upload cap in bytes: STC_UPLOAD_MAX_MB, default 100 (review I2). */
export function uploadMaxBytes(env: Record<string, string | undefined> = process.env): number {
const mb = Number(env.STC_UPLOAD_MAX_MB ?? "100");
return (Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
/** Upload cap in bytes from AppConfig.uploadMaxMb (STC_UPLOAD_MAX_MB, default 100; review I2). */
export function uploadMaxBytes(mb: number | undefined): number {
return (typeof mb === "number" && Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
}
/** Server-side allowlist by extension for uploads: text, documents, images, media, archives and code. */
@@ -73,19 +72,29 @@ function manifestToYaml(m: unknown): string {
}
export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature {
const store = createStudioStore({ dataDir: ctx.config.dataDir });
const store = createStudioStore(storeDirs(ctx.config));
const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs });
const acl = aclRepo(ctx.db);
const wtDir = resolve(studioWorktreesDir(ctx.config.dataDir));
const wtDir = resolve(ctx.config.studioWorktreesDir ?? studioWorktreesDir(ctx.config.dataDir));
const draftWriter = createDraftWriter(ctx);
const hasRole = (u: AuthUser) => u.roles.includes("owner") || u.roles.includes("author");
const authoredCourses = async (u: AuthUser): Promise<string[]> =>
(await ctx.db.sql`SELECT course_id FROM course_authors WHERE user_id = ${u.id}`).map((r: { course_id: string }) => r.course_id);
const canEdit = async (u: AuthUser, courseId: string) => hasRole(u) || (await acl.isAuthor(courseId, u.id));
/**
* An authoring right (owner, the `author` role, or a course_authors row) and, for a course the server has loaded,
* core `canView` (the one visibility rule, R14). A studio course not loaded yet (e.g. its published manifest is
* broken) has no visibility to check.
*/
const canEdit = async (u: AuthUser, courseId: string) => {
const isAuthor = await acl.isAuthor(courseId, u.id);
if (!hasRole(u) && !isAuthor) return false;
const c = ctx.courses.get(courseId);
return !c || canView(u, c, await acl.listForCourse(courseId), isAuthor);
};
const isStudioCourse = (c: LoadedCourse) => resolve(c.manifestPath).startsWith(wtDir + sep);
const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid`, owner: u.roles.includes("owner") });
const importedRoot = (() => { const d = resolve(ctx.config.dataDir, "courses"); return existsSync(d) ? realpathSync(d) : d; })();
const importedRoot = (() => { const d = resolve(ctx.config.importedCoursesDir ?? resolve(ctx.config.dataDir, "courses")); return existsSync(d) ? realpathSync(d) : d; })();
/** Why a course that is not studio-managed is read-only here. */
const readOnlyMessage = (c: LoadedCourse): string => {
const kind = c.manifest?.source?.kind;
@@ -250,7 +259,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
"/api/studio/courses/:courseId/upload": {
POST: course(async (req, _rc, user, courseId) => {
const max = uploadMaxBytes();
const max = uploadMaxBytes(ctx.config.uploadMaxMb);
const declared = Number(req.headers.get("content-length") ?? "0");
if (declared > max) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`);
const bytes = await readCapped(req, max);
+11 -3
View File
@@ -14,10 +14,18 @@ export function studioReposDir(dataDir: string): string {
/**
* The course dirs the CourseRepository must scan: the configured ones, the importer's plain folders in
* `<dataDir>/courses`, and studio courses through their published worktree in `<dataDir>/worktrees`.
* Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md.
* Core `loadConfig`/`resolveConfig` already include both in `config.courseDirs`; this stays for callers that build
* an AppConfig by hand.
*/
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs">): string[] {
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs"> & Partial<Pick<AppConfig, "importedCoursesDir" | "studioWorktreesDir">>): string[] {
const dirs = config.courseDirs.map((d) => resolve(d));
for (const d of [resolve(config.dataDir, "courses"), resolve(studioWorktreesDir(config.dataDir))]) if (!dirs.includes(d)) dirs.push(d);
const imported = config.importedCoursesDir ?? resolve(config.dataDir, "courses");
const worktrees = config.studioWorktreesDir ?? studioWorktreesDir(config.dataDir);
for (const d of [resolve(imported), resolve(worktrees)]) if (!dirs.includes(d)) dirs.push(d);
return dirs;
}
/** Store options from the server configuration (core AppConfig owns the layout). */
export function storeDirs(config: Pick<AppConfig, "dataDir"> & Partial<Pick<AppConfig, "studioReposDir" | "importedCoursesDir" | "studioWorktreesDir">>) {
return { dataDir: config.dataDir, reposDir: config.studioReposDir, importedDir: config.importedCoursesDir, worktreesDir: config.studioWorktreesDir };
}
+9 -3
View File
@@ -123,6 +123,12 @@ export type Actor = Identity & { id: string; owner?: boolean };
export interface StudioStoreOptions {
dataDir: string;
/** AppConfig.studioReposDir (default `<dataDir>/studio/repos`). */
reposDir?: string;
/** AppConfig.importedCoursesDir (default `<dataDir>/courses`): ids taken by the importer. */
importedDir?: string;
/** AppConfig.studioWorktreesDir (default `<dataDir>/worktrees`). */
worktreesDir?: string;
publishedBranch?: string;
/** Tests only: allow local-path remotes (the HTTP feature never sets this). */
allowLocalRemotes?: boolean;
@@ -132,9 +138,9 @@ export function createStudioStore(opts: StudioStoreOptions) {
const dataDir = opts.dataDir;
const main = opts.publishedBranch ?? "main";
// Studio repos live apart from the importer's plain folders in <dataDir>/courses (controller ruling, review I4).
const reposDir = join(dataDir, "studio", "repos");
const importedDir = join(dataDir, "courses");
const worktreesDir = join(dataDir, "worktrees");
const reposDir = opts.reposDir ?? join(dataDir, "studio", "repos");
const importedDir = opts.importedDir ?? join(dataDir, "courses");
const worktreesDir = opts.worktreesDir ?? join(dataDir, "worktrees");
const remoteGuard = opts.allowLocalRemotes ? ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"] : REMOTE_GUARD;
const paths = (courseId: string) => {
@@ -82,7 +82,7 @@ export function LessonEditor({ ws, lessonId, onGone }: { ws: Workspace; lessonId
</Field>
<div className="studio-preview-wrap" aria-live="off">
<p className="faint" id="preview-label" style={{ margin: "0 0 10px" }}>Preview</p>
<LessonPreview courseId={ws.courseId} markdown={body.content} lesson={lesson} resolveUrl={(rel) => ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
<LessonPreview courseId={ws.courseId} markdown={body.content} lesson={lesson} file={mdPath} resolveCourseUrl={ws.resolveUrl} resolveUrl={(rel) => ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
</div>
</div>
) : null}
@@ -0,0 +1,28 @@
import { useMemo } from "react";
import { extractSection } from "@stc/course-format/markdown";
import { LessonView, type LessonPayload } from "@stc/feature-learner/web";
import type { LessonPreviewProps } from "./preview";
/**
* The studio's live preview, rendered by the learner's real `LessonView` (contract request studio 3) in preview
* mode: no notes, attempts or progress. Draft files (images, links) resolve through the studio's raw-file route.
* Video lessons preview their transcript; exercises preview their instructions.
*/
export default function LearnerLessonPreview(props: LessonPreviewProps) {
const { courseId, markdown, lesson, file, resolveCourseUrl, resolveUrl } = props;
const payload = useMemo<LessonPayload>(() => {
const anchored = lesson?.anchor ? extractSection(markdown, lesson.anchor) : null;
const body = lesson?.anchor && anchored === null ? `> Heading anchor \`#${lesson.anchor}\` was not found in this file.\n\n${markdown}` : anchored ?? markdown;
return {
courseId, courseTitle: "",
lesson: { id: lesson?.id ?? "preview", title: lesson?.title ?? "", type: lesson?.type === "exercise" ? "exercise" : "text", minutes: null },
module: { id: "", title: "Preview", index: 0 },
locked: false,
markdown: body, baseFile: resolveCourseUrl ? file ?? null : null, trackMarkdown: {}, tracks: [],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null,
prev: null, next: null, lessonFiles: {},
};
}, [courseId, markdown, lesson?.id, lesson?.title, lesson?.type, lesson?.anchor, file, resolveCourseUrl]);
// With the file known, links resolve course-relative; otherwise relative to the markdown file (the v1 contract).
return <div className="studio-preview"><LessonView lesson={payload} preview look="reading" mediaUrl={resolveCourseUrl ?? resolveUrl} /></div>;
}
+12 -6
View File
@@ -1,16 +1,21 @@
import type { ComponentType } from "react";
import { lazy, Suspense, type ComponentType } from "react";
import { renderMarkdown } from "./markdown";
/**
* Live-preview adapter. v1 renders with a small built-in Markdown renderer; Phase 2 swaps in the learner
* package's `LessonView` via `setLessonPreview` (docs/contract-requests/studio.md).
* Live-preview adapter. The default is the learner's `LessonView` (./learnerPreview, loaded lazily so the
* lesson renderer is fetched only when an editor opens); `setLessonPreview` can replace it, and the small built-in
* Markdown renderer stays as `SimpleMarkdownPreview` and as the loading fallback.
*/
export interface LessonPreviewProps {
courseId: string;
markdown: string;
lesson?: { id: string; title: string; type: string; anchor?: string } | null;
/** Maps a course-relative path (images, attachments) to a URL that serves the draft file. */
/** Maps a path relative to the markdown file (images, attachments) to a URL that serves the draft file. */
resolveUrl: (rel: string) => string;
/** Course-relative path of the markdown file. */
file?: string;
/** Maps a course-relative path to a URL that serves the draft file (preferred over `resolveUrl` when given). */
resolveCourseUrl?: (courseRel: string) => string;
}
export type LessonPreviewComponent = ComponentType<LessonPreviewProps>;
@@ -18,11 +23,12 @@ export function SimpleMarkdownPreview({ markdown, resolveUrl }: LessonPreviewPro
return <div className="studio-preview prose reading" dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown, { resolveUrl }) }} />;
}
let current: LessonPreviewComponent = SimpleMarkdownPreview;
const LearnerPreview = lazy(() => import("./learnerPreview"));
let current: LessonPreviewComponent = LearnerPreview;
export function setLessonPreview(c: LessonPreviewComponent): void {
current = c;
}
export function LessonPreview(props: LessonPreviewProps) {
const C = current;
return <C {...props} />;
return <Suspense fallback={<SimpleMarkdownPreview {...props} />}><C {...props} /></Suspense>;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { WebFeature } from "@stc/contracts";
export const studioWeb: WebFeature = {
name: "studio",
routes: [
{ path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header" } },
{ path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header", roles: ["owner", "author"] } },
{ path: "/studio/:courseId", element: () => import("./ui/CourseStudio") },
],
};
+4 -4
View File
@@ -1,6 +1,6 @@
import { resolve } from "node:path";
import type { AuthUser, ServerContext } from "@stc/contracts";
import { createApp } from "@stc/core";
import type { AppConfig, AuthUser, ServerContext } from "@stc/contracts";
import { createApp, resolveConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { studioFeature } from "../src/server/feature";
@@ -15,7 +15,7 @@ export const users: Record<string, AuthUser> = {
};
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void) {
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}) {
const dataDir = tempDir();
prepare?.(dataDir);
let dropSchema = async () => {};
@@ -31,7 +31,7 @@ export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite:/
}
const db = await connect(dbUrl);
await migrate(db);
const config = { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x" };
const config = resolveConfig({ port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x", ...configOverrides });
const courses = await createCourseRepository(studioCourseDirs(config));
const ctx = {
config, db, courses, jobs: null as never,
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import LearnerLessonPreview from "../src/ui/learnerPreview";
const md = "# Intro\n\nHello ![pic](img/a.png)\n\n## Part two\n\nOnly this <b>part</b>.\n\n## Part three\n\nNot this.\n";
const draft = (rel: string) => `/api/studio/courses/c1/raw/${rel}`;
test("the studio preview renders through the learner LessonView, with draft URLs", () => {
const html = renderToString(<LearnerLessonPreview courseId="c1" markdown={md} lesson={{ id: "l1", title: "Lesson one", type: "text" }} file="lessons/one.md" resolveCourseUrl={draft} resolveUrl={() => "/wrong"} />);
expect(html).toContain('class="lr lr-lesson"');
expect(html).toContain("Lesson one");
expect(html).toContain('src="/api/studio/courses/c1/raw/lessons/img/a.png"');
expect(html).not.toContain("/api/media/");
});
test("an anchored lesson previews only its section; a missing anchor says so", () => {
const one = renderToString(<LearnerLessonPreview courseId="c1" markdown={md} lesson={{ id: "l1", title: "T", type: "text", anchor: "part-two" }} file="lessons/one.md" resolveCourseUrl={draft} resolveUrl={draft} />);
expect(one).toContain("Only this");
expect(one).not.toContain("Not this");
const missing = renderToString(<LearnerLessonPreview courseId="c1" markdown={md} lesson={{ id: "l1", title: "T", type: "text", anchor: "nope" }} file="lessons/one.md" resolveCourseUrl={draft} resolveUrl={draft} />);
expect(missing).toContain("was not found");
});
test("raw HTML in a draft is sanitised by the learner pipeline", () => {
const html = renderToString(<LearnerLessonPreview courseId="c1" markdown={"# T\n\n<img src=x onerror=alert(1)><script>alert(2)</script>"} lesson={{ id: "l1", title: "T", type: "text" }} file="a.md" resolveCourseUrl={draft} resolveUrl={draft} />);
expect(html).not.toContain("onerror");
expect(html).not.toContain("<script>");
});
@@ -169,7 +169,7 @@ describe("I3: owner-only visibility", () => {
describe("I2: uploads", () => {
let env: Awaited<ReturnType<typeof setupApp>>;
afterEach(() => { delete process.env.STC_UPLOAD_MAX_MB; return env?.stop(); });
afterEach(() => env?.stop());
const upload = (name: string, bytes: Uint8Array<ArrayBuffer>, extra: Record<string, string> = {}) => {
const fd = new FormData();
fd.set("file", new File([bytes], name));
@@ -192,10 +192,9 @@ describe("I2: uploads", () => {
expect(readFileSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/media/slides.pdf"))[0]).toBe(2);
});
test("size cap from STC_UPLOAD_MAX_MB, by Content-Length and while streaming", async () => {
env = await setupApp();
test("size cap from AppConfig.uploadMaxMb (STC_UPLOAD_MAX_MB), by Content-Length and while streaming", async () => {
env = await setupApp([], "sqlite://:memory:", undefined, { uploadMaxMb: 1 });
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
process.env.STC_UPLOAD_MAX_MB = "1";
expect((await upload("big.zip", new Uint8Array(1024 * 1024 + 10))).status).toBe(413);
// no Content-Length: a streamed body is cut off once it passes the cap
const fd = new FormData();
+9 -2
View File
@@ -10,7 +10,14 @@
"noEmit": true,
"allowImportingTsExtensions": true,
"baseUrl": ".",
"paths": { "@stc/*": ["packages/*/src"] }
"paths": {
"@stc/feature-learner/web": ["packages/feature-learner/src/web/index.ts"],
"@stc/feature-import/web": ["packages/feature-import/src/web.ts"],
"@stc/feature-studio/web": ["packages/feature-studio/src/web.ts"],
"@stc/feature-ai/web": ["packages/feature-ai/src/web/index.ts"],
"@stc/course-format/markdown": ["packages/course-format/src/markdown.ts"],
"@stc/*": ["packages/*/src"]
}
},
"include": ["packages/*/src", "packages/*/test", "apps/*/src"]
"include": ["packages/*/src", "packages/*/test", "apps/*/src", "apps/*/test"]
}