feat: resolve Phase 1 contract requests and compose every feature
Contracts and core: - Feature gains auth and stop; FeatureFactory; core composeServer builds the context, runs the factories, then the job queue from the union of their jobs (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI. - AppConfig/loadConfig own the layout and settings: courseDirs include <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir, importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python. resolveConfig derives the same for tests; cachePaths shares the importer's cache layout. - canView: the global author role views every course (R14); the learner, studio and AI packages all call it. - migrate(db, packages): core first, then packages in name order. - Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on an empty file is 416. - courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts. Packages: - import, studio and AI read AppConfig instead of process.env; the importer refuses ids used by studio/repos. - learner migrations run through core migrate; library covers fall back to the importer's cached candidate behind a canView-checked route. - studio preview renders through the learner LessonView (anchors honoured, draft URLs); AI drafts go to the studio draft branch for studio courses (as the requesting user) and to <dataDir>/drafts otherwise, and the generator page says which. Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts imports them by package name; nav.roles hides links by role. Tests: compose, config, canView, media headers, migrations order, covers, preview, nav, SPA bundle without server code, and an end-to-end flow on SQLite and Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
427eda6e93
commit
777ae2cc37
72 files changed
+1066
-244
No files matched your search
@@ -24,8 +24,6 @@ export interface AiFeatureOptions {
|
||||
/** 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;
|
||||
}
|
||||
@@ -147,8 +145,9 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
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])];
|
||||
// AppConfig.aiAllowedBaseUrls (STC_AI_ALLOWED_BASE_URLS) plus the admin's list in the server AI settings.
|
||||
const configOrigins = parseAllowedOrigins((ctx.config.aiAllowedBaseUrls ?? []).join(","));
|
||||
const allowedOrigins = async () => [...new Set([...configOrigins, ...(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.
|
||||
@@ -186,9 +185,8 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
};
|
||||
/**
|
||||
* 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).
|
||||
* `course_authors` row) AND see the course under core's `canView`, the one visibility rule (R14; it lets the
|
||||
* global `author` role view every course).
|
||||
*/
|
||||
const canAuthor = async (user: AuthUser, course: LoadedCourse) => {
|
||||
const isAuthorRow = await acl.isAuthor(course.id, user.id);
|
||||
|
||||
@@ -178,7 +178,11 @@ export default function GeneratePage() {
|
||||
{job.status === "failed" ? <p className="ai-error">{job.error}</p> : null}
|
||||
{job.result ? (
|
||||
<>
|
||||
<span className="ai-hint">{job.result.branch ? "Committed to your draft branch " : "Saved as a draft at "}<span className="ai-mono">{job.result.location}</span>. Review it in the studio before publishing.</span>
|
||||
{job.result.branch ? (
|
||||
<span className="ai-hint">Committed to your draft branch <span className="ai-mono">{job.result.location}</span>. Review it in the studio before publishing.</span>
|
||||
) : (
|
||||
<span className="ai-hint">This course is not edited in the studio, so the draft was saved as files on the server at <span className="ai-mono">{job.result.location}</span>. Nothing in the course changed; copy what you want into it yourself.</span>
|
||||
)}
|
||||
<ul className="ai-rows">{job.result.paths.map((p) => <li key={p}><span className="ai-path">{p}</span><span className="ai-meta">draft</span></li>)}</ul>
|
||||
{job.result.warnings.length ? (
|
||||
<div className="ai-section">
|
||||
|
||||
@@ -4,7 +4,7 @@ export const aiWeb: WebFeature = {
|
||||
name: "ai",
|
||||
routes: [
|
||||
{ path: "/c/:courseId/assistant", element: () => import("./AssistantPage") },
|
||||
{ path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin" } },
|
||||
{ path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin", roles: ["owner"] } },
|
||||
{ path: "/account/ai", element: () => import("./AccountAiPage") },
|
||||
{ path: "/studio/generate", element: () => import("./GeneratePage") },
|
||||
],
|
||||
|
||||
@@ -1,11 +1,2 @@
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
// Learner URLs the AI pages link to; the learner owns the routes (shared helpers in @stc/contracts, request 7).
|
||||
export { courseUrl, lessonUrl } from "@stc/contracts";
|
||||
Reference in new issue
Block a user