feat(studio): /api/studio routes, author access, acceptance tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:18:25 +01:00
1 parent 06b6ea6168
commit 497ce91216
6 files changed
+528 -1

No files matched your search

@@ -0,0 +1,300 @@
import { basename, resolve, sep } from "node:path";
import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
import { 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 { checkRelPath, createStudioStore, MANIFEST, StudioError, type FileWrite, type StudioStore } from "./store";
import { createPublishedWatcher, type PublishedWatcher } from "./watch";
export const OUTSIDE_MESSAGE = "This course is managed outside STCourses";
const MAX_UPLOAD = 2 * 1024 * 1024 * 1024; // 2 GB; large videos belong in the media dir, not git, but we do not forbid them
export interface StudioFeature extends Feature {
store: StudioStore;
watcher: PublishedWatcher;
draftWriter: DraftWriter;
stop(): void;
}
export interface StudioFeatureOptions {
/** Poll the published worktrees for external commits (default true). */
watch?: boolean;
watchIntervalMs?: number;
}
const json = (body: unknown, status = 200) => Response.json(body, { status });
const fail = (status: number, error: string) => json({ error }, status);
async function body<T>(req: Request): Promise<T> {
try {
return (await req.json()) as T;
} catch {
throw new StudioError("request body must be JSON");
}
}
function manifestToYaml(m: unknown): string {
return Bun.YAML.stringify(m, null, 2) + "\n";
}
export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature {
const store = createStudioStore({ dataDir: ctx.config.dataDir });
const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs });
const acl = aclRepo(ctx.db);
const wtDir = resolve(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));
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` });
const afterPublishedChange = async (courseId: string) => {
await ctx.courses.reload(courseId);
watcher.note(courseId, await store.publishedHead(courseId));
};
/** Wraps a handler: signed-in author/owner (or per-course author), error mapping. */
const guard = (h: (req: Request, rc: RequestContext, user: AuthUser) => Promise<Response>): RouteHandler => async (req, rc) => {
const user = rc.user;
if (!user) return fail(401, "sign in to use the studio");
try {
if (!hasRole(user) && (await authoredCourses(user)).length === 0) return fail(403, "the studio is for authors");
return await h(req, rc, user);
} catch (e) {
if (e instanceof StudioError) return fail(e.status, e.message);
const msg = e instanceof Error ? e.message : String(e);
if (/^invalid path/.test(msg)) return fail(400, msg);
console.error("[studio]", e);
return fail(500, msg);
}
};
/** For per-course routes: the course must be studio-managed and editable by the user. */
const course = (h: (req: Request, rc: RequestContext, user: AuthUser, courseId: string) => Promise<Response>) =>
guard(async (req, rc, user) => {
const courseId = rc.params.courseId ?? "";
if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course");
if (!store.isManaged(courseId)) {
return ctx.courses.get(courseId) ? fail(403, OUTSIDE_MESSAGE) : fail(404, "no such course");
}
return h(req, rc, user, courseId);
});
async function courseState(courseId: string, user: AuthUser) {
const status = await store.status(courseId, user.id);
const m = await store.readFile(courseId, user.id, MANIFEST);
let manifestRaw: unknown = null;
let manifestErrors: string[] = [];
if (m) {
try { manifestRaw = Bun.YAML.parse(m.content); } catch { /* reported by parseManifest */ }
const parsed = parseManifest(m.content);
manifestErrors = parsed.ok ? [] : parsed.errors;
} else {
manifestErrors = [`${MANIFEST} is missing`];
}
return {
readOnly: false,
course: { id: courseId, title: (manifestRaw as { title?: string } | null)?.title ?? courseId, managed: true },
status,
manifestText: m?.content ?? "",
manifestHash: m?.hash ?? null,
manifestRaw,
manifestErrors,
diagnostics: status.merging ? [] : await store.validateDraft(courseId, user.id),
files: await store.listFiles(courseId, user.id),
};
}
const routes: Feature["routes"] = {
"/api/studio/courses": {
GET: guard(async (_req, _rc, user) => {
const mine = new Set(await authoredCourses(user));
const all = hasRole(user);
const managed = await store.listManaged();
const courses: unknown[] = [];
for (const id of managed) {
if (!all && !mine.has(id)) continue;
const loaded = ctx.courses.get(id);
const st = await store.status(id, user.id).catch(() => null);
courses.push({
id, managed: true,
title: loaded?.manifest?.title ?? id,
pitch: loaded?.manifest?.pitch ?? null,
accent: loaded?.manifest?.accent ?? null,
publishedStatus: loaded?.status ?? "error",
ahead: st?.ahead ?? 0, behind: st?.behind ?? 0, merging: st?.merging ?? false, remote: st?.remote ?? null,
});
}
if (all) {
for (const c of ctx.courses.list()) {
if (isStudioCourse(c) || managed.includes(c.id)) continue;
courses.push({ id: c.id, managed: false, title: c.manifest?.title ?? c.id, pitch: c.manifest?.pitch ?? null, accent: c.manifest?.accent ?? null, publishedStatus: c.status, message: OUTSIDE_MESSAGE });
}
}
return json({ courses, canCreate: all });
}),
POST: guard(async (req, _rc, user) => {
if (!hasRole(user)) return fail(403, "only authors can create courses");
const b = await body<{ id?: unknown; title?: unknown; pitch?: unknown }>(req);
if (typeof b.id !== "string" || typeof b.title !== "string" || !b.title.trim()) return fail(400, "id and title are required");
if (ctx.courses.get(b.id) || store.isManaged(b.id)) return fail(409, `course "${b.id}" already exists`);
await store.createCourse({ id: b.id, title: b.title, pitch: typeof b.pitch === "string" ? b.pitch : undefined }, who(user));
await ctx.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${b.id}, ${user.id})`;
await afterPublishedChange(b.id);
return json({ id: b.id }, 201);
}),
},
"/api/studio/courses/:courseId": {
GET: guard(async (_req, rc, user) => {
const courseId = rc.params.courseId ?? "";
if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course");
if (!store.isManaged(courseId)) {
const c = ctx.courses.get(courseId);
if (!c) return fail(404, "no such course");
return json({ readOnly: true, message: OUTSIDE_MESSAGE, course: { id: c.id, title: c.manifest?.title ?? c.id, managed: false, root: c.root }, diagnostics: c.diagnostics });
}
return json(await courseState(courseId, user));
}),
},
"/api/studio/courses/:courseId/file": {
GET: course(async (_req, rc, user, courseId) => {
const path = rc.url.searchParams.get("path") ?? "";
const f = await store.readFile(courseId, user.id, path);
return f ? json({ path, ...f }) : fail(404, "no such file");
}),
},
"/api/studio/courses/:courseId/files": {
PUT: course(async (req, _rc, user, courseId) => {
const b = await body<{ message?: string; files?: FileWrite[]; manifest?: unknown; manifestBaseHash?: string | null }>(req);
const files: FileWrite[] = Array.isArray(b.files) ? b.files.map((f) => ({ path: String(f.path), content: f.delete ? undefined : String(f.content ?? ""), baseHash: f.baseHash, delete: !!f.delete })) : [];
if (b.manifest !== undefined) {
files.unshift({ path: MANIFEST, content: manifestToYaml(b.manifest), baseHash: b.manifestBaseHash });
}
const mf = files.find((f) => checkRelPath(f.path) === MANIFEST);
if (mf && typeof mf.content === "string") {
const parsed = parseManifest(mf.content);
if (parsed.ok && parsed.manifest.source && parsed.manifest.source.kind !== "authored" && parsed.manifest.visibility !== "restricted" && !user.roles.includes("owner")) {
return fail(403, "only the owner can make an imported course public or signed-in visible");
}
}
const r = await store.saveFiles(courseId, who(user), files, b.message ?? "");
if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409);
return json({ ok: true, commit: r.commit });
}),
},
"/api/studio/courses/:courseId/validate": {
POST: course(async (req) => {
const b = await body<{ manifest?: unknown; manifestText?: string }>(req);
if (typeof b.manifestText === "string") {
const r = parseManifest(b.manifestText);
return json({ errors: r.ok ? [] : r.errors });
}
const r = ManifestSchema.safeParse(b.manifest);
return json({ errors: r.success ? [] : r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) });
}),
},
"/api/studio/courses/:courseId/upload": {
POST: course(async (req, _rc, user, courseId) => {
let form: FormData;
try { form = await req.formData(); } catch { return fail(400, "expected multipart form data"); }
const file = form.get("file");
if (!(file instanceof Blob)) return fail(400, "file is required");
if (file.size > MAX_UPLOAD) return fail(413, "file too large");
const explicit = form.get("path");
const dir = String(form.get("dir") ?? "").replace(/^\/+|\/+$/g, "");
const name = basename((file as File).name || "upload.bin");
const path = checkRelPath(typeof explicit === "string" && explicit ? explicit : dir ? `${dir}/${name}` : name);
const message = String(form.get("message") ?? "") || `Upload ${path}`;
const r = await store.saveFiles(courseId, who(user), [{ path, content: new Uint8Array(await file.arrayBuffer()) }], message);
if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409);
return json({ ok: true, path, commit: r.commit });
}),
},
"/api/studio/courses/:courseId/raw/*": {
GET: course(async (req, rc, user, courseId) => {
const m = /^\/api\/studio\/courses\/[^/]+\/raw\/(.*)$/s.exec(rc.url.pathname);
let rel: string;
try { rel = decodeURIComponent(m?.[1] ?? ""); } catch { return fail(404, "not found"); }
const dir = await store.draftDir(courseId, user.id);
try { checkRelPath(rel); } catch { return fail(404, "not found"); }
const abs = resolveInside(dir, rel);
return abs ? serveMedia(req, abs, { download: rc.url.searchParams.has("download") }) : fail(404, "not found");
}),
},
"/api/studio/courses/:courseId/publish": {
POST: course(async (_req, _rc, user, courseId) => {
const r = await store.publish(courseId, who(user));
if (r.ok) {
await afterPublishedChange(courseId);
return json(r);
}
return json(r, r.reason === "invalid" ? 422 : 409);
}),
},
"/api/studio/courses/:courseId/update-draft": {
POST: course(async (req, _rc, user, courseId) => {
const b = req.headers.get("content-type")?.includes("json") ? await body<{ from?: string }>(req) : {};
const r = await store.updateDraft(courseId, who(user), b.from === "remote" ? "remote" : "published");
return json(r, r.ok ? 200 : r.reason === "no-remote" ? 400 : 409);
}),
},
"/api/studio/courses/:courseId/conflicts": {
GET: course(async (_req, _rc, user, courseId) => json({ files: await store.conflicts(courseId, user.id) })),
},
"/api/studio/courses/:courseId/resolve": {
POST: course(async (req, _rc, user, courseId) => {
const b = await body<{ files?: { path: string; content: string | null }[] }>(req);
if (!Array.isArray(b.files) || !b.files.length) return fail(400, "files are required");
return json(await store.resolve(courseId, who(user), b.files.map((f) => ({ path: String(f.path), content: f.content === null ? null : String(f.content) }))));
}),
},
"/api/studio/courses/:courseId/abort": {
POST: course(async (_req, _rc, user, courseId) => {
await store.abortMerge(courseId, user.id);
return json({ ok: true });
}),
},
"/api/studio/courses/:courseId/remote": {
PUT: course(async (req, _rc, _user, courseId) => {
const b = await body<{ url?: string | null }>(req);
await store.setRemote(courseId, b.url ? String(b.url) : null);
return json({ ok: true, remote: await store.remote(courseId) });
}),
},
"/api/studio/courses/:courseId/sync": {
POST: course(async (_req, _rc, user, courseId) => {
const r = await store.sync(courseId, who(user));
if (r.ok) {
await afterPublishedChange(courseId);
return json(r);
}
return json(r, r.reason === "no-remote" ? 400 : r.reason === "network" ? 502 : 409);
}),
},
};
if (opts.watch !== false) {
watcher.check().catch((e) => console.error("[studio] watcher", e));
watcher.start();
}
return { name: "studio", routes, store, watcher, draftWriter, stop: () => watcher.stop() };
}