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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
427eda6e93
commit
777ae2cc37
@@ -1,6 +1,7 @@
|
||||
// Server
|
||||
export { migrationsDirs, runMigrations } from "./server/migrate";
|
||||
export { learnerFeature } from "./server/feature";
|
||||
export { userIdentity } from "./server/identity";
|
||||
// Browser (also available without the server half as "@stc/feature-learner/web")
|
||||
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
|
||||
// Shared JSON shapes
|
||||
|
||||
@@ -30,8 +30,8 @@ export function canEdit(user: AuthUser | null, courseId: string, idx: AccessInde
|
||||
|
||||
/**
|
||||
* Learners see courses with a manifest that core `canView` allows; owners also see broken ones. The author input
|
||||
* is per-course authorship only, the same as core's media route, so every route applies one rule. (The global
|
||||
* `author` role grants viewing only once Phase 2 extends `canView`; contract request learner §6.)
|
||||
* is per-course authorship (course_authors), the same as core's media route; core `canView` itself lets the owner
|
||||
* and the global `author` role view every course (R14), so every route applies one rule.
|
||||
*/
|
||||
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
|
||||
if (!c.manifest) return isOwner(user);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../shared/types";
|
||||
import { mediaUrl } from "../shared/types";
|
||||
import { accessIndex, courseFor, isOwner, visible } from "./access";
|
||||
import { coverUrlFor } from "./covers";
|
||||
import { moduleLocks } from "./gating";
|
||||
import { json, notFound, param } from "./http";
|
||||
|
||||
@@ -93,7 +94,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
|
||||
const doneCount = lessons.filter((l) => progress[l.id]?.state === "done").length;
|
||||
return {
|
||||
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [],
|
||||
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
|
||||
coverUrl: coverUrlFor(ctx, m),
|
||||
accent: m.accent ?? null, formats: formatsOf(m), lessonCount: lessons.length, minutes,
|
||||
collections: m.collections ?? [], tracks: (m.tracks ?? []).map((t) => t.label), source: m.source?.kind ?? null,
|
||||
addedAt: addedAt(c), status: c.status, progress: lessons.length ? doneCount / lessons.length : 0, doneCount,
|
||||
@@ -204,7 +205,7 @@ export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
|
||||
}));
|
||||
const payload: CoursePayload = {
|
||||
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [], accent: m.accent ?? null,
|
||||
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
|
||||
coverUrl: coverUrlFor(ctx, m),
|
||||
tracks: m.tracks ?? [], gating: m.gating, source: m.source?.kind ?? null, status: course.status,
|
||||
modules, progress, canEdit: found.canEdit, signedIn: !!rc.user,
|
||||
diagnostics: found.canEdit ? course.diagnostics.map((d) => ({ level: d.level, lessonId: d.lessonId, path: d.path, message: d.message })) : [],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Feature, ServerContext } from "@stc/contracts";
|
||||
import { cachePaths, serveMedia } from "@stc/core";
|
||||
import type { Manifest } from "@stc/course-format";
|
||||
import { mediaUrl } from "../shared/types";
|
||||
import { courseFor } from "./access";
|
||||
import { notFound } from "./http";
|
||||
|
||||
const CANDIDATE = /^(frame-\d+|deck-[A-Za-z0-9._-]+)\.jpg$/;
|
||||
|
||||
/** The importer's first cover candidate (`<cacheDir>/covers/<courseId>/frame-N.jpg | deck-<id>.jpg`), if any. */
|
||||
export function coverCandidate(ctx: ServerContext, courseId: string): string | null {
|
||||
let dir: string;
|
||||
try { dir = cachePaths(ctx.config).coversDir(courseId); } catch { return null; }
|
||||
let names: string[];
|
||||
try { names = readdirSync(dir).filter((n) => CANDIDATE.test(n)); } catch { return null; }
|
||||
if (!names.length) return null;
|
||||
// Prefer video frames (the importer's own first choice), then deck covers; numeric order within frames.
|
||||
names.sort((a, b) => (a.startsWith("frame-") === b.startsWith("frame-") ? a.localeCompare(b, "en", { numeric: true }) : a.startsWith("frame-") ? -1 : 1));
|
||||
return join(dir, names[0]!);
|
||||
}
|
||||
|
||||
export const coverRoute = (courseId: string) => `/api/courses/${encodeURIComponent(courseId)}/cover`;
|
||||
|
||||
/** The manifest's cover if it resolves inside the course; else the importer's cached candidate; else null. */
|
||||
export function coverUrlFor(ctx: ServerContext, m: Manifest): string | null {
|
||||
if (m.cover && ctx.courses.resolvePath(m.id, m.cover)) return mediaUrl(m.id, m.cover);
|
||||
return coverCandidate(ctx, m.id) ? coverRoute(m.id) : null;
|
||||
}
|
||||
|
||||
/** Serves the cached cover candidate to users who may view the course (core canView; 404 otherwise). */
|
||||
export function coverRoutes(ctx: ServerContext): Feature["routes"] {
|
||||
return {
|
||||
"/api/courses/:courseId/cover": {
|
||||
GET: async (req, rc) => {
|
||||
const found = await courseFor(ctx, rc.user, rc.params.courseId ?? "");
|
||||
if (!found) return notFound();
|
||||
const file = coverCandidate(ctx, found.course.id);
|
||||
if (!file) return notFound();
|
||||
const res = await serveMedia(req, file);
|
||||
res.headers.set("Cache-Control", "private, max-age=300");
|
||||
return res;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AuthService, Feature, RouteHandler, ServerContext } from "@stc/contracts";
|
||||
import type { AuthService, Feature, ServerContext } from "@stc/contracts";
|
||||
import { createAuth } from "./auth";
|
||||
import { runMigrations } from "./migrate";
|
||||
import { json } from "./http";
|
||||
import { adminRoutes } from "./admin";
|
||||
import { libraryRoutes } from "./catalog";
|
||||
import { coverRoutes } from "./covers";
|
||||
import { lessonRoutes } from "./lesson";
|
||||
import { userDataRoutes } from "./userdata";
|
||||
import { codeRoutes } from "./code";
|
||||
@@ -16,9 +16,8 @@ import { runnerFrameRoutes } from "./runnerFrame";
|
||||
* Returns the routes plus the `AuthService` that replaces the server's anonymous one.
|
||||
*/
|
||||
export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthService } {
|
||||
const ready = runMigrations(ctx.db);
|
||||
ready.catch((e) => console.error("[learner] migrations failed", e));
|
||||
const auth = createAuth(ctx, ready);
|
||||
// The server runs this package's migrations (migrationsDirs) in core migrate() before any feature is built.
|
||||
const auth = createAuth(ctx, Promise.resolve());
|
||||
const routes: Feature["routes"] = {
|
||||
"/api/auth/*": {
|
||||
GET: (req, rc) => auth.handler(req, rc.user),
|
||||
@@ -30,6 +29,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
|
||||
},
|
||||
...adminRoutes(ctx, auth.service.invalidate),
|
||||
...libraryRoutes(ctx),
|
||||
...coverRoutes(ctx),
|
||||
...lessonRoutes(ctx),
|
||||
...userDataRoutes(ctx),
|
||||
...codeRoutes(ctx),
|
||||
@@ -37,11 +37,5 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
|
||||
...assetRoutes(),
|
||||
...runnerFrameRoutes(),
|
||||
};
|
||||
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
|
||||
for (const methods of Object.values(routes)) {
|
||||
for (const [m, h] of Object.entries(methods)) {
|
||||
(methods as Record<string, RouteHandler>)[m] = async (req, rc) => { await ready; return h!(req, rc); };
|
||||
}
|
||||
}
|
||||
return { name: "learner", routes, auth: auth.service };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Db } from "@stc/db";
|
||||
|
||||
/** A user's display name and email (for commit authorship, e.g. the AI generator's studio drafts); null if unknown. */
|
||||
export async function userIdentity(db: Db, userId: string): Promise<{ name: string; email: string } | null> {
|
||||
const rows = await db.sql`SELECT name, email FROM auth_user WHERE id = ${userId}`;
|
||||
const r = rows[0] as { name: string | null; email: string } | undefined;
|
||||
if (!r) return null;
|
||||
return { name: r.name?.trim() || r.email, email: r.email };
|
||||
}
|
||||
@@ -1,33 +1,10 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Db } from "@stc/db";
|
||||
import { migrate, type Db } from "@stc/db";
|
||||
|
||||
/** Folders holding this package's migrations; Phase 2 wires these into `migrate()`. */
|
||||
/** Folders holding this package's migrations; the server passes them to core `migrate()` (contract request 2). */
|
||||
export const migrationsDirs: string[] = [join(import.meta.dir, "..", "..", "migrations")];
|
||||
|
||||
function statements(text: string): string[] {
|
||||
const noComments = text.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n");
|
||||
return noComments.split(";").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the learner migrations with the same bookkeeping as `@stc/db`'s `migrate()`
|
||||
* (table `schema_migrations`, keyed by file name). Safe to call repeatedly.
|
||||
*/
|
||||
export async function runMigrations(db: Db, dirs: string[] = migrationsDirs): Promise<string[]> {
|
||||
await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
|
||||
const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name));
|
||||
const applied: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
|
||||
if (done.has(name)) continue;
|
||||
const stmts = statements(readFileSync(join(dir, name), "utf8"));
|
||||
await db.sql.begin(async (tx) => {
|
||||
for (const s of stmts) await tx.unsafe(s);
|
||||
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`;
|
||||
});
|
||||
applied.push(name);
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
/** Core migrations plus the learner's, through core `migrate()` (tests and tools; the server migrates once at startup). */
|
||||
export function runMigrations(db: Db): Promise<string[]> {
|
||||
return migrate(db, { "feature-learner": migrationsDirs });
|
||||
}
|
||||
|
||||
@@ -182,8 +182,7 @@ export function fmtSize(n: number | null | undefined): string {
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`;
|
||||
export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`;
|
||||
export { lessonUrl as lessonHref, courseUrl as courseHref } from "@stc/contracts";
|
||||
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try { await navigator.clipboard.writeText(text); return true; } catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
import { COURSE_ROUTE, LESSON_ROUTE, type WebFeature } from "@stc/contracts";
|
||||
|
||||
export { LessonView, type LessonViewProps } from "./lesson/LessonView";
|
||||
export type { LessonPayload } from "../shared/types";
|
||||
export { ensureStyles as ensureLearnerStyles } from "./api";
|
||||
|
||||
/** Learner routes: library, course pages, lessons, notes and the account pages. */
|
||||
@@ -8,9 +9,9 @@ export const learnerWeb: WebFeature = {
|
||||
name: "learner",
|
||||
routes: [
|
||||
{ path: "/", element: () => import("./library/LibraryPage") },
|
||||
{ path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
|
||||
{ path: COURSE_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
|
||||
{ path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) },
|
||||
{ path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
|
||||
{ path: LESSON_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
|
||||
{ path: "/account", element: () => import("./account/AccountPage") },
|
||||
{ path: "/account/admin", element: () => import("./account/AdminPage") },
|
||||
],
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface LessonViewProps {
|
||||
signedIn?: boolean;
|
||||
/** Studio preview: no notes, no saved attempts, no progress reports. */
|
||||
preview?: boolean;
|
||||
/** URL for a course-relative file in the lesson text (the studio preview points these at draft files). */
|
||||
mediaUrl?: (courseRel: string) => string;
|
||||
onProgress?: (state: ProgressState, positionSeconds?: number) => void;
|
||||
/** An exercise attempt was saved (the server already updated progress). */
|
||||
onSaved?: () => void;
|
||||
@@ -85,12 +87,12 @@ function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track
|
||||
);
|
||||
}
|
||||
|
||||
function useLessonHtml(lesson: LessonPayload, track: string | null): string | null {
|
||||
function useLessonHtml(lesson: LessonPayload, track: string | null, media?: (courseRel: string) => string): string | null {
|
||||
return useMemo(() => {
|
||||
const md = (track && lesson.trackMarkdown[track]) || lesson.markdown;
|
||||
if (!md) return null;
|
||||
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true });
|
||||
}, [lesson, track]);
|
||||
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true, mediaUrl: media });
|
||||
}, [lesson, track, media]);
|
||||
}
|
||||
|
||||
function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) {
|
||||
@@ -109,7 +111,7 @@ function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayloa
|
||||
|
||||
function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) {
|
||||
const { lesson, track } = p;
|
||||
const html = useLessonHtml(lesson, track);
|
||||
const html = useLessonHtml(lesson, track, p.mediaUrl);
|
||||
const cmd = localCommandFor(lesson.localCommand, track);
|
||||
const minutes = lesson.lesson.minutes;
|
||||
const hasMeta = !!minutes || lesson.tracks.length > 1;
|
||||
@@ -228,9 +230,9 @@ function useVideoProgress(v: React.RefObject<HTMLVideoElement | null>, p: Lesson
|
||||
|
||||
function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) {
|
||||
const { lesson, track } = p;
|
||||
const html = useLessonHtml(lesson, track);
|
||||
const html = useLessonHtml(lesson, track, p.mediaUrl);
|
||||
const hintsHtml = useMemo(() => (lesson.hints.length
|
||||
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles })
|
||||
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, mediaUrl: p.mediaUrl })
|
||||
: null), [lesson, track]);
|
||||
const cmd = localCommandFor(lesson.localCommand, track);
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface RenderOptions {
|
||||
lessonFiles?: Record<string, string>;
|
||||
/** Drop a leading heading (the page shows the lesson title itself). */
|
||||
stripFirstHeading?: boolean;
|
||||
/** URL for a course-relative file; default `/api/media/<course>/<path>` (the studio preview serves draft files). */
|
||||
mediaUrl?: (courseRel: string) => string;
|
||||
}
|
||||
|
||||
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
@@ -226,7 +228,7 @@ export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "i
|
||||
if (rel === null) return null;
|
||||
const lessonId = kind === "link" ? opts.lessonFiles?.[rel] : undefined;
|
||||
if (lessonId) return { url: `/c/${encodeURIComponent(opts.courseId)}/${encodeURIComponent(lessonId)}${hash ? `#${hash}` : ""}`, external: false };
|
||||
return { url: mediaUrl(opts.courseId, rel) + (hash ? `#${hash}` : ""), external: false };
|
||||
return { url: (opts.mediaUrl ? opts.mediaUrl(rel) : mediaUrl(opts.courseId, rel)) + (hash ? `#${hash}` : ""), external: false };
|
||||
}
|
||||
|
||||
export function renderLessonHtml(md: string, opts: RenderOptions): string {
|
||||
|
||||
Reference in New Issue
Block a user