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)}`;
}