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:
co-authored by
Claude Opus 5.5
parent
ead7327bb1
commit
f3ce2e34bf
@@ -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>;
|
||||
|
||||
@@ -9,3 +9,4 @@ export const aiWeb: WebFeature = {
|
||||
{ path: "/studio/generate", element: () => import("./GeneratePage") },
|
||||
],
|
||||
};
|
||||
export { courseUrl, lessonUrl } from "./urls";
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user