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
@@ -1,4 +1,5 @@
|
||||
import type { AppConfig } from "@stc/contracts";
|
||||
import { storeDirs } from "./paths";
|
||||
import { createStudioStore, StudioError, safeAuthor, type Identity } from "./store";
|
||||
|
||||
/** Programmatic draft API for other packages (WP-D's studio generator). Writes only to `draft/<authorId>`, never publishes. */
|
||||
@@ -16,8 +17,11 @@ export interface DraftWriterOptions {
|
||||
identity?: (authorId: string) => Promise<Identity | null> | Identity | null;
|
||||
}
|
||||
|
||||
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, opts: DraftWriterOptions = {}): DraftWriter {
|
||||
const store = createStudioStore({ dataDir: ctx.config.dataDir });
|
||||
export function createDraftWriter(
|
||||
ctx: { config: Pick<AppConfig, "dataDir"> & Partial<Pick<AppConfig, "studioReposDir" | "importedCoursesDir" | "studioWorktreesDir">> },
|
||||
opts: DraftWriterOptions = {},
|
||||
): DraftWriter {
|
||||
const store = createStudioStore(storeDirs(ctx.config));
|
||||
return {
|
||||
async writeDraftFiles(courseId, authorId, files, message) {
|
||||
if (!store.isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404);
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { basename, extname, resolve, sep } from "node:path";
|
||||
import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
|
||||
import { serveMedia } from "@stc/core";
|
||||
import { canView, serveMedia } from "@stc/core";
|
||||
import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format";
|
||||
import { aclRepo } from "@stc/db";
|
||||
import { createDraftWriter, type DraftWriter } from "./draft-writer";
|
||||
import { studioWorktreesDir } from "./paths";
|
||||
import { storeDirs, studioWorktreesDir } from "./paths";
|
||||
import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioError, type FileWrite, type StudioStore } from "./store";
|
||||
import { createPublishedWatcher, type PublishedWatcher } from "./watch";
|
||||
|
||||
export const OUTSIDE_MESSAGE = "This course is managed outside STCourses";
|
||||
export const IMPORTED_MESSAGE = "Imported course: read-only in the studio for now";
|
||||
|
||||
/** Upload cap in bytes: STC_UPLOAD_MAX_MB, default 100 (review I2). */
|
||||
export function uploadMaxBytes(env: Record<string, string | undefined> = process.env): number {
|
||||
const mb = Number(env.STC_UPLOAD_MAX_MB ?? "100");
|
||||
return (Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
|
||||
/** Upload cap in bytes from AppConfig.uploadMaxMb (STC_UPLOAD_MAX_MB, default 100; review I2). */
|
||||
export function uploadMaxBytes(mb: number | undefined): number {
|
||||
return (typeof mb === "number" && Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** Server-side allowlist by extension for uploads: text, documents, images, media, archives and code. */
|
||||
@@ -73,19 +72,29 @@ function manifestToYaml(m: unknown): string {
|
||||
}
|
||||
|
||||
export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature {
|
||||
const store = createStudioStore({ dataDir: ctx.config.dataDir });
|
||||
const store = createStudioStore(storeDirs(ctx.config));
|
||||
const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs });
|
||||
const acl = aclRepo(ctx.db);
|
||||
const wtDir = resolve(studioWorktreesDir(ctx.config.dataDir));
|
||||
const wtDir = resolve(ctx.config.studioWorktreesDir ?? studioWorktreesDir(ctx.config.dataDir));
|
||||
const draftWriter = createDraftWriter(ctx);
|
||||
|
||||
const hasRole = (u: AuthUser) => u.roles.includes("owner") || u.roles.includes("author");
|
||||
const authoredCourses = async (u: AuthUser): Promise<string[]> =>
|
||||
(await ctx.db.sql`SELECT course_id FROM course_authors WHERE user_id = ${u.id}`).map((r: { course_id: string }) => r.course_id);
|
||||
const canEdit = async (u: AuthUser, courseId: string) => hasRole(u) || (await acl.isAuthor(courseId, u.id));
|
||||
/**
|
||||
* An authoring right (owner, the `author` role, or a course_authors row) and, for a course the server has loaded,
|
||||
* core `canView` (the one visibility rule, R14). A studio course not loaded yet (e.g. its published manifest is
|
||||
* broken) has no visibility to check.
|
||||
*/
|
||||
const canEdit = async (u: AuthUser, courseId: string) => {
|
||||
const isAuthor = await acl.isAuthor(courseId, u.id);
|
||||
if (!hasRole(u) && !isAuthor) return false;
|
||||
const c = ctx.courses.get(courseId);
|
||||
return !c || canView(u, c, await acl.listForCourse(courseId), isAuthor);
|
||||
};
|
||||
const isStudioCourse = (c: LoadedCourse) => resolve(c.manifestPath).startsWith(wtDir + sep);
|
||||
const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid`, owner: u.roles.includes("owner") });
|
||||
const importedRoot = (() => { const d = resolve(ctx.config.dataDir, "courses"); return existsSync(d) ? realpathSync(d) : d; })();
|
||||
const importedRoot = (() => { const d = resolve(ctx.config.importedCoursesDir ?? resolve(ctx.config.dataDir, "courses")); return existsSync(d) ? realpathSync(d) : d; })();
|
||||
/** Why a course that is not studio-managed is read-only here. */
|
||||
const readOnlyMessage = (c: LoadedCourse): string => {
|
||||
const kind = c.manifest?.source?.kind;
|
||||
@@ -250,7 +259,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
|
||||
|
||||
"/api/studio/courses/:courseId/upload": {
|
||||
POST: course(async (req, _rc, user, courseId) => {
|
||||
const max = uploadMaxBytes();
|
||||
const max = uploadMaxBytes(ctx.config.uploadMaxMb);
|
||||
const declared = Number(req.headers.get("content-length") ?? "0");
|
||||
if (declared > max) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`);
|
||||
const bytes = await readCapped(req, max);
|
||||
|
||||
@@ -14,10 +14,18 @@ export function studioReposDir(dataDir: string): string {
|
||||
/**
|
||||
* The course dirs the CourseRepository must scan: the configured ones, the importer's plain folders in
|
||||
* `<dataDir>/courses`, and studio courses through their published worktree in `<dataDir>/worktrees`.
|
||||
* Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md.
|
||||
* Core `loadConfig`/`resolveConfig` already include both in `config.courseDirs`; this stays for callers that build
|
||||
* an AppConfig by hand.
|
||||
*/
|
||||
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs">): string[] {
|
||||
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs"> & Partial<Pick<AppConfig, "importedCoursesDir" | "studioWorktreesDir">>): string[] {
|
||||
const dirs = config.courseDirs.map((d) => resolve(d));
|
||||
for (const d of [resolve(config.dataDir, "courses"), resolve(studioWorktreesDir(config.dataDir))]) if (!dirs.includes(d)) dirs.push(d);
|
||||
const imported = config.importedCoursesDir ?? resolve(config.dataDir, "courses");
|
||||
const worktrees = config.studioWorktreesDir ?? studioWorktreesDir(config.dataDir);
|
||||
for (const d of [resolve(imported), resolve(worktrees)]) if (!dirs.includes(d)) dirs.push(d);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** Store options from the server configuration (core AppConfig owns the layout). */
|
||||
export function storeDirs(config: Pick<AppConfig, "dataDir"> & Partial<Pick<AppConfig, "studioReposDir" | "importedCoursesDir" | "studioWorktreesDir">>) {
|
||||
return { dataDir: config.dataDir, reposDir: config.studioReposDir, importedDir: config.importedCoursesDir, worktreesDir: config.studioWorktreesDir };
|
||||
}
|
||||
@@ -123,6 +123,12 @@ export type Actor = Identity & { id: string; owner?: boolean };
|
||||
|
||||
export interface StudioStoreOptions {
|
||||
dataDir: string;
|
||||
/** AppConfig.studioReposDir (default `<dataDir>/studio/repos`). */
|
||||
reposDir?: string;
|
||||
/** AppConfig.importedCoursesDir (default `<dataDir>/courses`): ids taken by the importer. */
|
||||
importedDir?: string;
|
||||
/** AppConfig.studioWorktreesDir (default `<dataDir>/worktrees`). */
|
||||
worktreesDir?: string;
|
||||
publishedBranch?: string;
|
||||
/** Tests only: allow local-path remotes (the HTTP feature never sets this). */
|
||||
allowLocalRemotes?: boolean;
|
||||
@@ -132,9 +138,9 @@ export function createStudioStore(opts: StudioStoreOptions) {
|
||||
const dataDir = opts.dataDir;
|
||||
const main = opts.publishedBranch ?? "main";
|
||||
// Studio repos live apart from the importer's plain folders in <dataDir>/courses (controller ruling, review I4).
|
||||
const reposDir = join(dataDir, "studio", "repos");
|
||||
const importedDir = join(dataDir, "courses");
|
||||
const worktreesDir = join(dataDir, "worktrees");
|
||||
const reposDir = opts.reposDir ?? join(dataDir, "studio", "repos");
|
||||
const importedDir = opts.importedDir ?? join(dataDir, "courses");
|
||||
const worktreesDir = opts.worktreesDir ?? join(dataDir, "worktrees");
|
||||
const remoteGuard = opts.allowLocalRemotes ? ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"] : REMOTE_GUARD;
|
||||
|
||||
const paths = (courseId: string) => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function LessonEditor({ ws, lessonId, onGone }: { ws: Workspace; lessonId
|
||||
</Field>
|
||||
<div className="studio-preview-wrap" aria-live="off">
|
||||
<p className="faint" id="preview-label" style={{ margin: "0 0 10px" }}>Preview</p>
|
||||
<LessonPreview courseId={ws.courseId} markdown={body.content} lesson={lesson} resolveUrl={(rel) => ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
|
||||
<LessonPreview courseId={ws.courseId} markdown={body.content} lesson={lesson} file={mdPath} resolveCourseUrl={ws.resolveUrl} resolveUrl={(rel) => ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from "react";
|
||||
import { extractSection } from "@stc/course-format/markdown";
|
||||
import { LessonView, type LessonPayload } from "@stc/feature-learner/web";
|
||||
import type { LessonPreviewProps } from "./preview";
|
||||
|
||||
/**
|
||||
* The studio's live preview, rendered by the learner's real `LessonView` (contract request studio 3) in preview
|
||||
* mode: no notes, attempts or progress. Draft files (images, links) resolve through the studio's raw-file route.
|
||||
* Video lessons preview their transcript; exercises preview their instructions.
|
||||
*/
|
||||
export default function LearnerLessonPreview(props: LessonPreviewProps) {
|
||||
const { courseId, markdown, lesson, file, resolveCourseUrl, resolveUrl } = props;
|
||||
const payload = useMemo<LessonPayload>(() => {
|
||||
const anchored = lesson?.anchor ? extractSection(markdown, lesson.anchor) : null;
|
||||
const body = lesson?.anchor && anchored === null ? `> Heading anchor \`#${lesson.anchor}\` was not found in this file.\n\n${markdown}` : anchored ?? markdown;
|
||||
return {
|
||||
courseId, courseTitle: "",
|
||||
lesson: { id: lesson?.id ?? "preview", title: lesson?.title ?? "", type: lesson?.type === "exercise" ? "exercise" : "text", minutes: null },
|
||||
module: { id: "", title: "Preview", index: 0 },
|
||||
locked: false,
|
||||
markdown: body, baseFile: resolveCourseUrl ? file ?? null : null, trackMarkdown: {}, tracks: [],
|
||||
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null,
|
||||
prev: null, next: null, lessonFiles: {},
|
||||
};
|
||||
}, [courseId, markdown, lesson?.id, lesson?.title, lesson?.type, lesson?.anchor, file, resolveCourseUrl]);
|
||||
// With the file known, links resolve course-relative; otherwise relative to the markdown file (the v1 contract).
|
||||
return <div className="studio-preview"><LessonView lesson={payload} preview look="reading" mediaUrl={resolveCourseUrl ?? resolveUrl} /></div>;
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { lazy, Suspense, type ComponentType } from "react";
|
||||
import { renderMarkdown } from "./markdown";
|
||||
|
||||
/**
|
||||
* Live-preview adapter. v1 renders with a small built-in Markdown renderer; Phase 2 swaps in the learner
|
||||
* package's `LessonView` via `setLessonPreview` (docs/contract-requests/studio.md).
|
||||
* Live-preview adapter. The default is the learner's `LessonView` (./learnerPreview, loaded lazily so the
|
||||
* lesson renderer is fetched only when an editor opens); `setLessonPreview` can replace it, and the small built-in
|
||||
* Markdown renderer stays as `SimpleMarkdownPreview` and as the loading fallback.
|
||||
*/
|
||||
export interface LessonPreviewProps {
|
||||
courseId: string;
|
||||
markdown: string;
|
||||
lesson?: { id: string; title: string; type: string; anchor?: string } | null;
|
||||
/** Maps a course-relative path (images, attachments) to a URL that serves the draft file. */
|
||||
/** Maps a path relative to the markdown file (images, attachments) to a URL that serves the draft file. */
|
||||
resolveUrl: (rel: string) => string;
|
||||
/** Course-relative path of the markdown file. */
|
||||
file?: string;
|
||||
/** Maps a course-relative path to a URL that serves the draft file (preferred over `resolveUrl` when given). */
|
||||
resolveCourseUrl?: (courseRel: string) => string;
|
||||
}
|
||||
export type LessonPreviewComponent = ComponentType<LessonPreviewProps>;
|
||||
|
||||
@@ -18,11 +23,12 @@ export function SimpleMarkdownPreview({ markdown, resolveUrl }: LessonPreviewPro
|
||||
return <div className="studio-preview prose reading" dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown, { resolveUrl }) }} />;
|
||||
}
|
||||
|
||||
let current: LessonPreviewComponent = SimpleMarkdownPreview;
|
||||
const LearnerPreview = lazy(() => import("./learnerPreview"));
|
||||
let current: LessonPreviewComponent = LearnerPreview;
|
||||
export function setLessonPreview(c: LessonPreviewComponent): void {
|
||||
current = c;
|
||||
}
|
||||
export function LessonPreview(props: LessonPreviewProps) {
|
||||
const C = current;
|
||||
return <C {...props} />;
|
||||
return <Suspense fallback={<SimpleMarkdownPreview {...props} />}><C {...props} /></Suspense>;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { WebFeature } from "@stc/contracts";
|
||||
export const studioWeb: WebFeature = {
|
||||
name: "studio",
|
||||
routes: [
|
||||
{ path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header" } },
|
||||
{ path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header", roles: ["owner", "author"] } },
|
||||
{ path: "/studio/:courseId", element: () => import("./ui/CourseStudio") },
|
||||
],
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user