feat(studio): owner-only "Make editable in the studio" route and conversion job

POST /api/studio/courses/:id/convert (owner only, CSRF-guarded by core) queues
studio.convert-import; GET /api/studio/jobs/:id reports progress. Read-only
responses say whether the caller may convert. Resolves TODO(studio) (ruling R9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 20:32:59 +01:00
1 parent df6d235070
commit afa2338ad3
5 files changed
+201 -18

No files matched your search

+51 -10
View File
@@ -1,6 +1,6 @@
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 type { AuthUser, Feature, JobHandler, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
import { canView, serveMedia } from "@stc/core";
import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format";
import { aclRepo } from "@stc/db";
@@ -10,7 +10,8 @@ import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioErro
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";
export const IMPORTED_MESSAGE = "Imported course: not editable in the studio until the owner makes it editable";
export const CONVERT_JOB = "studio.convert-import";
/** Upload cap in bytes from AppConfig.uploadMaxMb (STC_UPLOAD_MAX_MB, default 100; review I2). */
export function uploadMaxBytes(mb: number | undefined): number {
@@ -44,6 +45,7 @@ async function readCapped(req: Request, max: number): Promise<Uint8Array<ArrayBu
}
export interface StudioFeature extends Feature {
jobs: Record<string, JobHandler>;
store: StudioStore;
watcher: PublishedWatcher;
draftWriter: DraftWriter;
@@ -99,13 +101,22 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
const readOnlyMessage = (c: LoadedCourse): string => {
const kind = c.manifest?.source?.kind;
const underImports = c.root === importedRoot || c.root.startsWith(importedRoot + sep);
if (kind && kind !== "authored" && underImports) {
// TODO(studio): convert imported course to a studio-managed repo (git init into <dataDir>/studio/repos/<id>.git
// plus a published worktree), so authors can edit it and the owner-only visibility rule applies on publish.
return IMPORTED_MESSAGE;
}
if (kind && kind !== "authored" && underImports) return IMPORTED_MESSAGE;
return OUTSIDE_MESSAGE;
};
const isOwner = (u: AuthUser) => u.roles.includes("owner");
/** v1.1 S: only the owner may turn an imported course into a studio course ("Make editable in the studio"). */
const convertible = (u: AuthUser, courseId: string) => isOwner(u) && store.isConvertibleImport(courseId);
/** The conversion job: payload `{ courseId, actor }`, checked again by the store under the course lock. */
const convertJob: JobHandler = async (payload, jc) => {
const p = (payload ?? {}) as { courseId?: unknown; actor?: { id?: unknown; name?: unknown; email?: unknown } };
const courseId = typeof p.courseId === "string" ? p.courseId : "";
const actor = { name: String(p.actor?.name ?? "STCourses"), email: String(p.actor?.email ?? "studio@stcourses.invalid") };
const r = await store.convertImported(courseId, actor, { onProgress: (f, m) => jc.reportProgress(f, m) });
await afterPublishedChange(courseId);
return { courseId, ...r };
};
const afterPublishedChange = async (courseId: string) => {
await ctx.courses.reload(courseId);
@@ -188,7 +199,11 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
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: readOnlyMessage(c) });
const message = readOnlyMessage(c);
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,
imported: message === IMPORTED_MESSAGE, convertible: convertible(user, c.id),
});
}
}
return json({ courses, canCreate: all });
@@ -212,12 +227,38 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
if (!store.isManaged(courseId)) {
const c = ctx.courses.get(courseId);
if (!c) return fail(404, "no such course");
return json({ readOnly: true, message: readOnlyMessage(c), course: { id: c.id, title: c.manifest?.title ?? c.id, managed: false, root: c.root }, diagnostics: c.diagnostics });
const message = readOnlyMessage(c);
return json({ readOnly: true, message, imported: message === IMPORTED_MESSAGE, convertible: convertible(user, c.id), 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/convert": {
POST: guard(async (_req, rc, user) => {
const courseId = rc.params.courseId ?? "";
if (!isOwner(user)) return fail(403, "only the owner can make an imported course editable in the studio");
if (store.isManaged(courseId)) return fail(409, `course "${courseId}" is already a studio course`);
if (!store.isConvertibleImport(courseId)) {
const c = ctx.courses.get(courseId);
return c ? fail(409, `${readOnlyMessage(c) === IMPORTED_MESSAGE ? "this imported course" : OUTSIDE_MESSAGE}: only imported courses in ${importedRoot} can be made editable`) : fail(404, "no such imported course");
}
const pending = (await ctx.jobs.list(CONVERT_JOB)).find((j) => (j.payload as { courseId?: string } | null)?.courseId === courseId && (j.status === "queued" || j.status === "running"));
if (pending) return fail(409, "this course is already being made editable");
const jobId = await ctx.jobs.enqueue(CONVERT_JOB, { courseId, actor: { id: user.id, name: who(user).name, email: who(user).email } });
return json({ jobId }, 202);
}),
},
"/api/studio/jobs/:jobId": {
GET: guard(async (_req, rc, user) => {
if (!isOwner(user)) return fail(403, "only the owner can follow studio jobs");
const j = await ctx.jobs.get(rc.params.jobId ?? "");
if (!j || !j.kind.startsWith("studio.")) return fail(404, "no such job");
return json({ id: j.id, kind: j.kind, status: j.status, progress: j.progress, message: j.message, error: j.error, result: j.result });
}),
},
"/api/studio/courses/:courseId/file": {
GET: course(async (_req, rc, user, courseId) => {
const path = rc.url.searchParams.get("path") ?? "";
@@ -364,5 +405,5 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
watcher.start();
}
return { name: "studio", routes, multipartRoutes: ["/api/studio/courses/:courseId/upload"], store, watcher, draftWriter, stop: () => watcher.stop() };
return { name: "studio", routes, jobs: { [CONVERT_JOB]: convertJob }, multipartRoutes: ["/api/studio/courses/:courseId/upload"], store, watcher, draftWriter, stop: () => watcher.stop() };
}