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:
co-authored by
Claude Opus 5.5
parent
427eda6e93
commit
777ae2cc37
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(); } };
|
||||
}
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from "./exercise";
|
||||
export * from "./ai";
|
||||
export * from "./import";
|
||||
export * from "./web";
|
||||
export * from "./routes";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// The learner's public page routes. The learner package registers these patterns; every other package (AI
|
||||
// citations, studio links) builds links with the helpers, so a link always matches a real route.
|
||||
// Pure functions with no dependencies, the only runtime code in @stc/contracts.
|
||||
|
||||
export const COURSE_ROUTE = "/c/:courseId";
|
||||
export const LESSON_ROUTE = "/c/:courseId/:lessonId";
|
||||
|
||||
export function courseUrl(courseId: string): string {
|
||||
return `/c/${encodeURIComponent(courseId)}`;
|
||||
}
|
||||
|
||||
export function lessonUrl(courseId: string, lessonId: string): string {
|
||||
return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`;
|
||||
}
|
||||
@@ -7,7 +7,12 @@ export interface Feature {
|
||||
name: string; // "learner" | "import" | "studio" | "ai" | "core"
|
||||
routes: Record<string, Partial<Record<"GET"|"POST"|"PUT"|"PATCH"|"DELETE", RouteHandler>>>; // paths start with /api/
|
||||
jobs?: Record<string, JobHandler>; // job kind -> handler
|
||||
auth?: AuthService; // replaces the anonymous AuthService (the learner package supplies it)
|
||||
stop?(): void | Promise<void>; // called when the server stops (timers, watchers)
|
||||
}
|
||||
/** Features are built after the ServerContext exists. The job queue is built from the union of their `jobs`, so a
|
||||
* factory must not use `ctx.jobs` synchronously (only while a request or job runs). */
|
||||
export type FeatureFactory = (ctx: ServerContext) => Feature;
|
||||
export interface JobContext { reportProgress(fraction: number, message?: string): Promise<void>; signal: AbortSignal }
|
||||
export type JobHandler = (payload: unknown, jc: JobContext) => Promise<unknown>;
|
||||
export type JobStatus = "queued" | "running" | "done" | "failed";
|
||||
@@ -20,8 +25,27 @@ export interface ServerContext {
|
||||
jobs: JobQueue; auth: AuthService;
|
||||
}
|
||||
export interface AppConfig {
|
||||
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, media/, worktrees/
|
||||
courseDirs: string[]; // folders scanned for stcourse.yaml (course repos and overlays/)
|
||||
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, courses/, studio/, worktrees/
|
||||
/** Folders scanned for stcourse.yaml: STC_COURSE_DIRS, plus importedCoursesDir and studioWorktreesDir (deduplicated). */
|
||||
courseDirs: string[];
|
||||
secret: string; // STC_SECRET, used to encrypt BYOK keys
|
||||
publicUrl: string;
|
||||
/** `<dataDir>/courses`: the importer's plain course folders. */
|
||||
importedCoursesDir: string;
|
||||
/** `<dataDir>/studio/repos`: studio bare repos (`<id>.git`), never scanned. */
|
||||
studioReposDir: string;
|
||||
/** `<dataDir>/worktrees`: studio published worktrees (`<id>/published`); drafts sit one level deeper and are never scanned. */
|
||||
studioWorktreesDir: string;
|
||||
/** `<dataDir>/cache`: derived files (decks/, covers/, transcripts/). */
|
||||
cacheDir: string;
|
||||
/** STC_IMPORT_ROOTS (":"-separated), default `<dataDir>/import-inbox`: the only folders the importer reads from. */
|
||||
importRoots: string[];
|
||||
/** STC_UPLOAD_MAX_MB, default 100. */
|
||||
uploadMaxMb: number;
|
||||
/** STC_AI_ALLOWED_BASE_URLS (comma-separated origins) that learners' own AI keys may use besides the official endpoints. */
|
||||
aiAllowedBaseUrls: string[];
|
||||
/** STC_TRANSCRIBE_URL: Whisper-compatible endpoint; null disables transcription. */
|
||||
transcribeUrl: string | null;
|
||||
/** STC_PYTHON, default python3: Python with PyMuPDF, used for PDF page images and text. */
|
||||
python: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { Role } from "./server";
|
||||
|
||||
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin" } }
|
||||
/** `nav.roles`: the shell shows the link only to signed-in users holding one of these roles (absent: everyone). */
|
||||
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin"; roles?: Role[] } }
|
||||
export interface WebFeature { name: string; routes: WebRoute[] }
|
||||
|
||||
@@ -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,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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./markdown": "./src/markdown.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { connect, type Db } from "./connect";
|
||||
export { migrate } from "./migrate";
|
||||
export { migrate, type PackageMigrations } from "./migrate";
|
||||
export { progressRepo, type LessonProgress, type ProgressState } from "./repos/progress";
|
||||
export { notesRepo, type Note, type NoteInput } from "./repos/notes";
|
||||
export { attemptsRepo, type Attempt } from "./repos/attempts";
|
||||
|
||||
+25
-10
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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") },
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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')`;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -6,17 +6,24 @@ import { slugify, uniqueId } from "./util/text";
|
||||
import { extractFrame, type Tools } from "./media";
|
||||
|
||||
export interface ImportEnv { [k: string]: string | undefined }
|
||||
/** `env` supplies only tool paths and transcription credentials (STC_FFMPEG, STC_FFPROBE, STC_TRANSCRIBE_KEY, _MODEL). */
|
||||
export interface ImportOptions { env?: ImportEnv }
|
||||
|
||||
export interface Deps {
|
||||
ctx: ServerContext;
|
||||
env: ImportEnv;
|
||||
tools: Tools;
|
||||
/** AppConfig.python (STC_PYTHON). */
|
||||
python: string;
|
||||
/** AppConfig.importRoots (STC_IMPORT_ROOTS). */
|
||||
importRoots: string[];
|
||||
/** AppConfig.transcribeUrl (STC_TRANSCRIBE_URL). */
|
||||
transcribeUrl: string | null;
|
||||
}
|
||||
|
||||
export const coursesDir = (d: Deps) => resolve(d.ctx.config.dataDir, "courses");
|
||||
export const cacheDir = (d: Deps) => resolve(d.ctx.config.dataDir, "cache");
|
||||
export const coursesDir = (d: Deps) => resolve(d.ctx.config.importedCoursesDir ?? resolve(d.ctx.config.dataDir, "courses"));
|
||||
export const cacheDir = (d: Deps) => resolve(d.ctx.config.cacheDir ?? resolve(d.ctx.config.dataDir, "cache"));
|
||||
const studioRepo = (d: Deps, id: string) => join(d.ctx.config.studioReposDir ?? resolve(d.ctx.config.dataDir, "studio", "repos"), `${id}.git`);
|
||||
export const deckCacheDir = (d: Deps, courseId: string, deckId: string) => join(cacheDir(d), "decks", courseId, deckId);
|
||||
export const coverCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "covers", courseId);
|
||||
export const transcriptCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "transcripts", courseId);
|
||||
@@ -27,11 +34,14 @@ export const isPlainId = (s: string) => /^[a-z0-9][a-z0-9-]*$/.test(s);
|
||||
export async function freshCourseId(d: Deps, wanted: string | undefined, title: string): Promise<string> {
|
||||
if (wanted !== undefined) {
|
||||
if (!isPlainId(wanted)) throw new Error(`invalid courseId "${wanted}" (use lowercase letters, digits and dashes)`);
|
||||
if (await exists(join(coursesDir(d), wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`);
|
||||
// Studio repos share the id space (ruling R9): refuse an id the studio already uses, even before it is published.
|
||||
if (await exists(join(coursesDir(d), wanted)) || await exists(studioRepo(d, wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`);
|
||||
return wanted;
|
||||
}
|
||||
const taken = new Set<string>(d.ctx.courses.list().map((c) => c.id));
|
||||
for (const e of await readdir(coursesDir(d)).catch(() => [] as string[])) taken.add(e);
|
||||
const repos = d.ctx.config.studioReposDir ?? resolve(d.ctx.config.dataDir, "studio", "repos");
|
||||
for (const e of await readdir(repos).catch(() => [] as string[])) if (e.endsWith(".git")) taken.add(e.slice(0, -4));
|
||||
return uniqueId(slugify(title), (x) => taken.has(x));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { Feature, JobHandler, ServerContext } from "@stc/contracts";
|
||||
import type { Deps, ImportOptions } from "./context";
|
||||
import { importCodeRepo } from "./jobs/code-repo";
|
||||
@@ -14,15 +15,22 @@ export { deckCacheDir, coverCacheDir, transcriptCacheDir } from "./context";
|
||||
/**
|
||||
* `ctx` is only read when a job runs or a request arrives, never while this function runs, so the server can pass
|
||||
* its context object before the job queue (which needs these handlers) exists.
|
||||
* Environment: STC_PYTHON (Python with PyMuPDF, default python3), STC_FFMPEG / STC_FFPROBE, STC_TRANSCRIBE_URL (+ _KEY, _MODEL).
|
||||
* Config: AppConfig.python, importRoots, transcribeUrl. Environment: STC_FFMPEG / STC_FFPROBE, STC_TRANSCRIBE_KEY / _MODEL.
|
||||
*/
|
||||
export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Feature {
|
||||
const deps = (): Deps => {
|
||||
const env = opts.env ?? process.env;
|
||||
return { ctx, env, tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" }, python: env.STC_PYTHON || "python3" };
|
||||
const c = ctx.config;
|
||||
return {
|
||||
ctx, env,
|
||||
tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" },
|
||||
python: c.python || "python3",
|
||||
importRoots: c.importRoots?.length ? c.importRoots : [resolve(c.dataDir, "import-inbox")],
|
||||
transcribeUrl: c.transcribeUrl ?? null,
|
||||
};
|
||||
};
|
||||
const job = (fn: (d: Deps, p: unknown, jc: Parameters<JobHandler>[1]) => Promise<unknown>): JobHandler => (p, jc) => fn(deps(), p, jc);
|
||||
ensureRoots({ ctx, env: opts.env ?? process.env });
|
||||
ensureRoots(deps());
|
||||
return {
|
||||
name: "import",
|
||||
routes: importRoutes(deps),
|
||||
@@ -37,3 +45,6 @@ export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Fea
|
||||
}
|
||||
|
||||
export { importWeb } from "./web";
|
||||
|
||||
/** The importer adds no tables. */
|
||||
export const migrationsDirs: string[] = [];
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function adoptCover(d: Deps, courseId: string, courseDir: string, c
|
||||
|
||||
export async function maybeQueueTranscription(d: Deps, courseId: string, wanted: boolean, warnings: string[]): Promise<void> {
|
||||
if (!wanted) return;
|
||||
if (!d.env.STC_TRANSCRIBE_URL) {
|
||||
if (!d.transcribeUrl) {
|
||||
warnings.push("transcription requested but STC_TRANSCRIBE_URL is not set; skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function segmentsToMarkdown(title: string, segments: Segment[], fallbackT
|
||||
*/
|
||||
export async function transcribeCourse(d: Deps, payload: unknown, jc: JobContext): Promise<TranscribeResult> {
|
||||
const p = (payload ?? {}) as Record<string, unknown>;
|
||||
const url = d.env.STC_TRANSCRIBE_URL;
|
||||
const url = d.transcribeUrl;
|
||||
const courseId = optStr(p, "courseId") ?? "";
|
||||
if (!url) return { courseId, skipped: true, reason: "STC_TRANSCRIBE_URL is not set", transcribed: 0, warnings: [] };
|
||||
const course = await managedCourse(d, courseId);
|
||||
|
||||
@@ -5,19 +5,17 @@ import type { Deps } from "./context";
|
||||
|
||||
export const OUTSIDE = "path is outside the allowed import folders";
|
||||
|
||||
/** STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
|
||||
export function importRoots(d: Pick<Deps, "ctx" | "env">): string[] {
|
||||
const raw = d.env.STC_IMPORT_ROOTS;
|
||||
const list = raw ? raw.split(":").filter(Boolean) : [resolve(d.ctx.config.dataDir, "import-inbox")];
|
||||
return list.map((r) => resolve(r));
|
||||
/** AppConfig.importRoots: STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
|
||||
export function importRoots(d: Pick<Deps, "importRoots">): string[] {
|
||||
return d.importRoots.map((r) => resolve(r));
|
||||
}
|
||||
|
||||
export function ensureRoots(d: Pick<Deps, "ctx" | "env">): void {
|
||||
export function ensureRoots(d: Pick<Deps, "importRoots">): void {
|
||||
for (const r of importRoots(d)) { try { mkdirSync(r, { recursive: true }); } catch { /* reported when used */ } }
|
||||
}
|
||||
|
||||
/** Real path (symlinks followed) must lie inside the real path of one root. Throws OUTSIDE otherwise. */
|
||||
export async function assertAllowedPath(d: Pick<Deps, "ctx" | "env">, path: string): Promise<string> {
|
||||
export async function assertAllowedPath(d: Pick<Deps, "importRoots">, path: string): Promise<string> {
|
||||
let real: string;
|
||||
try { real = await realpath(resolve(path)); } catch { throw new Error(OUTSIDE); }
|
||||
for (const root of importRoots(d)) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
|
||||
const d = deps();
|
||||
const dir = coursesDir(d);
|
||||
const served = d.ctx.config.courseDirs.some((c) => resolve(c) === dir);
|
||||
return json({ transcribe: Boolean(d.env.STC_TRANSCRIBE_URL), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
|
||||
return json({ transcribe: Boolean(d.transcribeUrl), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
|
||||
}),
|
||||
},
|
||||
"/api/import/jobs": {
|
||||
@@ -42,7 +42,7 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
|
||||
const options: Record<string, unknown> = {};
|
||||
for (const k of OPTION_KEYS) if (body?.options?.[k] !== undefined && body.options[k] !== "") options[k] = body.options[k];
|
||||
if (kind === "transcribe") {
|
||||
if (!d.env.STC_TRANSCRIBE_URL) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409);
|
||||
if (!d.transcribeUrl) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409);
|
||||
if (typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400);
|
||||
return json({ id: await d.ctx.jobs.enqueue("import.transcribe", { courseId: options.courseId }) }, 202);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { WebFeature } from "@stc/contracts";
|
||||
/** Browser-safe entry (no server imports): apps/web imports this as "@stc/feature-import/web". */
|
||||
export const importWeb: WebFeature = {
|
||||
name: "import",
|
||||
routes: [{ path: "/admin/import", element: () => import("./web/ImportPage"), nav: { label: "Import", area: "admin" } }],
|
||||
routes: [{ path: "/admin/import", element: () => import("./web/ImportPage"), nav: { label: "Import", area: "admin", roles: ["owner"] } }],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
@@ -226,7 +228,7 @@ export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "i
|
||||
if (rel === null) return null;
|
||||
const lessonId = kind === "link" ? opts.lessonFiles?.[rel] : undefined;
|
||||
if (lessonId) return { url: `/c/${encodeURIComponent(opts.courseId)}/${encodeURIComponent(lessonId)}${hash ? `#${hash}` : ""}`, external: false };
|
||||
return { url: mediaUrl(opts.courseId, rel) + (hash ? `#${hash}` : ""), external: false };
|
||||
return { url: (opts.mediaUrl ? opts.mediaUrl(rel) : mediaUrl(opts.courseId, rel)) + (hash ? `#${hash}` : ""), external: false };
|
||||
}
|
||||
|
||||
export function renderLessonHtml(md: string, opts: RenderOptions): string {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 \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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user