fix(ai): lessonUrl builder in one place; authoring follows core canView

- Learner links go through lessonUrl/courseUrl (src/web/urls.ts).
- Generation, the studio course list and file listing require an authoring
  right and core canView, so a global author needs a grant for a restricted
  course. Contract requests 8 (global author in canView) and 9
  (STC_AI_ALLOWED_BASE_URLS in AppConfig).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 04:49:48 +01:00
co-authored by Claude Opus 5.5
parent ead7327bb1
commit f3ce2e34bf
7 changed files with 87 additions and 9 deletions
+14 -5
View File
@@ -184,8 +184,17 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
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));
/**
* Generation and the studio file listing: the user must hold an authoring right (owner, the `author` role, or a
* `course_authors` row) AND see the course under core's `canView`, the same visibility rule as everywhere else.
* A global `author` therefore cannot reach a restricted course without an ACL grant or a course_authors row
* (see contract request 8).
*/
const canAuthor = async (user: AuthUser, course: LoadedCourse) => {
const isAuthorRow = await acl.isAuthor(course.id, user.id);
const mayAuthor = user.roles.includes("owner") || user.roles.includes("author") || isAuthorRow;
return mayAuthor && canView(user, course, await acl.listForCourse(course.id), isAuthorRow);
};
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}`;
@@ -427,7 +436,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
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 ?? [] });
if (c.status === "ok" && (await canAuthor(rc.user, c))) out.push({ id: c.id, title: c.manifest?.title ?? c.id, tracks: c.manifest?.tracks ?? [] });
}
return json(out);
},
@@ -438,7 +447,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
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.");
if (!(await canAuthor(rc.user, course))) 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);
@@ -453,7 +462,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
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 canAuthor(rc.user, course))) 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);
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Pill, SegmentedToggle } from "@stc/ui";
import { api, readNdjson, type ChatEvent, type Citation, type Status } from "./api";
import { courseUrl, lessonUrl } from "./urls";
import { Markdown, MentorBanner, PageHead } from "./components";
type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; tools?: string[]; error?: string; notices?: string[] };
@@ -21,7 +22,7 @@ function Sources({ courseId, citations }: { courseId: string; citations: Citatio
{rows.map((c) => (
<li key={c.path}>
<span className="ai-path">{c.path}</span>
{c.lessonId ? <Link className="ai-meta" to={`/c/${encodeURIComponent(courseId)}/${encodeURIComponent(c.lessonId)}`}>{c.lessonTitle ?? c.lessonId}</Link> : <span className="ai-meta">{c.kind}</span>}
{c.lessonId ? <Link className="ai-meta" to={lessonUrl(courseId, c.lessonId)}>{c.lessonTitle ?? c.lessonId}</Link> : <span className="ai-meta">{c.kind}</span>}
</li>
))}
</ul>
@@ -120,7 +121,7 @@ export default function AssistantPage() {
const newConversation = () => { abortRef.current?.abort(); setConversationId(null); setTurns([]); };
const courseTitle = status?.course?.title ?? courseId;
const chap = <Link to={`/c/${encodeURIComponent(courseId)}`}>{courseTitle}</Link>;
const chap = <Link to={courseUrl(courseId)}>{courseTitle}</Link>;
if (loadError && !status) return <div className="ai-page"><PageHead chap={chap} title="Course assistant" /><p className="ai-error">{loadError}</p></div>;
if (!status) return <div className="ai-page"><PageHead chap={chap} title="Course assistant" /></div>;
+1
View File
@@ -9,3 +9,4 @@ export const aiWeb: WebFeature = {
{ path: "/studio/generate", element: () => import("./GeneratePage") },
],
};
export { courseUrl, lessonUrl } from "./urls";
+11
View File
@@ -0,0 +1,11 @@
/**
* Learner URLs the AI pages link to. The learner app (WP-A) owns these routes; keep every builder here so
* Phase 2 can change the shape in one place (contract request 7).
*/
export function courseUrl(courseId: string): string {
return `/c/${encodeURIComponent(courseId)}`;
}
export function lessonUrl(courseId: string, lessonId: string): string {
return `${courseUrl(courseId)}/${encodeURIComponent(lessonId)}`;
}
+26
View File
@@ -218,3 +218,29 @@ forEachDb("provider errors on a user's own key show the status and a fixed messa
const t2 = await (await call("POST", "/api/ai/courses/sample/chat", "owner", { message: "hello" })).text();
expect(t2).toContain("502: INTERNAL-SECRET-BODY");
});
forEachDb("authoring uses core canView: a global author needs a grant for a restricted course", async (db) => {
const { call } = await setup(db);
await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] });
const body = { courseId: "secret", kind: "questions", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId: "m2-text" }] };
const courses = async (u: string) => ((await (await call("GET", "/api/ai/studio/courses", u)).json()) as { id: string }[]).map((c) => c.id).sort();
// Public course: the author role is enough. Restricted course: not without a grant.
expect(await courses("auth")).toEqual(["sample"]);
expect((await call("GET", "/api/ai/studio/sources?courseId=secret", "auth")).status).toBe(403);
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(403);
// The owner sees everything.
expect(await courses("owner")).toEqual(["sample", "secret"]);
expect((await call("POST", "/api/ai/studio/generate", "owner", body)).status).toBe(202);
// An ACL grant (core canView) lets the global author in.
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'auth')`;
expect(await courses("auth")).toEqual(["sample", "secret"]);
expect((await call("POST", "/api/ai/studio/generate", "auth", body)).status).toBe(202);
// A course_authors row makes a learner an author of that course only.
expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(403);
await db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'bob')`;
expect(await courses("bob")).toEqual(["secret"]);
expect((await call("POST", "/api/ai/studio/generate", "bob", body)).status).toBe(202);
// A grant to view alone does not make a learner an author.
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'ann')`;
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
});
+10
View File
@@ -34,3 +34,13 @@ test("web routes", () => {
expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]);
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin" });
});
test("lesson links go through one builder", async () => {
const { lessonUrl, courseUrl } = await import("../src/web/urls");
expect(lessonUrl("sample", "m2-ex-add")).toBe("/c/sample/m2-ex-add");
expect(lessonUrl("S08 - Docker", "l 1/x")).toBe("/c/S08%20-%20Docker/l%201%2Fx");
expect(courseUrl("sample")).toBe("/c/sample");
const src = await Bun.file(new URL("../src/web/AssistantPage.tsx", import.meta.url)).text();
expect(src).toContain("lessonUrl(courseId, c.lessonId)");
expect(src).not.toMatch(/`\/c\//);
});