Files
STCourses/packages/feature-ai/src/feature.ts
T
Lucas WintherandClaude Opus 5.5 a13cc66ebb fix(ai): BYOK endpoints limited to official presets and admin-allowed origins (SSRF)
- A user's own key goes only to https://api.anthropic.com, https://api.openai.com/v1
  or an origin listed in STC_AI_ALLOWED_BASE_URLS / the server config's
  allowedBaseUrls; anything else is a 400, and a stored key whose origin was
  later removed stops being used.
- User-scope provider calls never follow redirects, and their errors reach the
  user only as the HTTP status plus a fixed message; the full text is logged.
- The admin's server-scope provider may still use any base URL.
- Admin UI: allowed origins field; MCP stdio env editable, HTTP headers kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:47:25 +01:00

474 lines
27 KiB
TypeScript

import { readdir } from "node:fs/promises";
import { extname, join, relative, sep } from "node:path";
import type { AiProvider, AuthUser, ChatMessage, Feature, JobContext, RequestContext, ServerContext } from "@stc/contracts";
import { canView } from "@stc/core";
import type { LoadedCourse } from "@stc/course-format";
import { aclRepo, notesRepo, progressRepo } from "@stc/db";
import { buildSystemPrompt, citationsFor, readMentorText } from "./assistant/prompt";
import { combineTools, courseTools, mcpTools, type ToolSet } from "./assistant/tools";
import { runAssistant } from "./assistant/loop";
import { createSecretBox } from "./crypto";
import { createFsDraftSink, type DraftSink } from "./generator/draft-sink";
import { generateDraft, type GenerateRequest, type GenerateSource } from "./generator/generate";
import { connectMcp, mcpServersFor } from "./mcp/client";
import { createProvider } from "./providers";
import { PRESET_BASE_URLS, byokBaseUrl, noRedirectFetch, parseAllowedOrigins, publicProviderError, sanitizeProviderErrors } from "./endpoints";
import { getCourseIndex, noteChunks } from "./retrieval/course-index";
import { aiSettingsRepo, type McpServerConfig, type ProviderConfig } from "./settings";
export interface AiFeatureOptions {
/** Used by providers and HTTP MCP servers. Tests pass a fake; production uses global fetch. */
fetch?: typeof fetch;
/** Where generated drafts go. Phase 2 passes the studio's createDraftWriter(ctx); default writes under <dataDir>/drafts. */
draftSink?: DraftSink;
/** 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;
}
const json = (body: unknown, status = 200) => Response.json(body, { status });
const err = (status: number, message: string) => json({ error: message }, status);
const KINDS = new Set(["anthropic", "openai"]);
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
const HISTORY_TURNS = 20;
const MAX_MESSAGE = 8_000;
async function body(req: Request): Promise<Record<string, unknown> | null> {
try {
const b = await req.json();
return b && typeof b === "object" && !Array.isArray(b) ? (b as Record<string, unknown>) : null;
} catch { return null; }
}
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
function parseProvider(v: unknown, scope: "server" | "user"): (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null | "invalid" {
if (v === null || v === undefined) return null;
if (typeof v !== "object") return "invalid";
const p = v as Record<string, unknown>;
if (!KINDS.has(p.kind as string)) return "invalid";
if (p.effort !== undefined && p.effort !== "" && !EFFORTS.has(p.effort as string)) return "invalid";
const out: ProviderConfig & { apiKey?: string; clearApiKey?: boolean } = { kind: p.kind as ProviderConfig["kind"] };
if (str(p.model)) out.model = str(p.model);
if (str(p.baseUrl)) {
try { new URL(str(p.baseUrl)!); } catch { return "invalid"; }
out.baseUrl = str(p.baseUrl);
}
if (str(p.effort)) out.effort = str(p.effort) as ProviderConfig["effort"];
if (str(p.apiKey)) out.apiKey = str(p.apiKey);
if (p.clearApiKey === true) out.clearApiKey = true;
// Server scope: any endpoint, but OpenAI-compatible needs one. User scope: an empty base URL means the official endpoint.
if (out.kind === "openai" && (!out.model || (scope === "server" && !out.baseUrl))) return "invalid";
return out;
}
function parseMcp(v: unknown): McpServerConfig[] | "invalid" {
if (v === undefined || v === null) return [];
if (!Array.isArray(v)) return "invalid";
const out: McpServerConfig[] = [];
const ids = new Set<string>();
for (const raw of v) {
if (!raw || typeof raw !== "object") return "invalid";
const s = raw as Record<string, unknown>;
const id = str(s.id);
if (!id || !/^[a-zA-Z0-9_-]{1,40}$/.test(id) || ids.has(id)) return "invalid";
ids.add(id);
const transport = s.transport === "stdio" || s.transport === "http" ? s.transport : null;
if (!transport) return "invalid";
const cfg: McpServerConfig = { id, name: str(s.name) ?? id, transport };
if (transport === "stdio") {
if (!str(s.command)) return "invalid";
cfg.command = str(s.command);
if (Array.isArray(s.args)) cfg.args = s.args.map(String);
if (s.env && typeof s.env === "object") cfg.env = Object.fromEntries(Object.entries(s.env).map(([k, val]) => [k, String(val)]));
} else {
const url = str(s.url);
if (!url) return "invalid";
try { new URL(url); } catch { return "invalid"; }
cfg.url = url;
if (s.headers && typeof s.headers === "object") cfg.headers = Object.fromEntries(Object.entries(s.headers).map(([k, val]) => [k, String(val)]));
}
if (Array.isArray(s.courseIds)) cfg.courseIds = s.courseIds.map(String).filter(Boolean);
out.push(cfg);
}
return out;
}
function parseGenerate(b: Record<string, unknown>): GenerateRequest | null {
const courseId = str(b.courseId);
const topic = str(b.topic);
if (!courseId || !topic) return null;
if (!["document", "exercise", "questions"].includes(b.kind as string)) return null;
if (!["pure-snake", "systemic-design"].includes(b.style as string)) return null;
if (!Array.isArray(b.sources)) return null;
const sources: GenerateSource[] = [];
for (const s of b.sources) {
if (!s || typeof s !== "object") return null;
const x = s as Record<string, unknown>;
if (x.kind === "lesson" && str(x.lessonId)) sources.push({ kind: "lesson", lessonId: str(x.lessonId)! });
else if (x.kind === "file" && str(x.path)) sources.push({ kind: "file", path: str(x.path)! });
else if (x.kind === "cache" && str(x.path)) sources.push({ kind: "cache", path: str(x.path)! });
else if (x.kind === "text" && typeof x.text === "string" && x.text.trim()) sources.push({ kind: "text", label: str(x.label) ?? "Pasted text", text: x.text });
else return null;
}
return { courseId, topic: topic.slice(0, 200), kind: b.kind as GenerateRequest["kind"], style: b.style as GenerateRequest["style"], sources, instructions: typeof b.instructions === "string" ? b.instructions.slice(0, 8_000) : undefined };
}
const LISTABLE = new Set([".md", ".markdown", ".txt", ".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".rs", ".scala", ".fs", ".go", ".java", ".sh", ".json", ".yaml", ".yml", ".sql", ".html", ".css", ".toml", ".vtt", ".srt"]);
async function listFiles(root: string, limit = 800, keep: (rel: string) => boolean = () => true): Promise<string[]> {
const out: string[] = [];
const walk = async (dir: string, depth: number) => {
if (depth > 8 || out.length >= limit) return;
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const e of entries) {
if (out.length >= limit) return;
if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "target" || e.name === "dist") continue;
const abs = join(dir, e.name);
if (e.isDirectory()) await walk(abs, depth + 1);
else if (e.isFile() && LISTABLE.has(extname(e.name).toLowerCase())) {
const rel = relative(root, abs).split(sep).join("/");
if (keep(rel)) out.push(rel);
}
}
};
await walk(root, 0);
return out;
}
export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feature {
const settings = aiSettingsRepo(ctx.db, createSecretBox(ctx.config.secret));
const acl = aclRepo(ctx.db);
const notes = notesRepo(ctx.db);
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])];
/**
* 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.
*/
const makeProvider = (r: { source: "byok" | "server"; cfg: ProviderConfig; key: string | null }, userId: string): AiProvider => {
if (r.source === "server") return opts.providerFactory ? opts.providerFactory(r.cfg, r.key) : createProvider(r.cfg, r.key, opts.fetch);
const p = opts.providerFactory ? opts.providerFactory(r.cfg, r.key) : createProvider(r.cfg, r.key, noRedirectFetch(opts.fetch ?? fetch));
return sanitizeProviderErrors(p, (m) => log(`provider error for user ${userId} (own key): ${m}`));
};
/** BYOK first ("used only for their own requests"), then the server provider. */
const resolveProvider = async (userId: string | null): Promise<{ source: "byok" | "server"; cfg: ProviderConfig; key: string | null } | null> => {
if (userId) {
const u = await settings.getUser(userId);
if (u.byok) {
// Re-checked on every use: an origin the admin removed from the allowlist stops working at once.
const base = byokBaseUrl(u.byok.kind, u.byok.baseUrl, await allowedOrigins());
const key = base ? await settings.userApiKey(userId).catch(() => null) : null;
if (key && base) return { source: "byok", cfg: { ...u.byok, baseUrl: base }, key };
}
}
const s = await settings.getServer();
if (!s.provider) return null;
if (s.provider.kind === "anthropic" && !s.provider.hasApiKey) return null;
const key = s.provider.hasApiKey ? await settings.serverApiKey().catch(() => null) : null;
if (s.provider.kind === "anthropic" && !key) return null;
return { source: "server", cfg: s.provider, key };
};
const viewable = async (user: AuthUser | null, courseId: string): Promise<LoadedCourse | null> => {
const c = ctx.courses.get(courseId);
if (!c || c.status !== "ok") return null;
const ok = canView(user, c, await acl.listForCourse(c.id), user ? await acl.isAuthor(c.id, user.id) : false);
return ok ? c : null;
};
const canAuthor = async (user: AuthUser, courseId: string) =>
user.roles.includes("owner") || user.roles.includes("author") || (await acl.isAuthor(courseId, user.id));
const conversationOf = async (id: string, userId: string) => {
const rows = await ctx.db.sql`SELECT id, user_id, course_id, title, created_at FROM ai_conversations WHERE id = ${id} AND user_id = ${userId}`;
return rows[0] as { id: string; user_id: string; course_id: string; title: string | null; created_at: string } | undefined;
};
// Roles: "user", "assistant", and "sources" (JSON citations for the assistant message before it).
const addMessage = async (conversationId: string, role: "user" | "assistant" | "sources", content: string) => {
await ctx.db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${Bun.randomUUIDv7()}, ${conversationId}, ${role}, ${content}, ${new Date().toISOString()})`;
};
const messagesOf = async (conversationId: string) =>
(await ctx.db.sql`SELECT id, role, content, created_at FROM ai_messages WHERE conversation_id = ${conversationId}`)
.map((r: { id: string; role: string; content: string; created_at: string }) => ({ id: r.id, role: r.role, content: r.content, createdAt: r.created_at }))
.sort((a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : 1)) as { id: string; role: "user" | "assistant" | "sources"; content: string; createdAt: string }[];
/** Chat turns with each assistant message's stored citations attached. */
const turnsOf = async (conversationId: string) => {
const out: { id: string; role: "user" | "assistant"; content: string; createdAt: string; citations?: unknown[] }[] = [];
for (const m of await messagesOf(conversationId)) {
if (m.role === "sources") {
const last = out[out.length - 1];
if (last?.role === "assistant") { try { last.citations = JSON.parse(m.content); } catch {} }
} else out.push({ ...m, role: m.role });
}
return out;
};
const signedIn = (rc: RequestContext) => (rc.user ? null : err(401, "Sign in to use the assistant."));
const ownerOnly = (rc: RequestContext) => (!rc.user ? err(401, "Sign in first.") : rc.user.roles.includes("owner") ? null : err(403, "Only the owner can change AI settings."));
const chat = async (req: Request, rc: RequestContext): Promise<Response> => {
const deny = signedIn(rc);
if (deny) return deny;
const user = rc.user!;
const course = await viewable(user, rc.params.courseId ?? "");
if (!course) return err(404, "Course not found.");
const b = await body(req);
const message = typeof b?.message === "string" ? b.message.trim() : "";
if (!message) return err(400, "Write a message.");
if (message.length > MAX_MESSAGE) return err(400, "That message is too long.");
const resolved = await resolveProvider(user.id);
if (!resolved) return err(409, "AI is not set up. An admin can configure a server provider, or you can add your own key under Account, AI.");
let provider: AiProvider;
try { provider = makeProvider(resolved, user.id); } catch (e) { return err(409, (e as Error).message); }
let conversationId = str(b?.conversationId);
if (conversationId) {
const conv = await conversationOf(conversationId, user.id);
if (!conv || conv.course_id !== course.id) return err(404, "Conversation not found.");
} else {
conversationId = Bun.randomUUIDv7();
await ctx.db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES (${conversationId}, ${user.id}, ${course.id}, ${message.slice(0, 80)}, ${new Date().toISOString()})`;
}
const history = (await turnsOf(conversationId)).slice(-HISTORY_TURNS);
await addMessage(conversationId, "user", message);
const lessonId = str(b?.lessonId);
const lesson = lessonId ? course.lessonIndex.get(lessonId)?.lesson : undefined;
const index = await getCourseIndex(course, ctx.config.dataDir);
const userNotes = await notes.list(user.id, course.id);
const sources = index.search(lesson ? `${message} ${lesson.title}` : message, 6, noteChunks(course, userNotes));
const mentorText = await readMentorText(ctx.courses, course);
const mentorOn = !(await settings.isMentorOff(user.id, course.id));
const convId = conversationId;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const enc = new TextEncoder();
const send = (ev: unknown) => { try { controller.enqueue(enc.encode(JSON.stringify(ev) + "\n")); } catch {} };
send({ type: "meta", conversationId: convId, mentorOn, mentorAvailable: !!mentorText, source: resolved.source, citations: citationsFor(sources) });
let tools: ToolSet | null = null;
try {
const course_ = courseTools({ repo: ctx.courses, course, index, listNotes: (lid) => notes.list(user.id, course.id, lid), getProgress: () => progress.get(user.id, course.id) });
const servers = mcpServersFor(course.id, (await settings.getServer()).mcpServers);
const mcp = servers.length ? await mcpTools(servers, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs }) : null;
for (const w of mcp?.warnings ?? []) send({ type: "notice", message: w });
tools = combineTools(mcp ? [course_, mcp] : [course_]);
const messages: ChatMessage[] = [
{ role: "system", content: buildSystemPrompt({ course, mentorText, mentorOn, sources, lesson: lesson ? { id: lesson.id, title: lesson.title } : null, extraTools: mcp?.names }) },
...history.map((m) => ({ role: m.role, content: m.content }) as ChatMessage),
{ role: "user", content: message },
];
let answer = "";
let cited: unknown[] = [];
for await (const ev of runAssistant({ provider, messages, tools, signal: req.signal })) {
if (ev.type === "done") {
answer = ev.text;
cited = citationsFor([...sources, ...tools.sources]);
send({ type: "done", text: ev.text, citations: cited });
} else send(ev);
}
if (answer) {
await addMessage(convId, "assistant", answer);
await addMessage(convId, "sources", JSON.stringify(cited));
}
} catch (e) {
const m = (e as Error).message;
if (resolved.source === "byok") log(`chat error for user ${user.id} (own key): ${m}`);
send({ type: "error", message: resolved.source === "byok" ? publicProviderError(m) : m });
} finally {
await tools?.close?.().catch(() => {});
try { controller.close(); } catch {}
}
},
});
return new Response(stream, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" } });
};
const generateJob = async (payload: unknown, jc: JobContext) => {
const p = payload as { req: GenerateRequest; userId: string };
const resolved = await resolveProvider(p.userId);
if (!resolved) throw new Error("AI is not set up for this user.");
const provider = makeProvider(resolved, p.userId);
return generateDraft({ provider, repo: ctx.courses, req: p.req, sink, authorId: p.userId, dataDir: ctx.config.dataDir, signal: jc.signal, onProgress: (f, m) => jc.reportProgress(f, m) });
};
return {
name: "ai",
jobs: { "ai.generate": generateJob },
routes: {
"/api/ai/status": {
GET: async (_req, rc) => {
const resolved = rc.user ? await resolveProvider(rc.user.id) : null;
const out: Record<string, unknown> = { enabled: !!resolved, source: resolved?.source ?? null, signedIn: !!rc.user };
const courseId = rc.url.searchParams.get("courseId");
if (courseId) {
const course = await viewable(rc.user, courseId);
if (course) {
out.course = { id: course.id, title: course.manifest?.title ?? course.id };
out.mentor = { available: !!course.manifest?.mentor, on: rc.user ? !(await settings.isMentorOff(rc.user.id, course.id)) : true };
}
}
return json(out);
},
},
"/api/ai/admin/config": {
GET: async (_req, rc) => ownerOnly(rc) ?? json(await settings.getServer()),
PUT: async (req, rc) => {
const deny = ownerOnly(rc);
if (deny) return deny;
const b = await body(req);
if (!b) return err(400, "Invalid JSON.");
const provider = parseProvider(b.provider, "server");
const mcpServers = parseMcp(b.mcpServers);
if (provider === "invalid") return err(400, "Invalid provider. OpenAI-compatible endpoints need a base URL and a model.");
if (mcpServers === "invalid") return err(400, "Invalid MCP server list.");
let allowedBaseUrls: string[] | undefined;
if (b.allowedBaseUrls !== undefined) {
const list = Array.isArray(b.allowedBaseUrls) ? b.allowedBaseUrls.map(String).map((x) => x.trim()).filter(Boolean) : null;
if (!list) return err(400, "allowedBaseUrls must be a list of origins.");
allowedBaseUrls = parseAllowedOrigins(list);
if (allowedBaseUrls.length !== new Set(list.map((x) => x.replace(/\/+$/, ""))).size) return err(400, "Each allowed base URL must be an origin such as http://ollama.internal:11434.");
}
await settings.saveServer({ provider, mcpServers, allowedBaseUrls });
return json(await settings.getServer());
},
},
"/api/ai/admin/mcp/test": {
POST: async (req, rc) => {
const deny = ownerOnly(rc);
if (deny) return deny;
const b = await body(req);
const parsed = parseMcp(b ? [b] : null);
if (parsed === "invalid" || !parsed.length) return err(400, "Invalid MCP server.");
try {
const c = await connectMcp(parsed[0]!, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs ?? 15_000 });
try { return json({ ok: true, tools: (await c.tools()).map((t) => ({ name: t.name, description: t.description })) }); } finally { await c.close(); }
} catch (e) {
return json({ ok: false, error: (e as Error).message });
}
},
},
"/api/ai/account": {
GET: async (_req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
const u = await settings.getUser(rc.user.id);
const s = await settings.getServer();
return json({ byok: u.byok, serverProvider: s.provider ? { kind: s.provider.kind, model: s.provider.model ?? null } : null });
},
},
"/api/ai/account/key": {
PUT: async (req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
const b = await body(req);
const p = b ? parseProvider(b, "user") : "invalid";
if (!p || p === "invalid" || !p.apiKey) return err(400, "Choose a provider and paste your key. OpenAI-compatible endpoints also need a model.");
const base = byokBaseUrl(p.kind, p.baseUrl, await allowedOrigins());
if (!base) return err(400, "That base URL is not allowed for your own key. Use the official endpoint, or ask an admin to allow it.");
await settings.saveUserKey(rc.user.id, { ...p, baseUrl: base === PRESET_BASE_URLS[p.kind] ? undefined : base, apiKey: p.apiKey });
return json((await settings.getUser(rc.user.id)).byok);
},
DELETE: async (_req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
await settings.removeUserKey(rc.user.id);
return json({ ok: true });
},
},
"/api/ai/courses/:courseId/mentor": {
PUT: async (req, rc) => {
const deny = signedIn(rc);
if (deny) return deny;
const course = await viewable(rc.user, rc.params.courseId ?? "");
if (!course) return err(404, "Course not found.");
const b = await body(req);
if (typeof b?.off !== "boolean") return err(400, "Send { off: true | false }.");
await settings.setMentorOff(rc.user!.id, course.id, b.off);
return json({ mentor: { available: !!course.manifest?.mentor, on: !b.off } });
},
},
"/api/ai/courses/:courseId/chat": { POST: chat },
"/api/ai/courses/:courseId/conversations": {
GET: async (_req, rc) => {
const deny = signedIn(rc);
if (deny) return deny;
const rows = await ctx.db.sql`SELECT id, title, created_at FROM ai_conversations WHERE user_id = ${rc.user!.id} AND course_id = ${rc.params.courseId ?? ""}`;
return json(rows.map((r: { id: string; title: string | null; created_at: string }) => ({ id: r.id, title: r.title, createdAt: r.created_at })).sort((a: { createdAt: string }, b: { createdAt: string }) => (a.createdAt < b.createdAt ? 1 : -1)));
},
},
"/api/ai/conversations/:id": {
GET: async (_req, rc) => {
const deny = signedIn(rc);
if (deny) return deny;
const conv = await conversationOf(rc.params.id ?? "", rc.user!.id);
if (!conv) return err(404, "Conversation not found.");
return json({ id: conv.id, courseId: conv.course_id, title: conv.title, messages: await turnsOf(conv.id) });
},
DELETE: async (_req, rc) => {
const deny = signedIn(rc);
if (deny) return deny;
const conv = await conversationOf(rc.params.id ?? "", rc.user!.id);
if (!conv) return err(404, "Conversation not found.");
await ctx.db.sql.begin(async (tx) => {
await tx`DELETE FROM ai_messages WHERE conversation_id = ${conv.id}`;
await tx`DELETE FROM ai_conversations WHERE id = ${conv.id}`;
});
return json({ ok: true });
},
},
"/api/ai/studio/courses": {
GET: async (_req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
const out = [];
for (const c of ctx.courses.list()) {
if (c.status === "ok" && (await canAuthor(rc.user, c.id))) out.push({ id: c.id, title: c.manifest?.title ?? c.id, tracks: c.manifest?.tracks ?? [] });
}
return json(out);
},
},
"/api/ai/studio/sources": {
GET: async (_req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
const courseId = rc.url.searchParams.get("courseId") ?? "";
const course = ctx.courses.get(courseId);
if (!course || course.status !== "ok") return err(404, "Course not found.");
if (!(await canAuthor(rc.user, course.id))) return err(403, "Only authors can generate drafts.");
const lessons = (course.manifest?.modules ?? []).flatMap((m) => m.lessons.map((l) => ({ id: l.id, title: l.title, type: l.type, module: m.title, hasText: l.type !== "video" || !!l.transcript })));
const files = await listFiles(course.root, 800); // authors may use any course file, solutions included
const cache = await listFiles(join(ctx.config.dataDir, "cache", course.id), 400);
return json({ lessons, files, cache });
},
},
"/api/ai/studio/generate": {
POST: async (req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
const b = await body(req);
const g = b ? parseGenerate(b) : null;
if (!g) return err(400, "Choose a course, a kind (document, exercise or questions), a style and a topic.");
const course = ctx.courses.get(g.courseId);
if (!course || course.status !== "ok") return err(404, "Course not found.");
if (!(await canAuthor(rc.user, course.id))) return err(403, "Only authors can generate drafts.");
if (!(await resolveProvider(rc.user.id))) return err(409, "AI is not set up.");
const jobId = await ctx.jobs.enqueue("ai.generate", { req: g, userId: rc.user.id });
return json({ jobId }, 202);
},
},
"/api/ai/studio/jobs/:id": {
GET: async (_req, rc) => {
if (!rc.user) return err(401, "Sign in first.");
const job = await ctx.jobs.get(rc.params.id ?? "");
const owner = (job?.payload as { userId?: string } | null)?.userId;
if (!job || job.kind !== "ai.generate" || (owner !== rc.user.id && !rc.user.roles.includes("owner"))) return err(404, "Job not found.");
return json({ id: job.id, status: job.status, progress: job.progress, message: job.message, result: job.result, error: job.error });
},
},
},
};
}