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:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 15:02:46 +01:00
1 parent 427eda6e93
commit 777ae2cc37
72 files changed
+1066 -244

No files matched your search

+5 -7
View File
@@ -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);
+5 -1
View File
@@ -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">
+1 -1
View File
@@ -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") },
],
+2 -11
View File
@@ -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";