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]>
56 lines
3.0 KiB
TypeScript
56 lines
3.0 KiB
TypeScript
import { resolve } from "node:path";
|
|
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";
|
|
import { studioCourseDirs } from "../src/server/paths";
|
|
import { tempDir } from "./helpers";
|
|
|
|
export const REPO = resolve(import.meta.dir, "../../..");
|
|
export const users: Record<string, AuthUser> = {
|
|
ada: { id: "u-ada", email: "[email protected]", name: "Ada Lovelace", roles: ["author"], groups: [] },
|
|
owner: { id: "u-own", email: "[email protected]", name: "Olive Owner", roles: ["owner"], groups: [] },
|
|
lea: { id: "u-lea", email: "[email protected]", name: "Lea Learner", roles: ["learner"], groups: [] },
|
|
};
|
|
|
|
/** 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, configOverrides: Partial<AppConfig> = {}) {
|
|
const dataDir = tempDir();
|
|
prepare?.(dataDir);
|
|
let dropSchema = async () => {};
|
|
if (dbUrl.startsWith("postgres")) {
|
|
// a private schema per test, like packages/db's forEachDb
|
|
const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`;
|
|
const admin = await connect(dbUrl);
|
|
await admin.sql.unsafe(`CREATE SCHEMA ${schema}`);
|
|
const u = new URL(dbUrl);
|
|
u.searchParams.set("options", `-c search_path=${schema}`);
|
|
dbUrl = u.toString();
|
|
dropSchema = async () => { await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); await admin.close(); };
|
|
}
|
|
const db = await connect(dbUrl);
|
|
await migrate(db);
|
|
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,
|
|
auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null },
|
|
} satisfies ServerContext;
|
|
const feature = studioFeature(ctx, { watch: false });
|
|
const app = createApp(ctx, [feature]);
|
|
const call = async (as: string | null, method: string, path: string, body?: unknown) => {
|
|
const headers: Record<string, string> = {};
|
|
if (as) headers["x-user"] = as;
|
|
let payload: BodyInit | undefined;
|
|
if (body instanceof FormData) payload = body;
|
|
else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); }
|
|
const r = await app.fetch(new Request(`http://x${path}`, { method, headers, body: payload }));
|
|
const text = await r.text();
|
|
let json: any = null;
|
|
try { json = JSON.parse(text); } catch { json = text; }
|
|
return { status: r.status, json };
|
|
};
|
|
return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); await dropSchema(); } };
|
|
}
|