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:
1 parent
df6d235070
commit
afa2338ad3
5 files changed
+201
-18
No files matched your search
@@ -1,5 +1,5 @@
|
||||
// Server entry of @stc/feature-studio. The browser imports "@stc/feature-studio/web" instead (no server code).
|
||||
export { studioFeature, OUTSIDE_MESSAGE, IMPORTED_MESSAGE, uploadMaxBytes, UPLOAD_EXTENSIONS, type StudioFeature, type StudioFeatureOptions } from "./server/feature";
|
||||
export { studioFeature, OUTSIDE_MESSAGE, IMPORTED_MESSAGE, CONVERT_JOB, uploadMaxBytes, UPLOAD_EXTENSIONS, type StudioFeature, type StudioFeatureOptions } from "./server/feature";
|
||||
export { createDraftWriter, type DraftWriter, type DraftWriterOptions } from "./server/draft-writer";
|
||||
export { createStudioStore, contentHash, isAllowedRemote, ownerRequiredFor, type StudioStore, type Identity, type DraftStatus, type PublishResult, type SyncResult } from "./server/store";
|
||||
export { createPublishedWatcher, type PublishedWatcher } from "./server/watch";
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
Reference in new issue
Block a user