merge: learner

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 14:40:29 +01:00
co-authored by Claude Opus 5.5
73 changed files with 6870 additions and 9 deletions
+19 -7
View File
@@ -1,25 +1,35 @@
import type { AppConfig, AuthService, Feature, JobHandler, ServerContext } from "@stc/contracts";
import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerContext } from "@stc/contracts";
import { createApp, createJobQueue, loadConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { learnerFeature } from "@stc/feature-learner";
import indexHtml from "../../web/index.html";
// Temporary: the learner package replaces this with real sessions.
// Used until a feature supplies an `auth` service (the learner package does).
const anonymousAuth: AuthService = { userFromRequest: async () => null };
/** A feature, or a factory that needs the server context. A feature may carry the `AuthService`. */
export type FeatureEntry = (Feature & { auth?: AuthService }) | ((ctx: ServerContext) => Feature & { auth?: AuthService });
export async function startServer(
config: AppConfig,
features: Feature[],
features: FeatureEntry[],
_webFeatures?: unknown,
): Promise<{ server: Bun.Server<undefined>; stop(): Promise<void> }> {
const db = await connect(config.databaseUrl);
await migrate(db);
const courses = await createCourseRepository(config.courseDirs);
const handlers: Record<string, JobHandler> = {};
for (const f of features) Object.assign(handlers, f.jobs);
// Factories get the context before the job queue exists; ctx.jobs is set right after they run.
const ctx: ServerContext = { config, db, courses, jobs: null as unknown as JobQueue, auth: anonymousAuth };
const built = features.map((f) => (typeof f === "function" ? f(ctx) : f));
for (const f of built) {
Object.assign(handlers, f.jobs);
if (f.auth) ctx.auth = f.auth;
}
const jobs = createJobQueue(db, handlers);
const ctx: ServerContext = { config, db, courses, jobs, auth: anonymousAuth };
const app = createApp(ctx, features);
ctx.jobs = jobs;
const app = createApp(ctx, built);
jobs.start();
const server = Bun.serve({
port: config.port,
@@ -39,6 +49,8 @@ export async function startServer(
if (import.meta.main) {
const config = loadConfig();
const { server } = await startServer(config, []);
const { server } = await startServer(config, [
learnerFeature,
]);
console.log(`STCourses listening on ${server.url}`);
}
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSP (learner review C1, ruling R16). No inline script: the one hash is Bun's dev-server helper
(/_bun/unref beacon), which production builds do not inject. frame-ancestors cannot be set in a meta tag;
see docs/contract-requests/learner.md section 5. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-ES8VF47udzjVFFPh4RhgcgjUrADRd1Ef0GD9UagS2mY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
<title>STCourses</title>
<link rel="stylesheet" href="../../packages/ui/src/fonts.css">
<link rel="stylesheet" href="../../packages/ui/src/tokens.css">
+2 -1
View File
@@ -1,4 +1,5 @@
import type { WebFeature } from "@stc/contracts";
import { learnerWeb } from "@stc/feature-learner/web";
// Phase 1 packages append one import line each and push into this list.
export const webFeatures: WebFeature[] = [];
export const webFeatures: WebFeature[] = [learnerWeb];
+158
View File
@@ -57,6 +57,16 @@
"packages/feature-learner": {
"name": "@stc/feature-learner",
"version": "0.0.0",
"dependencies": {
"@better-auth/sso": "^1.7.14",
"better-auth": "^1.7.14",
"marked": "^18.1.2",
"pdfjs-dist": "^6.4.299",
"sanitize-html": "^2",
},
"devDependencies": {
"@types/sanitize-html": "^2.16.2",
},
},
"packages/feature-studio": {
"name": "@stc/feature-studio",
@@ -68,14 +78,68 @@
},
},
"packages": {
"@authenio/xml-encryption": ["@authenio/[email protected]", "", { "dependencies": { "@xmldom/xmldom": "^0.8.13", "escape-html": "^1.0.3" } }, "sha512-/jv5qO935fH4Ma+fxh5rJLRYZvUM57fd/I74IiSoR8cFm+sgjszXFEEn1PkqZhhBD4MVkH2YT+/UDY+pnW0S0Q=="],
"@better-auth/core": ["@better-auth/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.4", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ByhdrGVwp/el+hoTYKSVOHIH23/lQj9TQ1txdGcWOg22tnfr3hJ6ukzySooeYj/R36XsQgBhkJqIp8mKsNeErA=="],
"@better-auth/drizzle-adapter": ["@better-auth/[email protected]", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-5RC8Ec5Ur15FWsS4As9/VrlRbB3+9EfShxIpA5A1FcD6IgPxFvzQNkAm2+FYx9LUSqx47NPf9hBTG2GUCMP9Ig=="],
"@better-auth/kysely-adapter": ["@better-auth/[email protected]", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-4wkqj8a8JVpWDYLlgzfbk7eOaWCFkL6HHQd6CLamFKaC8vNsXew1x4v5dE5O6dGr8j4g5qMISS+BAvOuIvBevg=="],
"@better-auth/memory-adapter": ["@better-auth/[email protected]", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0" } }, "sha512-Ojv9BcmFyHuTHha9ZM/NUW1ut9J84wLngta+07Es0Ejn8kekqP5aHBpZd5Z8AvADs5iJUOWvQGFmGlPWAKJgBg=="],
"@better-auth/mongo-adapter": ["@better-auth/[email protected]", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-6IA0RSPZFMdRwwnLZ9oWnDIiD0y0o0azTHUfa01BNU2AFJgMDkeiASUV3bKcJBskzQoyQrff74eFYTsTSeeVUQ=="],
"@better-auth/prisma-adapter": ["@better-auth/[email protected]", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mF6t5xqeZNWzUe0ErmrSdzQmtslJscmu9X8Mp5QmROlpnnAzvSc70RILu8lHms8CePt7N34+KRH1QvYjGFrVGQ=="],
"@better-auth/sso": ["@better-auth/[email protected]", "", { "dependencies": { "@xmldom/xmldom": "^0.9.12", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.14", "better-call": "1.4.4" } }, "sha512-sfUzpm6GO5l5MSUA17qRO/E4INthqF+KRmymtj4Hhm+Xsx6CoRgr0BVIDfAic3tSqVhObcVYiiiZ38dXXhkCCw=="],
"@better-auth/telemetry": ["@better-auth/[email protected]", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3" } }, "sha512-PtKFzsH7gje/WJuUPDjckOoNFazsq8NMt3AdiXIPzHl7tZULcNGTKAX8K7+yn0rG6cO/4mtMgpOjhtilElOnmg=="],
"@better-auth/utils": ["@better-auth/[email protected]", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg=="],
"@better-fetch/fetch": ["@better-fetch/[email protected]", "", {}, "sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg=="],
"@fontsource/ibm-plex-mono": ["@fontsource/[email protected]", "", {}, "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q=="],
"@fontsource/ibm-plex-sans": ["@fontsource/[email protected]", "", {}, "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w=="],
"@fontsource/literata": ["@fontsource/[email protected]", "", {}, "sha512-9JGMT5+YfBK49thoMq9ASaMRJErHT4V4QuMhAEwKHRLreOsIMP2xP6ZRLhPJ0e6YlET+kjWTJuN7LAxt9/gWGQ=="],
"@napi-rs/canvas": ["@napi-rs/[email protected]", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
"@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
"@noble/hashes": ["@noble/[email protected]", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
"@nodable/entities": ["@nodable/[email protected]", "", {}, "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ=="],
"@remix-run/router": ["@remix-run/[email protected]", "", {}, "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@stc/contracts": ["@stc/contracts@workspace:packages/contracts"],
"@stc/core": ["@stc/core@workspace:packages/core"],
@@ -104,10 +168,72 @@
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/sanitize-html": ["@types/[email protected]", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="],
"@xmldom/is-dom-node": ["@xmldom/[email protected]", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
"@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="],
"anynum": ["[email protected]", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="],
"better-auth": ["[email protected]", "", { "dependencies": { "@better-auth/core": "1.7.14", "@better-auth/drizzle-adapter": "1.7.14", "@better-auth/kysely-adapter": "1.7.14", "@better-auth/memory-adapter": "1.7.14", "@better-auth/mongo-adapter": "1.7.14", "@better-auth/prisma-adapter": "1.7.14", "@better-auth/telemetry": "1.7.14", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.4", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0 || ^3.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-QxDCI0pOCCHDYmruisKhKyrvOJ6Icv9xg0RA6ZUysfKFXENYwhNNpwUlkE/NmViH2nDMgkKcymmLr55pnpR9vw=="],
"better-call": ["[email protected]", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dayjs": ["[email protected]", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"defu": ["[email protected]", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
"domelementtype": ["[email protected]", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
"entities": ["[email protected]", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"escape-html": ["[email protected]", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"fast-xml-builder": ["[email protected]", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="],
"fast-xml-parser": ["[email protected]", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="],
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
"is-plain-object": ["[email protected]", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="],
"is-unsafe": ["[email protected]", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="],
"jose": ["[email protected]", "", {}, "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A=="],
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
"launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"nanostores": ["[email protected]", "", {}, "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA=="],
"parse-srcset": ["[email protected]", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
"path-expression-matcher": ["[email protected]", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
"pdfjs-dist": ["[email protected]", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
"react": ["[email protected]", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="],
@@ -116,14 +242,46 @@
"react-router-dom": ["[email protected]", "", { "dependencies": { "@remix-run/router": "1.23.4", "react-router": "6.30.6" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ=="],
"rou3": ["[email protected]", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="],
"samlify": ["[email protected]", "", { "dependencies": { "@authenio/xml-encryption": "^6.0.2", "@xmldom/xmldom": "^0.9.12", "xml": "^1.0.1", "xml-crypto": "^6.4.0", "xml-escape": "^1.1.0", "xpath": "^0.0.35" } }, "sha512-peZitMO2cAnf0pY45H0yw0Drqmby7U91yRovH3AH8TdwYCNRnmidVORXIjcK+21cBk3Mjl8EVz8Nvuw/v20rAw=="],
"sanitize-html": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.1.0", "launder": "^1.7.2", "parse-srcset": "^1.0.2", "postcss": "^8.5.27" } }, "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig=="],
"scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"set-cookie-parser": ["[email protected]", "", {}, "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ=="],
"source-map-js": ["[email protected]", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"strnum": ["[email protected]", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="],
"tldts": ["[email protected]", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="],
"tldts-core": ["[email protected]", "", {}, "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["[email protected]", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
"xml": ["[email protected]", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
"xml-crypto": ["[email protected]", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.15", "xpath": "^0.0.33" } }, "sha512-d21ModhBO/+QWWAmOjyDeQJuHNmsR/HtOxyBchiTbKfGNKuHYwQNd+SlLhwyyrwoAGLp4Qc6BQ7nAtnM47dHSQ=="],
"xml-escape": ["[email protected]", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="],
"xml-naming": ["[email protected]", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xpath": ["[email protected]", "", {}, "sha512-8O+m2N04SwLpGw/1Grz2HD8ejnWDV7M1dMPgKKuOjhYh5W9H4pel/+06kInohIdpS2FeSbL4i8ID8c4tpxwq9Q=="],
"zod": ["[email protected]", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
"@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="],
"@types/node/undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"xml-crypto/@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="],
"xml-crypto/xpath": ["[email protected]", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
}
}
+57
View File
@@ -0,0 +1,57 @@
# Contract requests from WP-A (learner)
## 1. Feature factories and the auth service (`apps/server/src/main.ts`, `@stc/contracts`)
**Problem.** Phase 0 passes `Feature[]` to `startServer`, but `ServerContext` is built inside `startServer`, so a feature that needs `ctx` (every Phase 1 package exports `xFeature(ctx)`) cannot be listed. There is also no way for a feature to replace the anonymous `AuthService`.
**Workaround in `feat/learner` (applied, small):** `startServer` accepts `FeatureEntry = Feature | ((ctx) => Feature & { auth?: AuthService })`. Factories run after `ctx` exists (with `ctx.jobs` set right after they return, so factories must not use `jobs` synchronously), and a feature carrying `auth` replaces `ctx.auth`. The `main` entry lists `learnerFeature`.
**Proposal.** Move both into the contract:
```ts
export type FeatureFactory = (ctx: ServerContext) => Feature;
export interface Feature { /* … */ auth?: AuthService; migrationsDirs?: string[] }
```
and let `startServer` take `Array<Feature | FeatureFactory>`.
## 2. Migrations per package
`migrate()` only reads `packages/db/migrations`. The learner package exports `migrationsDirs` and, until Phase 2 wires it in, runs its own migrations lazily inside `learnerFeature(ctx)` through `runMigrations(db, dirs)`, which uses the same `schema_migrations` table and file-name keys. When `migrate()` learns about `migrationsDirs`, the lazy call becomes a no-op; it can then be removed.
## 3. Package entry for the browser
**Problem.** The package index exports both the server (`learnerFeature`, which pulls in Better Auth and `@better-auth/sso`) and the browser half (`learnerWeb`, `LessonView`). A browser bundle of the index fails: `@better-auth/sso` imports `X509Certificate` from `node:crypto`, which has no browser polyfill. The root `tsconfig.json` maps `@stc/*` to `packages/*/src`, so package.json `exports` subpaths are not used either.
**Workaround in `feat/learner` (applied).** `LessonView` and `learnerWeb` are exported from the index (for server-side and type use) and from a browser-only entry, `@stc/feature-learner/web`. That subpath resolves through the existing mapping to `packages/feature-learner/web/src/index.ts`, a one-line re-export of `src/web`. `apps/web/src/features.ts` imports `learnerWeb` from it, and the studio should import `LessonView` from it too.
**Proposal.** Add `"@stc/*/web": ["packages/*/src/web"]` ahead of the general mapping in `tsconfig.json` (Bun honours tsconfig paths), and let every feature package expose `src/web/index.ts`. The shim directory can then be deleted. This also settles the importer's relative import in `features.ts`.
## 4. LessonView for the studio preview
`import { LessonView } from "@stc/feature-learner/web"`. Props: `lesson: LessonPayload` (the JSON from `GET /api/courses/:id/lessons/:lessonId`), plus optional `look`, `track`, `onTrackChange`, `preview`. With `preview` set, it makes no notes, attempt or progress requests; it still loads code listings and decks through the normal media routes. It injects its own stylesheet (scoped under `.lr`) and works with or without a router.
## 5. Content-Security-Policy on the SPA (review C1, ruling R16)
**Applied in `feat/learner`.** The SPA's CSP is a `<meta http-equiv="Content-Security-Policy">` tag in `apps/web/index.html`, ahead of every script. `Bun.serve` serves the HTML import directly, as in Phase 0; there is no proxy and no change to proxy environment variables. The policy:
```
default-src 'self'; script-src 'self' 'sha256-<Bun dev helper>'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self';
worker-src 'self' blob:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'
```
- **No inline script.**
- The one hash is the inline helper Bun's dev server injects (the `/_bun/unref` beacon). Production serving does not inject it.
- If a Bun upgrade changes the helper, the only effect is a dev-only CSP console error; `test/csp.test.ts` (dev mode) will then fail and name it.
- **Dev HMR works:** the HMR websocket `ws://<host>/_bun/hmr` is same-origin, and Chrome matches it with `connect-src 'self'` (checked: handshake 101, no violations).
- **The runner frame** (`/api/learner/runner-frame`) keeps its own header-based CSP, including `sandbox allow-scripts` and `frame-ancestors 'self'`.
**Proposal for core (Phase 2).**
- A meta tag cannot carry `frame-ancestors`, `sandbox` or `report-uri`. Core should additionally send `Content-Security-Policy: frame-ancestors 'self'` (or `X-Frame-Options: SAMEORIGIN`), plus `X-Content-Type-Options: nosniff`, on HTML responses. That is possible if Bun ever allows headers on HTML imports, or by serving a production static build (`bun build apps/web/index.html`) through a normal route that sets headers.
- Ideally core then sends the whole policy as a header and the meta tag stays as defence in depth.
- Every feature's web code must keep to this policy: no inline scripts or event handlers, and no `eval`. The learner and studio preview, through `LessonView`, comply.
## 6. `canView` and the global `author` role (review I1)
**Ruling applied.** Learner routes now call core `canView` with per-course authorship only (`course_authors`), exactly like core's `/api/media`. A user with only the global `author` role gets a 404 for a restricted course they are not listed on, on every route.
**Proposal for Phase 2.** Extend core `canView` so the global `author` role can view every course (the studio and AI need the same rule), for example `canView(user, course, acl, isCourseAuthor)` returning true when `user.roles.includes("author")`. The learner then needs no change: it already delegates to `canView`.
@@ -0,0 +1,147 @@
# WP-A Learner App Implementation Plan
> **For agentic workers:** executed inline by the WP-A agent (no subagents), task by task, TDD, one commit per task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Build `packages/feature-learner`: sign-in (Better Auth), roles/groups admin API, the library, the lesson reader for every lesson type, notes, progress, gating, and the in-browser JS/TS exercise runner. It must render `sample`, `pure-snake` and `systemic-design`.
**Architecture:**
- Server side (`src/server/*`): `learnerFeature(ctx): Feature & { auth: AuthService }` returns the JSON API routes and the `AuthService`. It runs its own migrations (`migrations/0001_learner_auth.sql`) lazily on first use until Phase 2 wires `migrationsDirs` into `migrate()`.
- Auth: Better Auth with a **thin custom adapter over `Bun.SQL`** (`src/server/auth/adapter.ts`). One adapter serves both dialects because `@stc/db` already gives a `Bun.SQL` handle for SQLite and Postgres.
- Web side (`src/web/*`): React routes `/`, `/c/:courseId`, `/c/:courseId/:lessonId`, `/c/:courseId/notes`, `/account`, `/account/admin`. Styles are plain CSS loaded as text and injected once, so the same module graph is safe to import on the server.
- `LessonView` is a presentational lesson renderer that takes a `LessonPayload`. The studio uses it for preview.
- The browser runner (`src/web/runner/*`) runs learner code and tests as ES modules from blob URLs inside a module Web Worker, with a hard timeout (the worker is terminated). TS is transpiled by the server's `Bun.Transpiler` (`POST /api/learner/transpile`).
- Decks render client-side with pdfjs-dist. The worker script is served from `/api/learner/pdf.worker.mjs`.
**Tech Stack:** Bun 1.3, TypeScript, React 19, React Router 6, better-auth 1.7 (+ `@better-auth/sso` for SAML/OIDC SSO, `generic-oauth` for a configured OIDC provider), marked + DOMPurify, pdfjs-dist.
**Spec:** sections 4.1–4.4, 6, 7, 12, 13. Visual reference: `docs/design/mockups/platform-v1.html` minus its `[` / `]` / `/` / Ctrl+Enter shortcuts.
## Global Constraints
Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain CSS, no course content in the DB, both dialects, AGPL, tokens, keyboard rules, commit trailer). Package-specific:
- Owned paths: `packages/feature-learner/**`, `docs/superpowers/plans/2026-10-30-learner.md`, `docs/contract-requests/learner.md`. Shared touches: one line in `apps/web/src/features.ts`, the feature wiring in `apps/server/src/main.ts`.
- Frozen: `packages/contracts`, `packages/course-format`, `packages/db` (src + 0001), `packages/core`, `packages/ui`.
- No new colours: course accent (manifest) and the tokens only. Literata only for lesson titles and reading text.
## Review Focus
1. **Auth adapter correctness on both dialects** (where operators, AND/OR grouping, dates and booleans as text/int). Test: Task 2 runs the adapter suite under `forEachDb`.
2. **Owner bootstrap is race-safe**: exactly one owner even with concurrent sign-ups (unique key claim). Test: Task 3.
3. **Visibility**: lesson, outline, code and zip routes all apply `canView` and return 404 for hidden courses; gating returns `locked` without content. Test: Task 5/7.
4. **Zip download** handles spaces and non-ASCII names (UTF-8 flag) and never follows symlinks out of the root. Test: Task 7.
5. **Runner isolation**: learner code runs only inside the worker; an infinite loop is killed by the timeout. Test: Task 8.
---
### Task 1: Package scaffold, migrations runner, test helper
**Files:** `packages/feature-learner/{package.json,src/index.ts,src/server/migrate.ts,migrations/0001_learner_auth.sql,test/helpers.ts,test/migrate.test.ts}`
**Interfaces (Produces):**
- `migrationsDirs: string[]` (absolute path of `packages/feature-learner/migrations`).
- `runMigrations(db: Db, dirs?: string[]): Promise<string[]>` — same bookkeeping table and semantics as `@stc/db` `migrate()`.
- Tables: `auth_user`, `auth_session`, `auth_account`, `auth_verification`, `auth_sso_provider` (Better Auth, snake_case columns, ISO text dates, integer booleans), `learner_meta (key PK, value)`, `learner_group_claims (user_id, group_id)`.
- test helper `forEachDb(name, fn)` = `@stc/db`'s helper plus the learner migrations.
- [x] Step 1: failing test: after `runMigrations` the tables exist on both dialects and a second run applies nothing.
- [x] Step 2: implement; Step 3: green on SQLite and Postgres; commit.
### Task 2: Better Auth adapter over Bun.SQL
**Files:** `src/server/auth/adapter.ts`, `test/adapter.test.ts`
**Interfaces:** `bunSqlAdapter(db: Db)` → Better Auth `DBAdapterInstance`. Model `x` → table `auth_<snake(x)>`, field `camelCase` → `snake_case`. `supportsDates/Booleans/JSON: false`, no transactions (SQLite has one connection; a nested query in a transaction would deadlock).
- [x] Failing tests (both dialects): create/findOne/findMany (eq, ne, in, not_in, contains, starts_with, ends_with, lt/gt, insensitive mode, OR grouping, sort, limit, offset), update, updateMany, delete, deleteMany, count, consumeOne, incrementOne.
- [x] Implement, green, commit.
### Task 3: AuthService, owner bootstrap, claim groups, feature wiring
**Files:** `src/server/auth/index.ts`, `src/server/auth/groups.ts`, `src/server/feature.ts`, `test/auth.test.ts`, `apps/server/src/main.ts` (wiring), `docs/contract-requests/learner.md`
**Interfaces:**
- `createAuth(ctx, opts?)` → `{ handler(req), service: AuthService, providers(): ProviderInfo[] }`.
- Email/password always on; social providers (GitHub, Google, Microsoft, GitLab, Discord) from `STC_AUTH_<NAME>_ID/SECRET`; one generic OIDC provider from `STC_AUTH_OIDC_*`; SAML and per-domain OIDC through `@better-auth/sso` (owner-only registration).
- First user to sign up becomes `owner` (claimed via `INSERT` into `learner_meta` key `owner`, PK conflict loses); others get `learner`.
- `syncClaimGroups(db, userId, names)` creates groups by name and replaces the user's claim-sourced memberships; manual memberships are untouched.
- `learnerFeature(ctx)` returns `Feature & { auth }`; `startServer` accepts feature factories `(ctx) => Feature` and installs a feature's `auth` (contract request filed; two-line change in `main.ts`).
- [x] Failing tests: sign-up → session cookie → `userFromRequest` returns roles `[owner]`; second user `[learner]`; concurrent first sign-ups give one owner; sign-out clears; claim sync keeps manual memberships.
- [x] Implement, green, commit.
### Task 4: Admin API (roles, groups, course ACL, library curation)
**Files:** `src/server/admin.ts`, `test/admin.test.ts`
- Routes (owner only, 403 otherwise): `GET /api/admin/users`, `PUT /api/admin/users/:id/roles`, `GET|POST /api/admin/groups`, `DELETE /api/admin/groups/:id`, `PUT /api/admin/groups/:id/members`, `GET|PUT /api/admin/courses/:id/acl`, `GET|PUT /api/admin/library` (featured + collections).
- The last owner cannot drop their own owner role.
- [x] Tests first; implement; commit.
### Task 5: Library, outline and lesson API
**Files:** `src/server/catalog.ts`, `src/server/lesson.ts`, `src/server/gating.ts`, `test/catalog.test.ts`, `test/lesson.test.ts`
- `GET /api/library` → `{ featured, continue[], newest[], collections[], courses[] }` filtered by `canView`. Each course: id, title, pitch, authors, coverUrl, accent, formats (`text|video|slides|exercises`), lessonCount, minutes (video duration, text estimated at 220 wpm, exercises 15 min), collections, progress, status.
- `GET /api/library/search?q=` → courses and lessons matching title/pitch/lesson titles.
- `GET /api/courses/:id` → outline, tracks, gating, `locked` per module, progress, diagnostics (errors only for owner/authors).
- `GET /api/courses/:id/lessons/:lessonId` → `LessonPayload` (markdown, hints, decks, video, transcript, code, attachments, browser exercise, local command, prev/next) or `{ locked: true }`.
- Gating: module *i* locked when any lesson of an earlier module is not done; owners and course authors bypass.
- [x] Tests against the sample fixture plus temp courses; implement; commit.
### Task 6: Progress, notes, exercise attempts API
**Files:** `src/server/userdata.ts`, `test/userdata.test.ts`
- `PUT /api/courses/:id/progress/:lessonId` `{state, positionSeconds?}`; notes CRUD (`GET/POST /api/courses/:id/notes`, `PATCH/DELETE /api/notes/:id`); attempts `GET/PUT /api/courses/:id/lessons/:lessonId/attempt?track=`. A passing attempt marks the lesson done. Anonymous → 401. Hidden course → 404.
- [x] Tests on both dialects (acceptance: notes persist on SQLite and Postgres); implement; commit.
### Task 7: Code files and zip download
**Files:** `src/server/zip.ts`, `src/server/code.ts`, `test/zip.test.ts`, `test/code.test.ts`
- `GET /api/courses/:id/code/:moduleId` → `{ dir, command, files: {path,size}[] }` (skips `.git`, `node_modules`, symlinks escaping root; caps 2000 files).
- `GET /api/courses/:id/code/:moduleId/zip` → `application/zip`, `Content-Disposition` via `contentDisposition("<dir name>.zip")`.
- `zip(entries)`: store/deflate, CRC32, UTF-8 name flag.
- [x] Tests: zip round-trip via `unzip -l`, non-ASCII names, symlink escape skipped; commit.
### Task 8: Browser exercise runner
**Files:** `src/web/runner/{assert.ts,worker-source.ts,runner.ts}`, `src/server/transpile.ts`, `test/runner.test.ts`
- `createBrowserRunner(language, { transpile })`: `ExerciseRunner`. Worker disables `fetch`, `XMLHttpRequest`, `WebSocket`, `importScripts` before importing code. Results carry expected/actual for failed asserts.
- [x] Tests (Bun supports module workers and blob URLs): starter fails, solution passes, a syntax error is reported, `while(true)` times out, TS is transpiled.
- [x] commit.
### Task 9: Markdown pipeline
**Files:** `src/web/markdown.ts`, `test/markdown.test.ts`
- `renderLessonHtml(md, { courseId, baseFile, tracks, lessonFiles })` → sanitized HTML: heading ids, code blocks tagged with their track (`data-t`), bold track labels tagged, `<details>` turned into folds; consecutive hint folds become a tier group (`data-tier`, `data-tiers`), relative images/links rewritten to `/api/media/...` or lesson routes.
- [x] Tests with Pure Snake-style input; commit.
### Task 10: Web shell and library
**Files:** `src/web/{routes.tsx,api.ts,styles.css,shell/*,library/*}`
- Header extras (course switcher, search, Reading/Work, account) portalled into the Phase 0 header; course outline sidebar (mobile overlay, Escape closes); library: featured hero, continue row, New row, collection rows of covers, format/length/in-progress filters, search.
- [x] Component smoke tests with `react-dom/server` render; screenshot check later; commit.
### Task 11: Lesson views and `LessonView`
**Files:** `src/web/lesson/*`
- Text (track switcher, Reading/Work, folds, hint tiers), video (range-served `<video>`, resume, done near end, timestamp notes), deck viewer (pdfjs, thumbs, arrows, fullscreen, per-slide note), code files (tree, tabs, copy, zip), exercise page (editor, run, reset, per-test results, hints, local command; local-only: command + "Mark done" + optional pasted evidence), notes list, floating action bar.
- [x] Render tests for `LessonView` with each lesson type; commit.
### Task 12: Account and admin pages
**Files:** `src/web/account/*`
- Sign in / create account (email+password), configured provider buttons, SSO by email, sign out; owner admin page for users/roles, groups, featured course and collections.
- [x] Render tests; commit.
### Task 13: Wiring, verification, report
- [x] Append `learnerWeb` to `apps/web/src/features.ts`; `learnerFeature` in `main.ts`.
- [x] `bun test`, `bun run typecheck`, Postgres run; dev server on 3461 with `STC_COURSE_DIRS=./fixtures/courses:./overlays`; headless Chrome screenshots of library, text, video, deck, code, exercise, account for all three courses; fix what looks wrong.
- [x] Write `/home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/wp-learner-report.md`.
---
## Execution notes (added when the plan was finished)
- Tasks 1–9 were done by the first implementer (0b11715..99c0e49). Tasks 10–13 were done after a hand-over (89c51f0 onwards).
- Tasks 10–12 landed in one commit, because the route modules import each other (the routes list loads the lesson, library and account pages).
- Styles are a TS string (`src/web/styles.ts`) injected once, not a `.css` text import: Bun's dev server loses text imports on hot rebuilds.
- The browser entry is `@stc/feature-learner/web` (see `docs/contract-requests/learner.md` §3); the index cannot be bundled for the browser.
- Decks render client-side with pdf.js; the importer's `<dataDir>/cache/decks` is not used.
- The exercise editor is a textarea with a line gutter, as in the mockup (CodeMirror was optional).
@@ -0,0 +1,34 @@
-- Learner package: Better Auth tables (snake_case, ISO-8601 TEXT dates, INTEGER booleans) and helpers.
-- Portable SQL: runs unchanged on SQLite and Postgres.
CREATE TABLE auth_user (
id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, email_verified INTEGER NOT NULL,
image TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE auth_session (
id TEXT PRIMARY KEY, expires_at TEXT NOT NULL, token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ip_address TEXT, user_agent TEXT,
user_id TEXT NOT NULL
);
CREATE INDEX auth_session_by_user ON auth_session (user_id);
CREATE TABLE auth_account (
id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL,
access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at TEXT, refresh_token_expires_at TEXT,
scope TEXT, password TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX auth_account_by_user ON auth_account (user_id);
CREATE TABLE auth_verification (
id TEXT PRIMARY KEY, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX auth_verification_by_identifier ON auth_verification (identifier);
CREATE TABLE auth_sso_provider (
id TEXT PRIMARY KEY, issuer TEXT NOT NULL, oidc_config TEXT, saml_config TEXT, user_id TEXT,
provider_id TEXT NOT NULL UNIQUE, organization_id TEXT, domain TEXT NOT NULL, domain_verified INTEGER
);
-- key/value for one-off facts such as which user claimed the owner role first
CREATE TABLE learner_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
-- group memberships that came from SAML/OIDC claims (replaced on each sign-in; manual ones are not listed here)
CREATE TABLE learner_group_claims (
user_id TEXT NOT NULL, group_id TEXT NOT NULL,
PRIMARY KEY (user_id, group_id)
);
+15 -1
View File
@@ -3,5 +3,19 @@
"version": "0.0.0",
"private": true,
"type": "module",
"license": "AGPL-3.0-only"
"license": "AGPL-3.0-only",
"dependencies": {
"@better-auth/sso": "^1.7.14",
"better-auth": "^1.7.14",
"marked": "^18.1.2",
"pdfjs-dist": "^6.4.299",
"sanitize-html": "^2"
},
"exports": {
".": "./src/index.ts",
"./web": "./web/src/index.ts"
},
"devDependencies": {
"@types/sanitize-html": "^2.16.2"
}
}
+7
View File
@@ -0,0 +1,7 @@
// Server
export { migrationsDirs, runMigrations } from "./server/migrate";
export { learnerFeature } from "./server/feature";
// Browser (also available without the server half as "@stc/feature-learner/web")
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
// Shared JSON shapes
export type * from "./shared/types";
@@ -0,0 +1,48 @@
import type { AuthUser, ServerContext } from "@stc/contracts";
import { canView } from "@stc/core";
import type { LoadedCourse } from "@stc/course-format";
import type { Db } from "@stc/db";
export interface AccessIndex {
acl: Map<string, { principal_type: string; principal_id: string }[]>;
authorOf: Set<string>;
}
/** All ACL rows plus the courses the user co-authors, fetched once per request. */
export async function accessIndex(db: Db, user: AuthUser | null): Promise<AccessIndex> {
const acl = new Map<string, { principal_type: string; principal_id: string }[]>();
const authorOf = new Set<string>();
if (!user) return { acl, authorOf };
for (const r of await db.sql`SELECT course_id, principal_type, principal_id FROM course_acl`) {
const list = acl.get(r.course_id) ?? [];
list.push({ principal_type: r.principal_type, principal_id: r.principal_id });
acl.set(r.course_id, list);
}
for (const r of await db.sql`SELECT course_id FROM course_authors WHERE user_id = ${user.id}`) authorOf.add(r.course_id);
return { acl, authorOf };
}
export const isOwner = (u: AuthUser | null): boolean => !!u?.roles.includes("owner");
export function canEdit(user: AuthUser | null, courseId: string, idx: AccessIndex): boolean {
return !!user && (user.roles.includes("owner") || user.roles.includes("author") || idx.authorOf.has(courseId));
}
/**
* 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.)
*/
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
if (!c.manifest) return isOwner(user);
return canView(user, c, idx.acl.get(c.id) ?? [], idx.authorOf.has(c.id));
}
/** The course if this user may read it, else null (callers answer 404, never 403, so hidden courses stay hidden). */
export async function courseFor(ctx: ServerContext, user: AuthUser | null, courseId: string) {
const course = ctx.courses.get(courseId);
if (!course || !course.manifest) return null;
const idx = await accessIndex(ctx.db, user);
if (!visible(user, course, idx)) return null;
return { course, manifest: course.manifest, idx, canEdit: canEdit(user, courseId, idx) };
}
@@ -0,0 +1,150 @@
import type { Feature, Role, ServerContext } from "@stc/contracts";
import { libraryRepo, type Collection } from "@stc/db";
import { ensureGroup } from "./auth/groups";
import { badRequest, body, json, notFound, ownerOnly, param } from "./http";
const ROLES: Role[] = ["owner", "author", "learner"];
/** Owner-only administration: users and roles, groups, course access lists and library curation. */
export function adminRoutes(ctx: ServerContext, invalidate: (userId?: string) => void): Feature["routes"] {
const { db } = ctx;
const lib = libraryRepo(db);
return {
"/api/admin/users": {
GET: ownerOnly(async () => {
const users = await db.sql`SELECT id, email, name, created_at FROM auth_user ORDER BY created_at, email`;
const roles = await db.sql`SELECT user_id, role FROM user_roles`;
const members = await db.sql`SELECT user_id, group_id FROM group_members`;
return json(users.map((u: { id: string; email: string; name: string; created_at: string }) => ({
id: u.id, email: u.email, name: u.name, createdAt: u.created_at,
roles: roles.filter((r: { user_id: string }) => r.user_id === u.id).map((r: { role: string }) => r.role).sort(),
groups: members.filter((m: { user_id: string }) => m.user_id === u.id).map((m: { group_id: string }) => m.group_id).sort(),
})));
}),
},
"/api/admin/users/:id/roles": {
PUT: ownerOnly(async (req, rc) => {
const id = param(rc, "id");
const b = await body<{ roles: Role[] }>(req);
if (!b || !Array.isArray(b.roles) || b.roles.some((r) => !ROLES.includes(r))) return badRequest("roles must be a list of owner, author, learner");
const exists = await db.sql`SELECT 1 AS x FROM auth_user WHERE id = ${id}`;
if (!exists.length) return notFound();
const roles = [...new Set(b.roles)];
if (!roles.includes("owner")) {
const owners = await db.sql`SELECT user_id FROM user_roles WHERE role = 'owner'`;
const others = owners.filter((o: { user_id: string }) => o.user_id !== id);
if (others.length === 0) return json({ error: "the last owner cannot lose the owner role" }, 409);
}
await db.sql.begin(async (tx) => {
await tx`DELETE FROM user_roles WHERE user_id = ${id}`;
for (const r of roles) await tx`INSERT INTO user_roles (user_id, role) VALUES (${id}, ${r})`;
});
invalidate(id);
return json({ id, roles: roles.sort() });
}),
},
"/api/admin/groups": {
GET: ownerOnly(async () => {
const groups = await db.sql`SELECT id, name FROM groups ORDER BY name`;
const members = await db.sql`SELECT group_id, user_id FROM group_members ORDER BY user_id`;
return json(groups.map((g: { id: string; name: string }) => ({
id: g.id, name: g.name,
members: members.filter((m: { group_id: string }) => m.group_id === g.id).map((m: { user_id: string }) => m.user_id),
})));
}),
POST: ownerOnly(async (req) => {
const b = await body<{ name: string }>(req);
const name = typeof b?.name === "string" ? b.name.trim() : "";
if (!name) return badRequest("name is required");
return json({ id: await ensureGroup(db, name), name }, 201);
}),
},
"/api/admin/groups/:id": {
DELETE: ownerOnly(async (_req, rc) => {
const id = param(rc, "id");
await db.sql.begin(async (tx) => {
await tx`DELETE FROM group_members WHERE group_id = ${id}`;
await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`;
await tx`DELETE FROM course_acl WHERE principal_type = 'group' AND principal_id = ${id}`;
await tx`DELETE FROM groups WHERE id = ${id}`;
});
invalidate();
return json({ ok: true });
}),
},
"/api/admin/groups/:id/members": {
PUT: ownerOnly(async (req, rc) => {
const id = param(rc, "id");
const b = await body<{ userIds: string[] }>(req);
if (!b || !Array.isArray(b.userIds)) return badRequest("userIds must be a list");
const g = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`;
if (!g.length) return notFound();
await db.sql.begin(async (tx) => {
await tx`DELETE FROM group_members WHERE group_id = ${id}`;
await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`;
for (const u of new Set(b.userIds.map(String))) await tx`INSERT INTO group_members (group_id, user_id) VALUES (${id}, ${u})`;
});
invalidate();
return json({ id, members: [...new Set(b.userIds)].sort() });
}),
},
"/api/admin/courses/:id/access": {
GET: ownerOnly(async (_req, rc) => {
const id = param(rc, "id");
const course = ctx.courses.get(id);
if (!course) return notFound();
const acl = await db.sql`SELECT principal_type, principal_id FROM course_acl WHERE course_id = ${id} ORDER BY principal_type, principal_id`;
const authors = await db.sql`SELECT user_id FROM course_authors WHERE course_id = ${id} ORDER BY user_id`;
return json({
visibility: course.manifest?.visibility ?? "restricted",
acl: acl.map((a: { principal_type: string; principal_id: string }) => ({ principal_type: a.principal_type, principal_id: a.principal_id })),
authors: authors.map((a: { user_id: string }) => a.user_id),
});
}),
PUT: ownerOnly(async (req, rc) => {
const id = param(rc, "id");
if (!ctx.courses.get(id)) return notFound();
const b = await body<{ acl?: { principal_type: string; principal_id: string }[]; authors?: string[] }>(req);
if (!b) return badRequest("body required");
if (b.acl && b.acl.some((a) => (a.principal_type !== "user" && a.principal_type !== "group") || !a.principal_id)) return badRequest("acl entries need principal_type user|group and principal_id");
await db.sql.begin(async (tx) => {
if (b.acl) {
await tx`DELETE FROM course_acl WHERE course_id = ${id}`;
const seen = new Set<string>();
for (const a of b.acl) {
const k = `${a.principal_type}\0${a.principal_id}`;
if (seen.has(k)) continue;
seen.add(k);
await tx`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES (${id}, ${a.principal_type}, ${a.principal_id})`;
}
}
if (b.authors) {
await tx`DELETE FROM course_authors WHERE course_id = ${id}`;
for (const u of new Set(b.authors)) await tx`INSERT INTO course_authors (course_id, user_id) VALUES (${id}, ${u})`;
}
});
return json({ ok: true });
}),
},
"/api/admin/library": {
GET: ownerOnly(async () => json({ featured: await lib.getFeatured(), collections: await lib.listCollections() })),
PUT: ownerOnly(async (req) => {
const b = await body<{ featured?: string | null; collections?: Collection[] }>(req);
if (!b) return badRequest("body required");
if (b.featured !== undefined) await lib.setFeatured(b.featured || null);
if (b.collections) {
const keep = new Set<string>();
for (const [i, c] of b.collections.entries()) {
if (!c.title?.trim()) return badRequest("every collection needs a title");
const cid = c.id || c.title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
keep.add(cid);
await lib.saveCollection({ id: cid, title: c.title.trim(), sort: c.sort ?? i, courseIds: c.courseIds ?? [] });
}
for (const c of await lib.listCollections()) if (!keep.has(c.id)) await lib.deleteCollection(c.id);
}
return json({ featured: await lib.getFeatured(), collections: await lib.listCollections() });
}),
},
};
}
@@ -0,0 +1,20 @@
import type { Feature } from "@stc/contracts";
/** The pdf.js worker from the installed pdfjs-dist, for client-side deck rendering. */
export function pdfWorkerPath(): string {
return Bun.resolveSync("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.dir);
}
export function assetRoutes(): Feature["routes"] {
let path: string | null = null;
return {
"/api/learner/pdf.worker.mjs": {
GET: async () => {
path ??= pdfWorkerPath();
return new Response(Bun.file(path), {
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "public, max-age=86400" },
});
},
},
};
}
@@ -0,0 +1,197 @@
import { createAdapterFactory, type CleanedWhere } from "better-auth/adapters";
import type { Db } from "@stc/db";
/**
* Better Auth database adapter over `Bun.SQL`.
*
* Better Auth's built-in SQL support goes through Kysely, which has no `Bun.SQL` dialect, and its
* Postgres path needs `pg` (banned here). `@stc/db` already gives one `Bun.SQL` handle for SQLite and
* Postgres, so this adapter writes portable SQL against it:
* - model `ssoProvider` → table `auth_sso_provider`, field `emailVerified` → column `email_verified`;
* - dates are ISO-8601 TEXT, booleans INTEGER 0/1, JSON TEXT (Better Auth converts them for us);
* - no transactions: the SQLite handle has a single connection, and a hook issuing a query while a
* transaction holds it would deadlock. Better Auth then runs the steps sequentially.
*/
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const snake = (s: string): string => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
export const camel = (s: string): string => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
function ident(name: string): string {
if (!IDENT.test(name)) throw new Error(`unsafe SQL identifier: ${name}`);
return `"${name}"`;
}
const table = (model: string) => ident(`auth_${snake(model)}`);
const col = (field: string) => ident(snake(field));
type Row = Record<string, unknown>;
function fromRow(r: Row): Row {
const out: Row = {};
for (const [k, v] of Object.entries(r)) out[camel(k)] = typeof v === "bigint" ? Number(v) : v;
return out;
}
function toValue(v: unknown): unknown {
if (v instanceof Date) return v.toISOString();
if (typeof v === "boolean") return v ? 1 : 0;
if (v !== null && typeof v === "object" && !Array.isArray(v)) return JSON.stringify(v);
return v === undefined ? null : v;
}
class Query {
params: unknown[] = [];
constructor(private dialect: Db["dialect"]) {}
p(v: unknown): string {
this.params.push(toValue(v));
return this.dialect === "postgres" ? `$${this.params.length}` : "?";
}
clause(w: CleanedWhere): string {
const c = col(w.field);
const op = w.operator ?? "eq";
const ci = w.mode === "insensitive";
const lhs = ci ? `LOWER(${c})` : c;
const val = (v: unknown) => (ci && typeof v === "string" ? this.p(v.toLowerCase()) : this.p(v));
const v = w.value;
switch (op) {
case "eq": return v === null ? `${c} IS NULL` : `${lhs} = ${val(v)}`;
case "ne": return v === null ? `${c} IS NOT NULL` : `(${lhs} <> ${val(v)} OR ${c} IS NULL)`;
case "lt": return `${c} < ${this.p(v)}`;
case "lte": return `${c} <= ${this.p(v)}`;
case "gt": return `${c} > ${this.p(v)}`;
case "gte": return `${c} >= ${this.p(v)}`;
case "in":
case "not_in": {
const list = Array.isArray(v) ? (v as unknown[]) : [v];
if (list.length === 0) return op === "in" ? "1 = 0" : "1 = 1";
return `${lhs} ${op === "in" ? "IN" : "NOT IN"} (${list.map((x) => val(x)).join(", ")})`;
}
case "contains": {
const s = String(v);
const needle = ci ? s.toLowerCase() : s;
return this.dialect === "postgres" ? `strpos(${lhs}, ${this.p(needle)}) > 0` : `instr(${lhs}, ${this.p(needle)}) > 0`;
}
case "starts_with": {
const s = ci ? String(v).toLowerCase() : String(v);
return `substr(${lhs}, 1, ${this.p(s.length)}) = ${this.p(s)}`;
}
case "ends_with": {
const s = ci ? String(v).toLowerCase() : String(v);
return `(length(${c}) >= ${this.p(s.length)} AND substr(${lhs}, length(${c}) - ${this.p(s.length)} + 1) = ${this.p(s)})`;
}
default:
throw new Error(`unsupported where operator: ${op as string}`);
}
}
/** AND clauses joined with AND, OR clauses grouped together (same as Better Auth's Kysely adapter). */
where(where: CleanedWhere[] | undefined): string {
if (!where || where.length === 0) return "";
const and = where.filter((w) => w.connector !== "OR").map((w) => this.clause(w));
const or = where.filter((w) => w.connector === "OR").map((w) => this.clause(w));
const parts = [...and];
if (or.length) parts.push(`(${or.join(" OR ")})`);
return ` WHERE ${parts.join(" AND ")}`;
}
}
export function bunSqlAdapter(db: Db) {
const run = async (text: string, params: unknown[]): Promise<Row[]> => {
const rows = await db.sql.unsafe(text, params as never[]);
return Array.from(rows as Iterable<Row>);
};
const runCount = async (text: string, params: unknown[]): Promise<number> => {
const res = await db.sql.unsafe(text, params as never[]);
return Number((res as { count?: number }).count ?? 0);
};
const q = () => new Query(db.dialect);
const selectList = (select?: string[]) => (select && select.length ? select.map(col).join(", ") : "*");
return createAdapterFactory({
config: {
adapterId: "bun-sql",
adapterName: "Bun.SQL adapter",
usePlural: false,
supportsDates: false,
supportsBooleans: false,
supportsJSON: false,
supportsArrays: false,
supportsNumericIds: false,
transaction: false,
},
adapter: () => ({
async create({ model, data, select }) {
const qq = q();
const keys = Object.keys(data).filter((k) => (data as Row)[k] !== undefined);
const vals = keys.map((k) => qq.p((data as Row)[k]));
const rows = await run(
`INSERT INTO ${table(model)} (${keys.map(col).join(", ")}) VALUES (${vals.join(", ")}) RETURNING ${selectList(select)}`,
qq.params,
);
return fromRow(rows[0]!) as never;
},
async findOne({ model, where, select }) {
const qq = q();
const w = qq.where(where);
const rows = await run(`SELECT ${selectList(select)} FROM ${table(model)}${w} LIMIT 1`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
async findMany({ model, where, limit, select, sortBy, offset }) {
const qq = q();
let text = `SELECT ${selectList(select)} FROM ${table(model)}${qq.where(where)}`;
if (sortBy) text += ` ORDER BY ${col(sortBy.field)} ${sortBy.direction === "desc" ? "DESC" : "ASC"}`;
if (limit !== undefined && limit !== null) text += ` LIMIT ${qq.p(limit)}`;
else if (offset) text += db.dialect === "sqlite" ? " LIMIT -1" : "";
if (offset) text += ` OFFSET ${qq.p(offset)}`;
return (await run(text, qq.params)).map(fromRow) as never;
},
async count({ model, where }) {
const qq = q();
const rows = await run(`SELECT COUNT(*) AS n FROM ${table(model)}${qq.where(where)}`, qq.params);
return Number(rows[0]?.n ?? 0);
},
async update({ model, where, update }) {
const qq = q();
const sets = Object.entries(update as Row).filter(([, v]) => v !== undefined).map(([k, v]) => `${col(k)} = ${qq.p(v)}`);
if (sets.length === 0) {
const q2 = q();
const rows = await run(`SELECT * FROM ${table(model)}${q2.where(where)} LIMIT 1`, q2.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
}
const rows = await run(`UPDATE ${table(model)} SET ${sets.join(", ")}${qq.where(where)} RETURNING *`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
async updateMany({ model, where, update }) {
const qq = q();
const sets = Object.entries(update).filter(([, v]) => v !== undefined).map(([k, v]) => `${col(k)} = ${qq.p(v)}`);
if (sets.length === 0) return 0;
return runCount(`UPDATE ${table(model)} SET ${sets.join(", ")}${qq.where(where)}`, qq.params);
},
async delete({ model, where }) {
const qq = q();
await run(`DELETE FROM ${table(model)}${qq.where(where)}`, qq.params);
},
async deleteMany({ model, where }) {
const qq = q();
return runCount(`DELETE FROM ${table(model)}${qq.where(where)}`, qq.params);
},
async consumeOne({ model, where }) {
const qq = q();
const t = table(model);
const rows = await run(`DELETE FROM ${t} WHERE "id" IN (SELECT "id" FROM ${t}${qq.where(where)} LIMIT 1) RETURNING *`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
async incrementOne({ model, where, increment, set }) {
const qq = q();
const sets = [
...Object.entries(increment).map(([k, d]) => `${col(k)} = ${col(k)} + ${qq.p(d)}`),
...Object.entries(set ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => `${col(k)} = ${qq.p(v)}`),
];
const t = table(model);
const rows = await run(`UPDATE ${t} SET ${sets.join(", ")} WHERE "id" IN (SELECT "id" FROM ${t}${qq.where(where)} LIMIT 1) RETURNING *`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
}),
});
}
@@ -0,0 +1,45 @@
import type { Db } from "@stc/db";
export const groupIdFromName = (name: string): string =>
name.trim().toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "group";
/** Reads a groups claim from an IdP profile: an array, or a comma/space separated string. */
export function groupsFromClaims(profile: Record<string, unknown> | null | undefined, claim = "groups"): string[] {
if (!profile) return [];
const raw = profile[claim] ?? (profile.extraFields as Record<string, unknown> | undefined)?.[claim];
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[,\s]+/) : [];
return [...new Set(list.map((g) => String(g).trim()).filter(Boolean))];
}
/** Finds or creates the group called `name`; returns its id. */
export async function ensureGroup(db: Db, name: string): Promise<string> {
const byName = await db.sql`SELECT id FROM groups WHERE name = ${name}`;
if (byName.length) return byName[0].id;
let id = groupIdFromName(name);
const taken = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`;
if (taken.length) id = `${id}-${Bun.randomUUIDv7().slice(-6)}`;
await db.sql`INSERT INTO groups (id, name) VALUES (${id}, ${name})`;
return id;
}
/**
* Makes the user's claim-sourced group memberships equal `names`. Memberships the owner added by hand
* are never removed here, and a group the user already belongs to by hand is not recorded as a claim.
*/
export async function syncClaimGroups(db: Db, userId: string, names: string[]): Promise<void> {
const wanted = new Set<string>();
for (const n of names) wanted.add(await ensureGroup(db, n));
const prior = new Set<string>((await db.sql`SELECT group_id FROM learner_group_claims WHERE user_id = ${userId}`).map((r: { group_id: string }) => r.group_id));
for (const g of prior) {
if (wanted.has(g)) continue;
await db.sql`DELETE FROM learner_group_claims WHERE user_id = ${userId} AND group_id = ${g}`;
await db.sql`DELETE FROM group_members WHERE user_id = ${userId} AND group_id = ${g}`;
}
for (const g of wanted) {
if (prior.has(g)) continue;
const member = await db.sql`SELECT 1 AS x FROM group_members WHERE group_id = ${g} AND user_id = ${userId}`;
if (member.length) continue; // manual membership stays manual
await db.sql`INSERT INTO group_members (group_id, user_id) VALUES (${g}, ${userId})`;
await db.sql`INSERT INTO learner_group_claims (user_id, group_id) VALUES (${userId}, ${g})`;
}
}
@@ -0,0 +1,181 @@
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { sso } from "@better-auth/sso";
import type { AuthService, AuthUser, Role, ServerContext } from "@stc/contracts";
import type { Db } from "@stc/db";
import { bunSqlAdapter } from "./adapter";
import { groupsFromClaims, syncClaimGroups } from "./groups";
export interface ProviderInfo { id: string; label: string; kind: "social" | "oidc" }
type Env = Record<string, string | undefined>;
const SOCIAL: { id: string; label: string; env: string }[] = [
{ id: "github", label: "GitHub", env: "GITHUB" },
{ id: "google", label: "Google", env: "GOOGLE" },
{ id: "microsoft", label: "Microsoft", env: "MICROSOFT" },
{ id: "gitlab", label: "GitLab", env: "GITLAB" },
{ id: "discord", label: "Discord", env: "DISCORD" },
];
/** Owner-only Better Auth paths (SSO provider administration). Sign-in and callbacks stay open. */
const OWNER_ONLY = /^\/api\/auth\/sso\/(register|update-provider|delete-provider|providers|get-provider|request-domain-verification|verify-domain)\b/;
export async function rolesOf(db: Db, userId: string): Promise<Role[]> {
const rows = await db.sql`SELECT role FROM user_roles WHERE user_id = ${userId} ORDER BY role`;
return rows.map((r: { role: Role }) => r.role);
}
export async function groupsOf(db: Db, userId: string): Promise<string[]> {
const rows = await db.sql`SELECT group_id FROM group_members WHERE user_id = ${userId} ORDER BY group_id`;
return rows.map((r: { group_id: string }) => r.group_id);
}
/**
* First user ever to be created becomes the owner. The claim is an INSERT into `learner_meta`
* keyed `owner`, so with concurrent sign-ups exactly one wins (primary-key conflict for the rest).
*/
export async function assignInitialRole(db: Db, userId: string): Promise<Role> {
let role: Role = "learner";
try {
await db.sql`INSERT INTO learner_meta (key, value) VALUES ('owner', ${userId})`;
role = "owner";
} catch {
role = "learner";
}
await db.sql`INSERT INTO user_roles (user_id, role) VALUES (${userId}, ${role})`;
return role;
}
export interface LearnerAuth {
handler(req: Request, user: AuthUser | null): Promise<Response>;
service: AuthService & { invalidate(userId?: string): void };
providers(): ProviderInfo[];
hasSso(): Promise<boolean>;
// biome-ignore lint/suspicious/noExplicitAny: Better Auth's instance type is deeply generic
api: any;
}
export function createAuth(ctx: ServerContext, ready: Promise<unknown>, env: Env = process.env): LearnerAuth {
const { db, config } = ctx;
const groupsClaim = env.STC_AUTH_GROUPS_CLAIM || "groups";
const pendingClaims = new Map<string, { groups: string[]; at: number }>();
const providers: ProviderInfo[] = [];
const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {};
for (const s of SOCIAL) {
const id = env[`STC_AUTH_${s.env}_ID`], secret = env[`STC_AUTH_${s.env}_SECRET`];
if (id && secret) {
socialProviders[s.id] = { clientId: id, clientSecret: secret };
providers.push({ id: s.id, label: s.label, kind: "social" });
}
}
// biome-ignore lint/suspicious/noExplicitAny: plugin list is heterogeneous
const plugins: any[] = [];
if (env.STC_AUTH_OIDC_ISSUER && env.STC_AUTH_OIDC_ID) {
const issuer = env.STC_AUTH_OIDC_ISSUER.replace(/\/$/, "");
plugins.push(genericOAuth({
config: [{
providerId: "oidc",
clientId: env.STC_AUTH_OIDC_ID,
clientSecret: env.STC_AUTH_OIDC_SECRET ?? "",
discoveryUrl: env.STC_AUTH_OIDC_DISCOVERY_URL || `${issuer}/.well-known/openid-configuration`,
scopes: (env.STC_AUTH_OIDC_SCOPES || "openid email profile").split(/[\s,]+/),
pkce: true,
mapProfileToUser: (profile) => {
const email = typeof profile.email === "string" ? profile.email.toLowerCase() : null;
if (email) pendingClaims.set(email, { groups: groupsFromClaims(profile as Record<string, unknown>, groupsClaim), at: Date.now() });
return {};
},
}],
}));
providers.push({ id: "oidc", label: env.STC_AUTH_OIDC_NAME || "Single sign-on", kind: "oidc" });
}
plugins.push(sso({
provisionUserOnEveryLogin: true,
provisionUser: async ({ user, userInfo }) => {
await syncClaimGroups(db, user.id, groupsFromClaims(userInfo, groupsClaim));
cache.clear();
},
modelName: "ssoProvider",
}));
const options: BetterAuthOptions = {
appName: "STCourses",
baseURL: config.publicUrl,
basePath: "/api/auth",
secret: config.secret,
database: bunSqlAdapter(db),
emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
socialProviders,
plugins,
trustedOrigins: [config.publicUrl, ...(env.STC_TRUSTED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean)],
telemetry: { enabled: false },
session: { cookieCache: { enabled: true, maxAge: 60 } },
databaseHooks: {
user: { create: { after: async (user) => { await assignInitialRole(db, user.id); } } },
session: {
create: {
after: async (session) => {
const rows = await db.sql`SELECT email FROM auth_user WHERE id = ${session.userId}`;
const email = rows[0]?.email?.toLowerCase();
const claim = email ? pendingClaims.get(email) : undefined;
if (claim && email) {
pendingClaims.delete(email);
if (Date.now() - claim.at < 10 * 60_000) await syncClaimGroups(db, session.userId, claim.groups);
}
cache.delete(session.userId);
},
},
},
},
};
const auth = betterAuth(options);
// userId -> roles/groups, briefly cached: every request (media ranges included) resolves the user.
const cache = new Map<string, { roles: Role[]; groups: string[]; at: number }>();
const TTL = 15_000;
const service: LearnerAuth["service"] = {
async userFromRequest(req) {
if (!req.headers.get("cookie")) return null;
await ready;
let s: { user: { id: string; email: string; name: string } } | null = null;
try {
s = await auth.api.getSession({ headers: req.headers });
} catch {
return null;
}
if (!s) return null;
const id = s.user.id;
let c = cache.get(id);
if (!c || Date.now() - c.at > TTL) {
c = { roles: await rolesOf(db, id), groups: await groupsOf(db, id), at: Date.now() };
cache.set(id, c);
}
return { id, email: s.user.email, name: s.user.name, roles: c.roles, groups: c.groups };
},
invalidate(userId) {
if (userId) cache.delete(userId);
else cache.clear();
},
};
return {
api: auth.api,
service,
providers: () => providers,
async hasSso() {
await ready;
const rows = await db.sql`SELECT COUNT(*) AS n FROM auth_sso_provider`;
return Number(rows[0].n) > 0;
},
async handler(req, user) {
await ready;
const path = new URL(req.url).pathname;
if (OWNER_ONLY.test(path) && !user?.roles.includes("owner")) return Response.json({ error: "owner only" }, { status: 403 });
return auth.handler(req);
},
};
}
@@ -0,0 +1,216 @@
import { statSync } from "node:fs";
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
import type { LoadedCourse, Manifest } from "@stc/course-format";
import { libraryRepo, progressRepo } from "@stc/db";
import type {
ContinueItem, CourseCard, CoursePayload, Format, LessonProgress, LibraryPayload, OutlineModule, SearchHit,
} from "../shared/types";
import { mediaUrl } from "../shared/types";
import { accessIndex, courseFor, isOwner, visible } from "./access";
import { moduleLocks } from "./gating";
import { json, notFound, param } from "./http";
const WPM = 220;
const EXERCISE_MINUTES = 15;
/** Lesson length estimates (minutes), cached per course and manifest mtime. */
export function createEstimator(ctx: ServerContext) {
const cache = new Map<string, { key: string; minutes: Map<string, number | null> }>();
return async function minutesFor(c: LoadedCourse): Promise<Map<string, number | null>> {
let key = c.manifestPath;
try { key += `@${statSync(c.manifestPath).mtimeMs}`; } catch { /* keep path */ }
const hit = cache.get(c.id);
if (hit && hit.key === key) return hit.minutes;
const minutes = new Map<string, number | null>();
for (const mod of c.manifest?.modules ?? []) {
for (const l of mod.lessons) {
if (l.type === "video") {
minutes.set(l.id, l.duration ? l.duration / 60 : null);
continue;
}
let words = 0;
try {
const md = await ctx.courses.readLessonMarkdown(c.id, l.id);
words = md ? md.split(/\s+/).filter(Boolean).length : 0;
} catch { /* unreadable: no estimate */ }
const read = words / WPM;
minutes.set(l.id, l.type === "exercise" ? Math.max(EXERCISE_MINUTES, read) : words ? Math.max(1, read) : null);
}
}
cache.set(c.id, { key, minutes });
return minutes;
};
}
export function formatsOf(m: Manifest): Format[] {
const types = new Set(m.modules.flatMap((x) => x.lessons.map((l) => l.type)));
const f: Format[] = [];
if (types.has("text")) f.push("text");
if (types.has("video")) f.push("video");
if (m.decks?.length) f.push("slides");
if (types.has("exercise")) f.push("exercises");
return f;
}
export function addedAt(c: LoadedCourse): string {
const imported = c.manifest?.source?.importedAt;
if (imported && !Number.isNaN(Date.parse(imported))) return new Date(imported).toISOString();
try { return statSync(c.manifestPath).mtime.toISOString(); } catch { return new Date(0).toISOString(); }
}
const slug = (s: string) => s.trim().toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
const fold = (s: string) => s.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
type ProgressByCourse = Map<string, Record<string, LessonProgress>>;
async function allProgress(ctx: ServerContext, user: AuthUser | null): Promise<ProgressByCourse> {
const out: ProgressByCourse = new Map();
if (!user) return out;
const rows = await ctx.db.sql`SELECT course_id, lesson_id, state, position_seconds, updated_at FROM progress WHERE user_id = ${user.id}`;
for (const r of rows) {
const rec = out.get(r.course_id) ?? {};
rec[r.lesson_id] = { state: r.state, positionSeconds: r.position_seconds === null ? null : Number(r.position_seconds), updatedAt: r.updated_at };
out.set(r.course_id, rec);
}
return out;
}
export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
const minutesFor = createEstimator(ctx);
const lib = libraryRepo(ctx.db);
async function card(c: LoadedCourse, progress: Record<string, LessonProgress>): Promise<CourseCard> {
const m = c.manifest;
if (!m) {
return {
id: c.id, title: c.id, pitch: c.diagnostics[0]?.message ?? null, authors: [], coverUrl: null, accent: null, formats: [],
lessonCount: 0, minutes: 0, collections: [], tracks: [], source: null, addedAt: addedAt(c), status: "error", progress: 0, doneCount: 0, started: false,
};
}
const lessons = m.modules.flatMap((x) => x.lessons);
const est = await minutesFor(c);
const minutes = Math.round(lessons.reduce((s, l) => s + (est.get(l.id) ?? 0), 0));
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,
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,
started: lessons.some((l) => progress[l.id]),
};
}
function continueItem(c: LoadedCourse, progress: Record<string, LessonProgress>, bypass: boolean): ContinueItem | null {
const m = c.manifest!;
const lessons = m.modules.flatMap((x) => x.lessons);
const done = lessons.filter((l) => progress[l.id]?.state === "done").length;
if (!lessons.some((l) => progress[l.id]) || done === lessons.length) return null;
const locks = moduleLocks(m, progress, bypass);
for (const [i, mod] of m.modules.entries()) {
if (locks[i]) break;
const next = mod.lessons.find((l) => progress[l.id]?.state !== "done");
if (!next) continue;
return {
courseId: m.id, lessonId: next.id, lessonTitle: next.title, moduleTitle: mod.title, moduleIndex: i,
moduleDone: mod.lessons.filter((l) => progress[l.id]?.state === "done").length, moduleTotal: mod.lessons.length,
progress: done / lessons.length,
};
}
return null;
}
return {
"/api/library": {
GET: async (_req, rc) => {
const user = rc.user;
const idx = await accessIndex(ctx.db, user);
const courses = ctx.courses.list().filter((c) => visible(user, c, idx));
const progress = await allProgress(ctx, user);
const cards = await Promise.all(courses.map((c) => card(c, progress.get(c.id) ?? {})));
const byId = new Map(cards.map((c) => [c.id, c]));
const cont: (ContinueItem & { at: string })[] = [];
for (const c of courses) {
if (!c.manifest) continue;
const p = progress.get(c.id) ?? {};
const item = continueItem(c, p, isOwner(user));
if (item) cont.push({ ...item, at: Object.values(p).map((x) => x.updatedAt).sort().pop() ?? "" });
}
cont.sort((a, b) => (a.at < b.at ? 1 : -1));
const newest = [...cards].filter((c) => c.status === "ok" || c.lessonCount).sort((a, b) => (a.addedAt < b.addedAt ? 1 : a.addedAt > b.addedAt ? -1 : a.title.localeCompare(b.title))).map((c) => c.id);
const collections: LibraryPayload["collections"] = [];
for (const col of await lib.listCollections()) {
collections.push({ id: col.id, title: col.title, courseIds: col.courseIds.filter((id) => byId.has(id)) });
}
for (const c of cards) {
for (const name of c.collections) {
const id = slug(name);
let col = collections.find((x) => x.id === id || x.title.toLowerCase() === name.toLowerCase());
if (!col) collections.push((col = { id, title: name, courseIds: [] }));
if (!col.courseIds.includes(c.id)) col.courseIds.push(c.id);
}
}
const featuredId = await lib.getFeatured();
const featured = (featuredId && byId.get(featuredId)) || (cont[0] && byId.get(cont[0].courseId)) || byId.get(newest[0] ?? "") || null;
const payload: LibraryPayload = {
signedIn: !!user,
featured,
continue: cont.map(({ at: _at, ...rest }) => rest),
newest,
collections: collections.filter((c) => c.courseIds.length > 0),
courses: cards.sort((a, b) => a.title.localeCompare(b.title)),
};
return json(payload);
},
},
"/api/library/search": {
GET: async (_req, rc) => {
const q = fold(rc.url.searchParams.get("q") ?? "").trim();
if (!q) return json([]);
const terms = q.split(/\s+/);
const hit = (s: string | undefined) => !!s && terms.every((t) => fold(s).includes(t));
const idx = await accessIndex(ctx.db, rc.user);
const out: SearchHit[] = [];
for (const c of ctx.courses.list()) {
const m = c.manifest;
if (!m || !visible(rc.user, c, idx)) continue;
const course = hit(`${m.title} ${m.pitch ?? ""} ${(m.authors ?? []).join(" ")} ${(m.collections ?? []).join(" ")}`);
const lessons = m.modules.flatMap((mod) => mod.lessons.filter((l) => hit(l.title) || hit(`${mod.title} ${l.title}`)).map((l) => ({ id: l.id, title: l.title, moduleTitle: mod.title })));
if (course || lessons.length) out.push({ courseId: m.id, courseTitle: m.title, course, lessons: lessons.slice(0, 8) });
}
out.sort((a, b) => Number(b.course) - Number(a.course) || b.lessons.length - a.lessons.length);
return json(out.slice(0, 50));
},
},
"/api/courses/:courseId": {
GET: async (_req, rc) => {
const found = await courseFor(ctx, rc.user, param(rc, "courseId"));
if (!found) return notFound();
const { course, manifest: m } = found;
const progress = rc.user ? await progressRepo(ctx.db).get(rc.user.id, m.id) : {};
const est = await minutesFor(course);
const locks = moduleLocks(m, progress, found.canEdit);
const deckTargets = new Set((m.decks ?? []).map((d) => d.attachTo));
const modules: OutlineModule[] = m.modules.map((mod, i) => ({
id: mod.id, title: mod.title, index: i, locked: locks[i]!, hasCode: !!mod.code,
lessons: mod.lessons.map((l, li) => ({
id: l.id, title: l.title, type: l.type, minutes: est.get(l.id) ?? null,
hasDeck: deckTargets.has(l.id) || (li === 0 && deckTargets.has(mod.id)), browser: !!l.browser,
})),
}));
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,
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 })) : [],
};
return json(payload);
},
},
};
}
@@ -0,0 +1,81 @@
import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { join, sep } from "node:path";
import type { Feature, RequestContext, ServerContext } from "@stc/contracts";
import { contentDisposition } from "@stc/core";
import type { CodeListing } from "../shared/types";
import { courseFor } from "./access";
import { json, notFound, param } from "./http";
import { zip } from "./zip";
const SKIP = new Set([".git", "node_modules", "__pycache__", ".DS_Store", ".venv", "target", "dist", ".idea", ".vscode"]);
export const MAX_FILES = 2000;
export const MAX_ZIP_BYTES = 200 * 1024 * 1024;
/**
* Regular files under `dirAbs` (relative paths, sorted). Symlinks are followed only when their target is a
* regular file inside `rootReal`; symlinked directories are skipped, so there are no cycles and no escapes.
*/
export function listFiles(rootReal: string, dirAbs: string, max = MAX_FILES): { files: { path: string; size: number; abs: string }[]; truncated: boolean } {
const files: { path: string; size: number; abs: string }[] = [];
let truncated = false;
const walk = (abs: string, rel: string) => {
let entries;
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const e of entries) {
if (SKIP.has(e.name)) continue;
if (files.length >= max) { truncated = true; return; }
const childAbs = join(abs, e.name), childRel = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) { walk(childAbs, childRel); continue; }
let real = childAbs;
if (e.isSymbolicLink()) {
try { real = realpathSync(childAbs); } catch { continue; }
if (!(real === rootReal || real.startsWith(rootReal + sep))) continue;
}
try {
const st = lstatSync(real);
if (st.isFile()) files.push({ path: childRel, size: st.size, abs: real });
} catch { /* vanished */ }
}
};
walk(dirAbs, "");
return { files, truncated };
}
export function codeRoutes(ctx: ServerContext): Feature["routes"] {
async function resolve(rc: RequestContext) {
const found = await courseFor(ctx, rc.user, param(rc, "courseId"));
if (!found) return null;
const mod = found.manifest.modules.find((m) => m.id === param(rc, "moduleId"));
if (!mod?.code) return null;
const dirAbs = ctx.courses.resolvePath(found.manifest.id, mod.code.dir);
if (!dirAbs) return null;
return { found, mod, code: mod.code, dirAbs, rootReal: realpathSync(found.course.root) };
}
return {
"/api/courses/:courseId/code/:moduleId": {
GET: async (_req, rc) => {
const r = await resolve(rc);
if (!r) return notFound();
const { files, truncated } = listFiles(r.rootReal, r.dirAbs);
const listing: CodeListing = { moduleId: r.mod.id, dir: r.code.dir, command: r.code.command ?? null, files: files.map(({ path, size }) => ({ path, size })), truncated };
return json(listing);
},
},
"/api/courses/:courseId/code/:moduleId/zip": {
GET: async (_req, rc) => {
const r = await resolve(rc);
if (!r) return notFound();
const { files } = listFiles(r.rootReal, r.dirAbs, 20_000);
const total = files.reduce((s, f) => s + f.size, 0);
if (total > MAX_ZIP_BYTES) return json({ error: "this folder is too large to download as one zip" }, 413);
const folder = r.code.dir.split("/").filter(Boolean).pop() ?? r.mod.id;
const data = zip(files.map((f) => ({ name: `${folder}/${f.path}`, data: new Uint8Array(readFileSync(f.abs)), mtime: lstatSync(f.abs).mtime })));
return new Response(data, {
headers: { "Content-Type": "application/zip", "Content-Length": String(data.length), "Content-Disposition": contentDisposition(`${folder}.zip`) },
});
},
},
};
}
@@ -0,0 +1,47 @@
import type { AuthService, Feature, RouteHandler, 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 { lessonRoutes } from "./lesson";
import { userDataRoutes } from "./userdata";
import { codeRoutes } from "./code";
import { transpileRoutes } from "./transpile";
import { assetRoutes } from "./assets";
import { runnerFrameRoutes } from "./runnerFrame";
/**
* The learner feature: auth, library, reader, notes, progress and exercises.
* 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);
const routes: Feature["routes"] = {
"/api/auth/*": {
GET: (req, rc) => auth.handler(req, rc.user),
POST: (req, rc) => auth.handler(req, rc.user),
},
"/api/me": { GET: async (_req, rc) => json({ user: rc.user }) },
"/api/learner/providers": {
GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }),
},
...adminRoutes(ctx, auth.service.invalidate),
...libraryRoutes(ctx),
...lessonRoutes(ctx),
...userDataRoutes(ctx),
...codeRoutes(ctx),
...transpileRoutes(),
...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,15 @@
import type { Manifest } from "@stc/course-format";
/**
* Sequential gating (spec section 12): with `gating: sequential`, module i is locked until every
* lesson of every earlier module is done. `bypass` (owners and course authors) unlocks everything.
*/
export function moduleLocks(m: Manifest, progress: Record<string, { state: string }>, bypass = false): boolean[] {
if (m.gating !== "sequential" || bypass) return m.modules.map(() => false);
let open = true;
return m.modules.map((mod) => {
const locked = !open;
if (!mod.lessons.every((l) => progress[l.id]?.state === "done")) open = false;
return locked;
});
}
@@ -0,0 +1,28 @@
import type { AuthUser, RequestContext, RouteHandler } from "@stc/contracts";
export const json = (body: unknown, status = 200): Response => Response.json(body, { status });
export const notFound = (): Response => json({ error: "not found" }, 404);
export const badRequest = (message: string): Response => json({ error: message }, 400);
/** Wraps a handler that needs a signed-in user; anonymous callers get 401. */
export const signedIn = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
(req, rc) => (rc.user ? h(req, rc, rc.user) : json({ error: "sign in required" }, 401));
/** Owner-only handler: 401 when anonymous, 403 for other roles. */
export const ownerOnly = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
signedIn((req, rc, user) => (user.roles.includes("owner") ? h(req, rc, user) : json({ error: "owner only" }, 403)));
export async function body<T = Record<string, unknown>>(req: Request): Promise<T | null> {
try {
const v = await req.json();
return v && typeof v === "object" ? (v as T) : null;
} catch {
return null;
}
}
/** Route params, decoded (core's matcher already decodes; Bun's router may not). */
export const param = (rc: RequestContext, name: string): string => {
const v = rc.params[name] ?? "";
try { return decodeURIComponent(v); } catch { return v; }
};
@@ -0,0 +1,115 @@
import { statSync } from "node:fs";
import type { Feature, ServerContext } from "@stc/contracts";
import { extractHints, extractSection, type Lesson, type Manifest } from "@stc/course-format";
import { progressRepo } from "@stc/db";
import type { FileRef, LessonPayload } from "../shared/types";
import { mediaUrl } from "../shared/types";
import { courseFor } from "./access";
import { createEstimator } from "./catalog";
import { moduleLocks } from "./gating";
import { json, notFound, param } from "./http";
const isMarkdown = (p: string) => /\.(md|markdown)$/i.test(p);
export function lessonFiles(m: Manifest): Record<string, string> {
const out: Record<string, string> = {};
for (const mod of m.modules) for (const l of mod.lessons) if (l.file && !l.anchor && isMarkdown(l.file) && !(l.file in out)) out[l.file] = l.id;
return out;
}
export function lessonRoutes(ctx: ServerContext): Feature["routes"] {
const minutesFor = createEstimator(ctx);
const read = async (courseId: string, rel: string): Promise<string | null> => {
const abs = ctx.courses.resolvePath(courseId, rel);
if (!abs) return null;
try { return await Bun.file(abs).text(); } catch { return null; }
};
const fileRef = (courseId: string, file: string, title?: string): FileRef => {
const abs = ctx.courses.resolvePath(courseId, file);
let size: number | null = null;
try { if (abs) size = statSync(abs).size; } catch { /* missing */ }
return { title: title ?? file.split("/").pop()!, file, url: mediaUrl(courseId, file), size };
};
return {
"/api/courses/:courseId/lessons/:lessonId": {
GET: async (_req, rc) => {
const found = await courseFor(ctx, rc.user, param(rc, "courseId"));
if (!found) return notFound();
const { course, manifest: m } = found;
const lessonId = param(rc, "lessonId");
const entry = course.lessonIndex.get(lessonId);
if (!entry) return notFound();
const { lesson: l } = entry;
const modIndex = m.modules.findIndex((x) => x.id === entry.moduleId);
const mod = m.modules[modIndex]!;
const flat: { l: Lesson; mi: number }[] = m.modules.flatMap((x, mi) => x.lessons.map((ll) => ({ l: ll, mi })));
const pos = flat.findIndex((x) => x.l.id === l.id);
const progress = rc.user ? await progressRepo(ctx.db).get(rc.user.id, m.id) : {};
const locks = moduleLocks(m, progress, found.canEdit);
const link = (x: { l: Lesson; mi: number } | undefined) => (x ? { id: x.l.id, title: x.l.title } : null);
const est = await minutesFor(course);
const base: LessonPayload = {
courseId: m.id, courseTitle: m.title,
lesson: { id: l.id, title: l.title, type: l.type, minutes: est.get(l.id) ?? null },
module: { id: mod.id, title: mod.title, index: modIndex },
locked: locks[modIndex]!,
markdown: null, baseFile: null, trackMarkdown: {}, tracks: m.tracks ?? [],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null,
localCommand: null,
prev: link(flat[pos - 1]), next: link(flat[pos + 1]),
lessonFiles: lessonFiles(m),
};
if (base.locked) return json(base);
if (l.type === "video") {
if (l.file) base.video = { url: mediaUrl(m.id, l.file), file: l.file, duration: l.duration ?? null };
if (l.transcript && isMarkdown(l.transcript)) {
base.transcript = await read(m.id, l.transcript);
base.baseFile = l.transcript;
}
} else if (l.file && isMarkdown(l.file)) {
const text = await read(m.id, l.file);
base.markdown = text === null ? null : l.anchor ? extractSection(text, l.anchor) : text;
base.baseFile = l.file;
}
for (const [track, t] of Object.entries(l.tracks ?? {})) {
if (t.file && isMarkdown(t.file)) {
const text = await read(m.id, t.file);
if (text !== null) base.trackMarkdown[track] = l.anchor ? extractSection(text, l.anchor) ?? text : text;
}
}
if (l.hints) {
const text = await read(m.id, l.hints.file);
if (text !== null) base.hints = extractHints(text, l.hints.match);
}
const first = mod.lessons[0]?.id === l.id;
base.decks = (m.decks ?? [])
.filter((d) => d.attachTo === l.id || (first && d.attachTo === mod.id))
.filter((d) => ctx.courses.resolvePath(m.id, d.file))
.map((d) => ({ id: d.id, title: d.title ?? d.file.split("/").pop()!.replace(/\.pdf$/i, ""), url: mediaUrl(m.id, d.file), attachTo: d.attachTo }));
if (mod.code) base.code = { moduleId: mod.id, dir: mod.code.dir, command: mod.code.command ?? null };
base.attachments = (m.attachments ?? [])
.filter((a) => !a.module || a.module === mod.id)
.filter((a) => ctx.courses.resolvePath(m.id, a.file))
.map((a) => fileRef(m.id, a.file, a.title));
if (l.browser) {
const starter = await read(m.id, l.browser.starter);
const tests = await read(m.id, l.browser.tests);
if (starter !== null && tests !== null) {
base.browser = {
language: l.browser.language, starter, tests, starterFile: l.browser.starter.split("/").pop()!,
// the solution is visible to authors only (spec section 5)
solution: found.canEdit && l.browser.solution ? await read(m.id, l.browser.solution) : null,
};
}
}
const trackCommands = Object.fromEntries(Object.entries(l.tracks ?? {}).filter(([, t]) => t.command).map(([k, t]) => [k, t.command!]));
base.localCommand = l.localCommand ?? (Object.keys(trackCommands).length ? trackCommands : null);
return json(base);
},
},
};
}
@@ -0,0 +1,33 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Db } from "@stc/db";
/** Folders holding this package's migrations; Phase 2 wires these into `migrate()`. */
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;
}
@@ -0,0 +1,67 @@
import type { Feature } from "@stc/contracts";
import { WORKER_SOURCE } from "../web/runner/worker-source";
/**
* The exercise runner frame. The page loads it in `<iframe sandbox="allow-scripts">` (never allow-same-origin),
* so it has an opaque origin: no cookies, no storage, no access to the page. Its own CSP allows only this one
* inline script (by hash) and blob: scripts and workers, and no connections at all (`connect-src 'none'`), which
* also covers dynamic import of remote URLs, Cache.add, nested workers, EventSource and WebSocket. The worker
* inherits that policy. The page talks to the frame only with postMessage, and both sides check `event.source`.
*/
export const RUNNER_FRAME_PATH = "/api/learner/runner-frame";
export const FRAME_SCRIPT = `(() => {
"use strict";
const SRC = ${JSON.stringify(WORKER_SOURCE)};
let worker = null;
const send = (msg) => parent.postMessage(msg, "*");
addEventListener("message", (ev) => {
if (ev.source !== parent) return;
const m = ev.data;
if (!m || m.type !== "run" || worker) return;
const url = URL.createObjectURL(new Blob([SRC], { type: "text/javascript" }));
// A classic worker: module workers from blob: URLs fail to start in an opaque-origin frame in Chrome.
// The worker still loads the learner and test modules with import() of blob: URLs.
worker = new Worker(url);
worker.onmessage = (e) => send({ type: "worker", data: e.data });
worker.onerror = (e) => { e.preventDefault(); send({ type: "crash", message: e.message || "The exercise worker crashed" }); };
worker.postMessage({ code: String(m.code), tests: String(m.tests) });
});
send({ type: "ready" });
})();`;
const hash = new Bun.CryptoHasher("sha256").update(FRAME_SCRIPT).digest("base64");
export const RUNNER_FRAME_CSP = [
"default-src 'none'",
`script-src 'sha256-${hash}' blob:`,
"worker-src blob:",
"connect-src 'none'",
"img-src 'none'",
"style-src 'none'",
"font-src 'none'",
"media-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'self'",
"sandbox allow-scripts",
].join("; ");
const HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Exercise runner</title></head><body><script>${FRAME_SCRIPT}</script></body></html>`;
export function runnerFrameRoutes(): Feature["routes"] {
return {
[RUNNER_FRAME_PATH]: {
GET: async () => new Response(HTML, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": RUNNER_FRAME_CSP,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Cache-Control": "no-store",
},
}),
},
};
}
@@ -0,0 +1,27 @@
import type { Feature } from "@stc/contracts";
import { badRequest, body, json } from "./http";
const MAX = 200_000;
const ts = new Bun.Transpiler({ loader: "ts", target: "browser" });
/** Type-strips TypeScript for the in-browser runner (the worker itself runs plain JavaScript). */
export function transpileTs(code: string): string {
return ts.transformSync(code);
}
export function transpileRoutes(): Feature["routes"] {
return {
"/api/learner/transpile": {
POST: async (req) => {
const b = await body<{ code?: string }>(req);
if (!b || typeof b.code !== "string") return badRequest("code is required");
if (b.code.length > MAX) return badRequest("code is too long");
try {
return json({ code: transpileTs(b.code) });
} catch (e) {
return json({ error: (e as Error).message }, 422);
}
},
},
};
}
@@ -0,0 +1,117 @@
import type { Feature, ServerContext } from "@stc/contracts";
import { attemptsRepo, notesRepo, progressRepo, type Note as DbNote } from "@stc/db";
import type { Note, ProgressState } from "../shared/types";
import { courseFor } from "./access";
import { moduleLocks } from "./gating";
import { badRequest, body, json, notFound, param, signedIn } from "./http";
const STATES: ProgressState[] = ["not-started", "in-progress", "done"];
const KINDS = ["none", "video", "slide"] as const;
const MAX_NOTE = 20_000;
const MAX_CODE = 200_000;
const publicNote = ({ userId: _u, ...n }: DbNote): Note => n;
/** Progress, notes and exercise attempts: per user, signed-in only, scoped to courses the user can see. */
export function userDataRoutes(ctx: ServerContext): Feature["routes"] {
const progress = progressRepo(ctx.db);
const notes = notesRepo(ctx.db);
const attempts = attemptsRepo(ctx.db);
/** Course and lesson the user may write to, or an error response. */
async function target(userId: string, user: Parameters<typeof courseFor>[1], courseId: string, lessonId: string) {
const found = await courseFor(ctx, user, courseId);
if (!found) return { error: notFound() };
const entry = found.course.lessonIndex.get(lessonId);
if (!entry) return { error: notFound() };
const p = await progress.get(userId, courseId);
const mi = found.manifest.modules.findIndex((m) => m.id === entry.moduleId);
if (moduleLocks(found.manifest, p, found.canEdit)[mi]) return { error: json({ error: "this module is locked until the previous one is done" }, 403) };
return { found, entry, progress: p };
}
return {
"/api/courses/:courseId/progress": {
GET: signedIn(async (_req, rc, user) => {
const courseId = param(rc, "courseId");
if (!(await courseFor(ctx, user, courseId))) return notFound();
return json(await progress.get(user.id, courseId));
}),
},
"/api/courses/:courseId/progress/:lessonId": {
PUT: signedIn(async (req, rc, user) => {
const courseId = param(rc, "courseId"), lessonId = param(rc, "lessonId");
const b = await body<{ state?: ProgressState; positionSeconds?: number }>(req);
if (!b || !STATES.includes(b.state as ProgressState)) return badRequest("state must be not-started, in-progress or done");
if (b.positionSeconds !== undefined && (typeof b.positionSeconds !== "number" || !Number.isFinite(b.positionSeconds) || b.positionSeconds < 0)) return badRequest("positionSeconds must be a non-negative number");
const t = await target(user.id, user, courseId, lessonId);
if (t.error) return t.error;
const prior = t.progress[lessonId];
// A position report ("in-progress") never undoes "done"; only an explicit not-started does.
const state = prior?.state === "done" && b.state === "in-progress" ? "done" : b.state!;
const pos = b.positionSeconds ?? prior?.positionSeconds ?? undefined;
await progress.set(user.id, courseId, lessonId, state, pos ?? undefined);
return json((await progress.get(user.id, courseId))[lessonId]);
}),
},
"/api/courses/:courseId/notes": {
GET: signedIn(async (_req, rc, user) => {
const courseId = param(rc, "courseId");
if (!(await courseFor(ctx, user, courseId))) return notFound();
const lesson = rc.url.searchParams.get("lesson") ?? undefined;
return json((await notes.list(user.id, courseId, lesson)).map(publicNote));
}),
POST: signedIn(async (req, rc, user) => {
const courseId = param(rc, "courseId");
const b = await body<{ lessonId?: string; anchorKind?: string; anchorValue?: string | number | null; deckId?: string | null; body?: string }>(req);
if (!b || typeof b.lessonId !== "string" || typeof b.body !== "string") return badRequest("lessonId and body are required");
if (b.body.length > MAX_NOTE) return badRequest("note is too long");
const kind = (b.anchorKind ?? "none") as (typeof KINDS)[number];
if (!KINDS.includes(kind)) return badRequest("anchorKind must be none, video or slide");
if (kind !== "none" && (b.anchorValue === null || b.anchorValue === undefined || Number.isNaN(Number(b.anchorValue)))) return badRequest("video and slide notes need a numeric anchorValue");
const found = await courseFor(ctx, user, courseId);
if (!found || !found.course.lessonIndex.has(b.lessonId)) return notFound();
if (kind === "slide" && !(found.manifest.decks ?? []).some((d) => d.id === b.deckId)) return badRequest("slide notes need the deckId of a deck in this course");
const n = await notes.create({
userId: user.id, courseId, lessonId: b.lessonId, anchorKind: kind,
anchorValue: kind === "none" ? null : String(b.anchorValue), deckId: kind === "slide" ? b.deckId! : null, body: b.body,
});
return json(publicNote(n), 201);
}),
},
"/api/notes/:id": {
PATCH: signedIn(async (req, rc, user) => {
const b = await body<{ body?: string }>(req);
if (!b || typeof b.body !== "string") return badRequest("body is required");
if (b.body.length > MAX_NOTE) return badRequest("note is too long");
const n = await notes.update(param(rc, "id"), user.id, b.body);
return n ? json(publicNote(n)) : notFound();
}),
DELETE: signedIn(async (_req, rc, user) => ((await notes.remove(param(rc, "id"), user.id)) ? json({ ok: true }) : notFound())),
},
"/api/courses/:courseId/lessons/:lessonId/attempt": {
GET: signedIn(async (_req, rc, user) => {
const courseId = param(rc, "courseId"), lessonId = param(rc, "lessonId");
const found = await courseFor(ctx, user, courseId);
if (!found || !found.course.lessonIndex.has(lessonId)) return notFound();
const a = await attempts.get(user.id, courseId, lessonId, rc.url.searchParams.get("track") ?? "default");
return json(a ? { code: a.code, result: a.result, passed: a.passed, updatedAt: a.updatedAt } : null);
}),
PUT: signedIn(async (req, rc, user) => {
const courseId = param(rc, "courseId"), lessonId = param(rc, "lessonId");
const b = await body<{ track?: string; code?: string; result?: unknown; passed?: boolean }>(req);
if (!b || typeof b.code !== "string") return badRequest("code is required");
if (b.code.length > MAX_CODE) return badRequest("code is too long");
const t = await target(user.id, user, courseId, lessonId);
if (t.error) return t.error;
if (t.entry.lesson.type !== "exercise") return badRequest("not an exercise");
const passed = b.passed === true;
await attempts.save({ userId: user.id, courseId, lessonId, track: b.track || "default", code: b.code, result: b.result ?? null, passed, updatedAt: new Date().toISOString() });
// An exercise counts as done when all its tests pass (spec 4.4).
if (passed) await progress.set(user.id, courseId, lessonId, "done");
else if (!t.progress[lessonId]) await progress.set(user.id, courseId, lessonId, "in-progress");
return json({ ok: true, passed, state: (await progress.get(user.id, courseId))[lessonId]?.state });
}),
},
};
}
@@ -0,0 +1,70 @@
/** Minimal ZIP writer (PKWARE APPNOTE 6.3): deflate or store per entry, CRC-32, UTF-8 names (flag bit 11). No ZIP64. */
export interface ZipEntry { name: string; data: Uint8Array<ArrayBuffer>; mtime?: Date }
function dosTime(d: Date): { time: number; date: number } {
const y = Math.max(1980, d.getFullYear());
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
date: ((y - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
export function zip(entries: ZipEntry[]): Uint8Array<ArrayBuffer> {
const enc = new TextEncoder();
const locals: Uint8Array<ArrayBuffer>[] = [];
const centrals: Uint8Array[] = [];
let offset = 0;
for (const e of entries) {
const name = enc.encode(e.name);
const crc = Bun.hash.crc32(e.data) >>> 0;
const deflated = Bun.deflateSync(e.data);
const useDeflate = deflated.length < e.data.length;
const body = useDeflate ? deflated : e.data;
const { time, date } = dosTime(e.mtime ?? new Date());
const local = new Uint8Array(30 + name.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(4, 20, true);
lv.setUint16(6, 0x0800, true); // UTF-8 names
lv.setUint16(8, useDeflate ? 8 : 0, true);
lv.setUint16(10, time, true);
lv.setUint16(12, date, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, body.length, true);
lv.setUint32(22, e.data.length, true);
lv.setUint16(26, name.length, true);
lv.setUint16(28, 0, true);
local.set(name, 30);
const central = new Uint8Array(46 + name.length);
const cv = new DataView(central.buffer);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 0x0314, true); // made by: unix, 2.0
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, useDeflate ? 8 : 0, true);
cv.setUint16(12, time, true);
cv.setUint16(14, date, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, body.length, true);
cv.setUint32(24, e.data.length, true);
cv.setUint16(28, name.length, true);
cv.setUint32(38, (0o100644 << 16) >>> 0, true); // external attrs: regular file rw-r--r--
cv.setUint32(42, offset, true);
central.set(name, 46);
locals.push(local, body);
centrals.push(central);
offset += local.length + body.length;
}
const cdSize = centrals.reduce((s, c) => s + c.length, 0);
const end = new Uint8Array(22);
const ev = new DataView(end.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, cdSize, true);
ev.setUint32(16, offset, true);
const out = new Uint8Array(offset + cdSize + 22);
let p = 0;
for (const part of [...locals, ...centrals, end]) { out.set(part, p); p += part.length; }
return out;
}
@@ -0,0 +1,121 @@
// JSON shapes shared by the learner API and the web app.
export type LessonType = "text" | "video" | "exercise";
export type Format = "text" | "video" | "slides" | "exercises";
export type ProgressState = "not-started" | "in-progress" | "done";
export interface LessonProgress { state: ProgressState; positionSeconds: number | null; updatedAt: string }
export interface CourseCard {
id: string;
title: string;
pitch: string | null;
authors: string[];
coverUrl: string | null;
accent: string | null;
formats: Format[];
lessonCount: number;
minutes: number;
collections: string[];
tracks: string[];
source: string | null;
addedAt: string;
status: "ok" | "error";
/** 0..1 share of lessons done; 0 when signed out. */
progress: number;
doneCount: number;
started: boolean;
}
export interface ContinueItem {
courseId: string;
lessonId: string;
lessonTitle: string;
moduleTitle: string;
moduleIndex: number;
moduleDone: number;
moduleTotal: number;
progress: number;
}
export interface LibraryPayload {
signedIn: boolean;
featured: CourseCard | null;
continue: ContinueItem[];
newest: string[];
collections: { id: string; title: string; courseIds: string[] }[];
courses: CourseCard[];
}
export interface SearchHit { courseId: string; courseTitle: string; course: boolean; lessons: { id: string; title: string; moduleTitle: string }[] }
export interface OutlineLesson { id: string; title: string; type: LessonType; minutes: number | null; hasDeck: boolean; browser: boolean }
export interface OutlineModule { id: string; title: string; index: number; locked: boolean; hasCode: boolean; lessons: OutlineLesson[] }
export interface Diagnostic { level: "error" | "warning"; lessonId?: string; path?: string; message: string }
export interface CoursePayload {
id: string;
title: string;
pitch: string | null;
authors: string[];
accent: string | null;
coverUrl: string | null;
tracks: { id: string; label: string }[];
gating: "none" | "sequential";
source: string | null;
status: "ok" | "error";
modules: OutlineModule[];
progress: Record<string, LessonProgress>;
canEdit: boolean;
signedIn: boolean;
diagnostics: Diagnostic[];
}
export interface DeckRef { id: string; title: string; url: string; attachTo: string }
export interface FileRef { title: string; file: string; url: string; size: number | null }
export interface LessonLink { id: string; title: string }
export interface LessonPayload {
courseId: string;
courseTitle: string;
lesson: { id: string; title: string; type: LessonType; minutes: number | null };
module: { id: string; title: string; index: number };
locked: boolean;
/** Lesson body (text: the file or its anchored section; exercise: the anchored section; video: null). */
markdown: string | null;
/** Course-relative path the markdown came from, for resolving relative links and images. */
baseFile: string | null;
/** Per-track replacement bodies, when the manifest gives track files. */
trackMarkdown: Record<string, string>;
tracks: { id: string; label: string }[];
video: { url: string; file: string; duration: number | null } | null;
transcript: string | null;
hints: { label: string; body: string }[];
decks: DeckRef[];
code: { moduleId: string; dir: string; command: string | null } | null;
attachments: FileRef[];
browser: { language: "js" | "ts"; starter: string; tests: string; starterFile: string; solution: string | null } | null;
localCommand: string | Record<string, string> | null;
prev: LessonLink | null;
next: LessonLink | null;
/** Course file → lesson id, for turning links between lesson files into reader links. */
lessonFiles: Record<string, string>;
}
export interface Note {
id: string;
courseId: string;
lessonId: string;
anchorKind: "none" | "video" | "slide";
anchorValue: string | null;
deckId: string | null;
body: string;
createdAt: string;
updatedAt: string;
}
export interface CodeListing { moduleId: string; dir: string; command: string | null; files: { path: string; size: number }[]; truncated: boolean }
export const mediaUrl = (courseId: string, rel: string): string =>
`/api/media/${encodeURIComponent(courseId)}/${rel.split("/").map(encodeURIComponent).join("/")}`;
@@ -0,0 +1,132 @@
import { useEffect, useState, type FormEvent } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Pill, SegmentedToggle } from "@stc/ui";
import { api, clearCourseCache, ensureStyles, refreshLibrary, refreshMe, useApi, useMe } from "../api";
import { HeaderExtras } from "../shell/HeaderExtras";
export interface ProvidersPayload { emailPassword: boolean; providers: { id: string; label: string; kind: "social" | "oidc" }[]; sso: boolean }
async function afterAuthChange() {
clearCourseCache();
await Promise.all([refreshMe(), refreshLibrary()]);
}
/** Only same-site paths are allowed as a return target. */
export function safeNext(raw: string | null): string {
return raw && raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
}
export default function AccountPage() {
ensureStyles();
const { loaded, user } = useMe();
useEffect(() => { document.title = "Account – STCourses"; }, []);
return (
<div className="lr" data-look="work">
<HeaderExtras course={null} lookMode={null} />
<article style={{ maxWidth: 660, margin: "0 auto", paddingBottom: 60 }}>
{!loaded ? null : user ? <SignedIn /> : <SignInForms />}
</article>
</div>
);
}
function SignedIn() {
const { user } = useMe();
const navigate = useNavigate();
const [busy, setBusy] = useState(false);
if (!user) return null;
const signOut = async () => {
setBusy(true);
try { await api("/api/auth/sign-out", { method: "POST", body: {} }); } catch { /* the session may already be gone */ }
await afterAuthChange();
setBusy(false);
navigate("/");
};
return (
<>
<h1 className="lib-h">Your account</h1>
<dl className="acct-dl">
<dt>Name</dt><dd>{user.name || "—"}</dd>
<dt>Email</dt><dd>{user.email}</dd>
<dt>Roles</dt><dd>{user.roles.join(", ") || "learner"}</dd>
{user.groups.length ? <><dt>Groups</dt><dd>{user.groups.join(", ")}</dd></> : null}
</dl>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{user.roles.includes("owner") ? <Link className="stc-pill" to="/account/admin">Users, groups and library</Link> : null}
<Pill onClick={signOut} disabled={busy}>Sign out</Pill>
</div>
</>
);
}
export function SignInForms() {
const loc = useLocation();
const navigate = useNavigate();
const next = safeNext(new URLSearchParams(loc.search).get("next"));
const { data: prov } = useApi<ProvidersPayload>("/api/learner/providers");
const [mode, setMode] = useState<"in" | "up">("in");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [ssoEmail, setSsoEmail] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setErr(null); setBusy(true);
try {
if (mode === "in") await api("/api/auth/sign-in/email", { method: "POST", body: { email, password } });
else await api("/api/auth/sign-up/email", { method: "POST", body: { name: name.trim() || email.split("@")[0], email, password } });
await afterAuthChange();
navigate(next);
} catch (e) {
setErr(mode === "in" ? "That email and password do not match an account." : (e as Error).message);
} finally { setBusy(false); }
};
const redirect = async (path: string, body: Record<string, unknown>) => {
setErr(null);
try {
const callbackURL = new URL(next, location.origin).toString();
const r = await api<{ url?: string }>(path, { method: "POST", body: { ...body, callbackURL } });
if (r?.url) location.assign(r.url); else setErr("The sign-in provider did not answer.");
} catch (e) { setErr((e as Error).message); }
};
return (
<>
<h1 className="lib-h">{mode === "in" ? "Sign in" : "Create an account"}</h1>
<p className="lib-sub">Your progress, notes and exercise code are kept with your account.</p>
<div style={{ marginBottom: 20 }}>
<SegmentedToggle label="Sign in or create an account" value={mode} onChange={(v) => { setMode(v as "in" | "up"); setErr(null); }}
options={[{ value: "in", label: "Sign in" }, { value: "up", label: "Create account" }]} />
</div>
<form className="form" onSubmit={submit}>
{mode === "up" ? <label>Name<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" /></label> : null}
<label>Email<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" /></label>
<label>Password<input type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={mode === "in" ? "current-password" : "new-password"} /></label>
{err ? <p className="err" role="alert">{err}</p> : null}
<div><Pill type="submit" variant="primary" disabled={busy}>{mode === "in" ? "Sign in" : "Create account"}</Pill></div>
{mode === "up" ? <p className="muted" style={{ fontSize: 12.5, margin: 0 }}>The first account on a new server becomes its owner.</p> : null}
</form>
{prov && (prov.providers.length || prov.sso) ? (
<>
<div className="or">or</div>
<div className="prov">
{prov.providers.map((p) => (
<Pill key={p.id} onClick={() => void (p.kind === "oidc" ? redirect("/api/auth/sign-in/oauth2", { providerId: p.id }) : redirect("/api/auth/sign-in/social", { provider: p.id }))}>
Continue with {p.label}
</Pill>
))}
</div>
{prov.sso ? (
<form className="form" style={{ marginTop: 18 }} onSubmit={(e) => { e.preventDefault(); void redirect("/api/auth/sign-in/sso", { email: ssoEmail }); }}>
<label>Work email for single sign-on<input type="email" required value={ssoEmail} onChange={(e) => setSsoEmail(e.target.value)} /></label>
<div><Pill type="submit">Continue with single sign-on</Pill></div>
</form>
) : null}
</>
) : null}
</>
);
}
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Pill } from "@stc/ui";
import type { Role } from "@stc/contracts";
import { api, ensureStyles, refreshLibrary, toast, useApi, useLibrary, useMe } from "../api";
import { HeaderExtras } from "../shell/HeaderExtras";
interface AdminUser { id: string; email: string; name: string; createdAt: string; roles: Role[]; groups: string[] }
interface AdminGroup { id: string; name: string; members: string[] }
interface Collection { id: string; title: string; sort: number; courseIds: string[] }
interface Access { visibility: string; acl: { principal_type: "user" | "group"; principal_id: string }[]; authors: string[] }
const ROLES: Role[] = ["owner", "author", "learner"];
/** Owner administration: roles, groups, course access and library curation. */
export default function AdminPage() {
ensureStyles();
const { loaded, user } = useMe();
useEffect(() => { document.title = "Administration – STCourses"; }, []);
const owner = !!user?.roles.includes("owner");
return (
<div className="lr" data-look="work">
<HeaderExtras course={null} lookMode={null} />
<article style={{ maxWidth: 900, margin: "0 auto", paddingBottom: 80 }}>
<p className="chap"><Link to="/account">Account</Link></p>
<h1 className="lib-h">Users, groups and library</h1>
{!loaded ? null : !owner ? <p className="lib-sub">Only the owner of this server can open this page.</p> : <AdminSections />}
</article>
</div>
);
}
function AdminSections() {
const users = useApi<AdminUser[]>("/api/admin/users");
const groups = useApi<AdminGroup[]>("/api/admin/groups");
return (
<>
<p className="lib-sub">Sign-in providers are configured on the server with STC_AUTH_* settings.</p>
<UsersSection users={users.data ?? []} reload={users.reload} />
<GroupsSection groups={groups.data ?? []} users={users.data ?? []} reload={() => { groups.reload(); users.reload(); }} />
<LibrarySection />
<AccessSection users={users.data ?? []} groups={groups.data ?? []} />
</>
);
}
function UsersSection({ users, reload }: { users: AdminUser[]; reload: () => void }) {
const setRoles = async (u: AdminUser, role: Role, on: boolean) => {
const roles = on ? [...u.roles, role] : u.roles.filter((r) => r !== role);
try { await api(`/api/admin/users/${encodeURIComponent(u.id)}/roles`, { method: "PUT", body: { roles } }); reload(); }
catch (e) { toast((e as Error).message); }
};
return (
<section className="admin-sec" aria-labelledby="adm-users">
<h2 id="adm-users">Users and roles</h2>
<table className="table">
<thead><tr><th>User</th><th>Roles</th><th>Groups</th></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td><div>{u.name || u.email}</div><span className="ls">{u.email}</span></td>
<td><div className="checks">{ROLES.map((r) => (
<label key={r}><input type="checkbox" checked={u.roles.includes(r)} onChange={(e) => setRoles(u, r, e.target.checked)} />{r}</label>
))}</div></td>
<td><span className="ls">{u.groups.join(", ")}</span></td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function GroupsSection({ groups, users, reload }: { groups: AdminGroup[]; users: AdminUser[]; reload: () => void }) {
const [name, setName] = useState("");
const create = async () => {
try { await api("/api/admin/groups", { method: "POST", body: { name } }); setName(""); reload(); } catch (e) { toast((e as Error).message); }
};
const setMembers = async (g: AdminGroup, userIds: string[]) => {
try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}/members`, { method: "PUT", body: { userIds } }); reload(); } catch (e) { toast((e as Error).message); }
};
const remove = async (g: AdminGroup) => {
if (!confirm(`Delete the group “${g.name}”? Courses shared with it lose that access.`)) return;
try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}`, { method: "DELETE" }); reload(); } catch (e) { toast((e as Error).message); }
};
return (
<section className="admin-sec" aria-labelledby="adm-groups">
<h2 id="adm-groups">Groups</h2>
<p className="source" style={{ marginTop: 0 }}>Groups give access to restricted courses. Groups named in OIDC or SAML claims are filled at sign-in.</p>
{groups.length ? (
<table className="table">
<thead><tr><th>Group</th><th>Members</th><th /></tr></thead>
<tbody>
{groups.map((g) => (
<tr key={g.id}>
<td>{g.name}</td>
<td><div className="checks">{users.map((u) => (
<label key={u.id}><input type="checkbox" checked={g.members.includes(u.id)}
onChange={(e) => setMembers(g, e.target.checked ? [...g.members, u.id] : g.members.filter((x) => x !== u.id))} />{u.name || u.email}</label>
))}</div></td>
<td style={{ textAlign: "right" }}><Pill className="sm" variant="ghost" onClick={() => remove(g)}>Delete</Pill></td>
</tr>
))}
</tbody>
</table>
) : null}
<div className="row-form">
<input className="field" placeholder="New group name" value={name} onChange={(e) => setName(e.target.value)} aria-label="New group name" />
<Pill className="sm" disabled={!name.trim()} onClick={create}>Add group</Pill>
</div>
</section>
);
}
function LibrarySection() {
const lib = useLibrary();
const cur = useApi<{ featured: string | null; collections: Collection[] }>("/api/admin/library");
const [featured, setFeatured] = useState<string>("");
const [cols, setCols] = useState<Collection[]>([]);
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (!cur.data) return;
setFeatured(cur.data.featured ?? ""); setCols(cur.data.collections); setDirty(false);
}, [cur.data]);
const courses = lib?.courses ?? [];
const save = async () => {
try {
await api("/api/admin/library", { method: "PUT", body: { featured: featured || null, collections: cols.filter((c) => c.title.trim()).map((c, i) => ({ ...c, sort: i })) } });
cur.reload(); void refreshLibrary(); toast("Library saved");
} catch (e) { toast((e as Error).message); }
};
const edit = (i: number, c: Partial<Collection>) => { setCols(cols.map((x, k) => (k === i ? { ...x, ...c } : x))); setDirty(true); };
return (
<section className="admin-sec" aria-labelledby="adm-lib">
<h2 id="adm-lib">Library</h2>
<div className="row-form" style={{ marginTop: 0 }}>
<label className="ls" htmlFor="adm-feat">Featured course</label>
<select id="adm-feat" className="field" value={featured} onChange={(e) => { setFeatured(e.target.value); setDirty(true); }}>
<option value="">Automatic (what you were last working on, else the newest)</option>
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
</select>
</div>
<h3 className="sec-h" style={{ marginTop: 24 }}>Collections</h3>
<p className="source" style={{ marginTop: 0 }}>Each collection is a row in the library. A course can be in several. Courses can also name collections in their manifest.</p>
<table className="table">
<tbody>
{cols.map((c, i) => (
<tr key={c.id || `new-${i}`}>
<td style={{ width: 220 }}><input className="field" value={c.title} aria-label="Collection title" onChange={(e) => edit(i, { title: e.target.value })} /></td>
<td><div className="checks">{courses.map((co) => (
<label key={co.id}><input type="checkbox" checked={c.courseIds.includes(co.id)}
onChange={(e) => edit(i, { courseIds: e.target.checked ? [...c.courseIds, co.id] : c.courseIds.filter((x) => x !== co.id) })} />{co.title}</label>
))}</div></td>
<td style={{ textAlign: "right" }}><Pill className="sm" variant="ghost" onClick={() => { setCols(cols.filter((_, k) => k !== i)); setDirty(true); }}>Remove</Pill></td>
</tr>
))}
</tbody>
</table>
<div className="row-form">
<Pill className="sm" onClick={() => { setCols([...cols, { id: "", title: "", sort: cols.length, courseIds: [] }]); setDirty(true); }}>Add collection</Pill>
<Pill className="sm" variant="primary" disabled={!dirty} onClick={save}>Save library</Pill>
</div>
</section>
);
}
function AccessSection({ users, groups }: { users: AdminUser[]; groups: AdminGroup[] }) {
const lib = useLibrary();
const [courseId, setCourseId] = useState("");
const access = useApi<Access>(courseId ? `/api/admin/courses/${encodeURIComponent(courseId)}/access` : null);
const [draft, setDraft] = useState<Access | null>(null);
useEffect(() => { setDraft(access.data); }, [access.data]);
const has = (t: "user" | "group", id: string) => !!draft?.acl.some((a) => a.principal_type === t && a.principal_id === id);
const toggle = (t: "user" | "group", id: string, on: boolean) => draft && setDraft({
...draft, acl: on ? [...draft.acl, { principal_type: t, principal_id: id }] : draft.acl.filter((a) => !(a.principal_type === t && a.principal_id === id)),
});
const save = async () => {
if (!draft) return;
try { await api(`/api/admin/courses/${encodeURIComponent(courseId)}/access`, { method: "PUT", body: { acl: draft.acl, authors: draft.authors } }); access.reload(); toast("Access saved"); }
catch (e) { toast((e as Error).message); }
};
return (
<section className="admin-sec" aria-labelledby="adm-access">
<h2 id="adm-access">Course access</h2>
<p className="source" style={{ marginTop: 0 }}>Restricted courses are visible to the users and groups listed here. Authors can edit a course in the studio.</p>
<div className="row-form" style={{ marginTop: 0 }}>
<select className="field" value={courseId} onChange={(e) => setCourseId(e.target.value)} aria-label="Course">
<option value="">Choose a course</option>
{(lib?.courses ?? []).map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
</select>
{draft ? <span className="ls">Visibility in the manifest: {draft.visibility}</span> : null}
</div>
{draft ? (
<>
<table className="table" style={{ marginTop: 14 }}>
<tbody>
<tr><td style={{ width: 120 }} className="ls">Users</td><td><div className="checks">{users.map((u) => <label key={u.id}><input type="checkbox" checked={has("user", u.id)} onChange={(e) => toggle("user", u.id, e.target.checked)} />{u.name || u.email}</label>)}</div></td></tr>
<tr><td className="ls">Groups</td><td><div className="checks">{groups.length ? groups.map((g) => <label key={g.id}><input type="checkbox" checked={has("group", g.id)} onChange={(e) => toggle("group", g.id, e.target.checked)} />{g.name}</label>) : <span className="ls">No groups yet</span>}</div></td></tr>
<tr><td className="ls">Authors</td><td><div className="checks">{users.map((u) => <label key={u.id}><input type="checkbox" checked={draft.authors.includes(u.id)} onChange={(e) => setDraft({ ...draft, authors: e.target.checked ? [...draft.authors, u.id] : draft.authors.filter((x) => x !== u.id) })} />{u.name || u.email}</label>)}</div></td></tr>
</tbody>
</table>
<div className="row-form"><Pill className="sm" variant="primary" onClick={save}>Save access</Pill></div>
</>
) : null}
</section>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { AuthUser } from "@stc/contracts";
import type { CoursePayload, LibraryPayload } from "../shared/types";
import { learnerCss } from "./styles";
/** Injects the learner stylesheet once (the module graph stays importable on the server). */
export function ensureStyles(): void {
if (typeof document === "undefined" || document.getElementById("stc-learner-css")) return;
const el = document.createElement("style");
el.id = "stc-learner-css";
el.textContent = learnerCss;
document.head.append(el);
}
export { learnerCss };
export class HttpError extends Error {
constructor(public status: number, message: string) { super(message); }
}
export async function api<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
const r = await fetch(path, {
method: init.method ?? "GET",
credentials: "same-origin",
headers: init.body !== undefined ? { "content-type": "application/json" } : undefined,
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
});
const text = await r.text();
let data: unknown = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (!r.ok) {
const msg = (data && typeof data === "object" && ("error" in data || "message" in data))
? String((data as { error?: string; message?: string }).error ?? (data as { message?: string }).message)
: r.status === 404 ? "Not found" : `Request failed (${r.status})`;
throw new HttpError(r.status, msg);
}
return data as T;
}
/** Loads `path` (null skips). `reload()` refetches without clearing the current data. */
export function useApi<T>(path: string | null): { data: T | null; error: HttpError | null; loading: boolean; reload: () => void; set: (v: T) => void } {
const [state, setState] = useState<{ path: string | null; data: T | null; error: HttpError | null; loading: boolean }>({ path, data: null, error: null, loading: !!path });
const [n, setN] = useState(0);
const current = state.path === path ? state : { path, data: null, error: null, loading: !!path };
useEffect(() => {
if (!path) return;
let live = true;
setState((s) => (s.path === path ? { ...s, loading: true } : { path, data: null, error: null, loading: true }));
api<T>(path).then(
(data) => live && setState({ path, data, error: null, loading: false }),
(e: unknown) => live && setState({ path, data: null, error: e instanceof HttpError ? e : new HttpError(0, String(e)), loading: false }),
);
return () => { live = false; };
}, [path, n]);
const reload = useCallback(() => setN((x) => x + 1), []);
const set = useCallback((v: T) => setState((s) => ({ ...s, data: v })), []);
return { data: current.data, error: current.error, loading: current.loading, reload, set };
}
/* ---------- tiny shared stores ---------- */
function store<T>(initial: T) {
let value = initial;
const subs = new Set<() => void>();
return {
get: () => value,
set(v: T) { if (v !== value) { value = v; subs.forEach((f) => f()); } },
subscribe(f: () => void) { subs.add(f); return () => { subs.delete(f); }; },
};
}
function useStore<T>(s: ReturnType<typeof store<T>>): T {
return useSyncExternalStore(s.subscribe, s.get, s.get);
}
const ls = {
get(k: string): string | null { try { return localStorage.getItem(k); } catch { return null; } },
set(k: string, v: string) { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
};
export type Look = "reading" | "work";
const lookStore = store<Look>((typeof window !== "undefined" && ls.get("stc.look") === "work") ? "work" : "reading");
export function useLook(): [Look, (l: Look) => void] {
const v = useStore(lookStore);
return [v, (l) => { ls.set("stc.look", l); lookStore.set(l); }];
}
const searchStore = store("");
export function useSearch(): [string, (q: string) => void] {
return [useStore(searchStore), searchStore.set];
}
const trackStore = store(0);
export function useTrack(courseId: string, tracks: { id: string }[]): [string | null, (t: string) => void] {
useStore(trackStore);
const saved = typeof window !== "undefined" ? ls.get(`stc.track.${courseId}`) : null;
const value = tracks.find((t) => t.id === saved)?.id ?? tracks[0]?.id ?? null;
return [value, (t) => { ls.set(`stc.track.${courseId}`, t); trackStore.set(trackStore.get() + 1); }];
}
/* ---------- session and caches ---------- */
const meStore = store<{ loaded: boolean; user: AuthUser | null }>({ loaded: false, user: null });
let meLoading: Promise<void> | null = null;
export function refreshMe(): Promise<void> {
meLoading = api<{ user: AuthUser | null }>("/api/me").then(
(r) => meStore.set({ loaded: true, user: r.user }),
() => meStore.set({ loaded: true, user: null }),
);
return meLoading;
}
export function useMe(): { loaded: boolean; user: AuthUser | null } {
const v = useStore(meStore);
useEffect(() => { if (!v.loaded && !meLoading) void refreshMe(); }, [v.loaded]);
return v;
}
const libraryStore = store<LibraryPayload | null>(null);
let libraryLoading: Promise<void> | null = null;
export function refreshLibrary(): Promise<void> {
libraryLoading = api<LibraryPayload>("/api/library").then((l) => libraryStore.set(l), () => { /* keep stale */ });
return libraryLoading;
}
export function useLibrary(): LibraryPayload | null {
const v = useStore(libraryStore);
useEffect(() => { if (!libraryLoading) void refreshLibrary(); }, []);
return v;
}
const courseCache = new Map<string, CoursePayload>();
const courseVersion = store(0);
/** The course outline with progress. Cached across route changes; `reload` refetches. */
export function useCourse(courseId: string): { course: CoursePayload | null; error: HttpError | null; reload: () => Promise<void> } {
useStore(courseVersion);
const [error, setError] = useState<HttpError | null>(null);
const live = useRef(true);
const reload = useCallback(async () => {
try {
const c = await api<CoursePayload>(`/api/courses/${encodeURIComponent(courseId)}`);
courseCache.set(courseId, c);
if (live.current) setError(null);
courseVersion.set(courseVersion.get() + 1);
} catch (e) {
if (live.current) setError(e instanceof HttpError ? e : new HttpError(0, String(e)));
}
}, [courseId]);
useEffect(() => {
live.current = true;
setError(null);
void reload();
return () => { live.current = false; };
}, [reload]);
return { course: courseCache.get(courseId) ?? null, error, reload };
}
export function clearCourseCache(): void { courseCache.clear(); courseVersion.set(courseVersion.get() + 1); }
/* ---------- toast ---------- */
const toastStore = store<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
export function toast(msg: string): void {
toastStore.set(msg);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastStore.set(null), 2600);
}
export function useToast(): string | null { return useStore(toastStore); }
/* ---------- formatting ---------- */
export function fmtTime(s: number): string {
const t = Math.max(0, Math.floor(s));
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), sec = t % 60;
return h ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`;
}
export function fmtMinutes(min: number | null | undefined): string {
if (!min) return "";
if (min < 60) return `${Math.round(min)} min`;
const h = Math.floor(min / 60), m = Math.round(min % 60);
return m ? `${h} h ${m} min` : `${h} h`;
}
export function fmtSize(n: number | null | undefined): string {
if (n === null || n === undefined) return "";
if (n < 1024) return `${n} B`;
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 async function copyText(text: string): Promise<boolean> {
try { await navigator.clipboard.writeText(text); return true; } catch {
const t = document.createElement("textarea");
t.value = text; document.body.append(t); t.select();
let ok = false;
try { ok = document.execCommand("copy"); } catch { /* unsupported */ }
t.remove();
return ok;
}
}
@@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui";
import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types";
import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api";
import { Check, Next, Prev } from "../icons";
import { CourseFrame, courseDone, moduleNumber } from "../shell/CourseFrame";
import { HeaderExtras } from "../shell/HeaderExtras";
import { LessonView, TitleText } from "../lesson/LessonView";
import { NoteItem } from "../lesson/notes";
function Problem({ title, children }: { title: string; children?: React.ReactNode }) {
ensureStyles();
return (
<div className="lr" data-look="work">
<HeaderExtras course={null} lookMode={null} />
<article className="col" style={{ maxWidth: 660, margin: "0 auto" }}>
<h1 className="lib-h">{title}</h1>
<div className="lib-sub">{children}</div>
<Link className="stc-pill" to="/">Back to the library</Link>
</article>
</div>
);
}
function CourseMissing({ status, signedIn }: { status: number; signedIn: boolean }) {
if (status === 404) {
return (
<Problem title="Course not found">
This course does not exist, or you do not have access to it.{signedIn ? "" : <> <Link to="/account">Signing in</Link> may help.</>}
</Problem>
);
}
return <Problem title="Something went wrong">The course could not be loaded. Try again in a moment.</Problem>;
}
/** First lesson that is not done (in an unlocked module), or the first lesson. */
export function nextLesson(c: CoursePayload): { id: string; title: string } | null {
for (const m of c.modules) {
if (m.locked) break;
for (const l of m.lessons) if (c.progress[l.id]?.state !== "done") return l;
}
return c.modules[0]?.lessons[0] ?? null;
}
/* ---------- /c/:courseId ---------- */
export function CoursePage() {
const { courseId = "" } = useParams();
const { course, error } = useCourse(courseId);
const { user } = useMe();
const [look] = useLook();
useEffect(() => { if (course) document.title = `${course.title} – STCourses`; }, [course]);
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
if (!course) return null;
const { done, total } = courseDone(course);
const next = nextLesson(course);
const started = Object.keys(course.progress).length > 0;
const minutes = course.modules.flatMap((m) => m.lessons).reduce((s, l) => s + (l.minutes ?? 0), 0);
return (
<CourseFrame course={course} currentLessonId={null} look={look} lookMode={null}>
<article className="col" data-look="work" style={{ paddingBottom: 60 }}>
<div style={{ display: "flex", gap: 24, alignItems: "flex-end" }}>
<span style={{ "--cw": "120px" } as CSSProperties}><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
<div style={{ minWidth: 0 }}>
<h1 className="title" style={{ fontFamily: "var(--sans)" }}>{course.title}</h1>
{course.authors.length ? <p className="source">{course.authors.join(", ")}</p> : null}
</div>
</div>
{course.pitch ? <p style={{ fontSize: 16, color: "var(--muted)", margin: "22px 0 0" }}>{course.pitch}</p> : null}
<dl className="meta">
<div><dt>Lessons</dt><dd className="tnum">{total}</dd></div>
{minutes ? <div><dt>Time</dt><dd>About {fmtMinutes(minutes)}</dd></div> : null}
{course.tracks.length > 1 ? <div><dt>Tracks</dt><dd>{course.tracks.map((t) => t.label).join(", ")}</dd></div> : null}
{course.gating === "sequential" ? <div><dt>Order</dt><dd>Each module opens when the one before it is done</dd></div> : null}
{course.signedIn ? <div style={{ minWidth: 180 }}><dt>Progress</dt><dd className="lr-prog" style={{ marginTop: 4 }}><ProgressBar value={total ? done / total : 0} label="Course progress" /><span className="tnum">{done} of {total}</span></dd></div> : null}
</dl>
{course.status === "error" ? <div className="notice"><strong>This course has problems.</strong> {course.canEdit ? "The list in the sidebar names them." : "Some lessons may not open."}</div> : null}
<div style={{ display: "flex", gap: 8, marginTop: 24, alignItems: "center", flexWrap: "wrap" }}>
{next ? <Link className="stc-pill stc-pill--primary" to={lessonHref(course.id, next.id)}>{started ? "Continue" : "Start"}{started ? <span style={{ fontWeight: 400 }}>: {next.title.replace(/`/g, "")}</span> : null}</Link> : null}
{!course.signedIn ? <span className="source" style={{ margin: 0 }}><Link to="/account">Sign in</Link> to keep your progress and notes.</span> : null}
</div>
<h2 className="sec-h" style={{ marginTop: 40 }}>Modules</h2>
<div className="lib">
{course.modules.map((m) => {
const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
return (
<Link key={m.id} className="lib-row" to={lessonHref(course.id, m.lessons[0]?.id ?? "")} style={{ gridTemplateColumns: "32px minmax(0,1fr) 120px" }}>
<span className="lnum tnum">{moduleNumber(course, m.index)}</span>
<span style={{ minWidth: 0 }}><span className="lt">{m.title}</span><span className="ls">{m.lessons.length === 1 ? "1 lesson" : `${m.lessons.length} lessons`}{m.locked ? " · locked" : ""}</span></span>
{course.signedIn ? <span className="lr-prog" style={{ margin: 0 }}><ProgressBar value={m.lessons.length ? d / m.lessons.length : 0} dim label={`${m.title} progress`} /></span> : <span />}
</Link>
);
})}
</div>
</article>
</CourseFrame>
);
}
/* ---------- /c/:courseId/:lessonId ---------- */
export function LessonPage() {
const { courseId = "", lessonId = "" } = useParams();
const loc = useLocation();
const navigate = useNavigate();
const { course, error: courseError, reload } = useCourse(courseId);
const lessonApi = useApi<LessonPayload>(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`);
const lesson = lessonApi.data;
const { user } = useMe();
const [look] = useLook();
const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []);
const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]);
const initialTime = q.has("t") ? Number(q.get("t")) : null;
const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null;
const progress = course?.progress[lessonId] ?? null;
const signedIn = !!user;
useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]);
useEffect(() => { window.scrollTo(0, 0); }, [lessonId]);
const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => {
if (!signedIn) return;
try {
await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } });
if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); }
} catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); }
}, [courseId, lessonId, signedIn, progress?.state, reload]);
if (courseError && !course) return <CourseMissing status={courseError.status} signedIn={signedIn} />;
if (lessonApi.error && course) {
return (
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}>
<article className="col"><h1 className="title">Lesson not found</h1><p className="source">This course has no lesson “{lessonId}”.</p></article>
</CourseFrame>
);
}
if (!course || !lesson || lesson.lesson.id !== lessonId) {
return course ? <CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}><div className="col" /></CourseFrame> : null;
}
const isDone = progress?.state === "done";
const isExercise = lesson.lesson.type === "exercise";
const n = moduleNumber(course, lesson.module.index);
return (
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={isExercise ? "work" : "toggle"}>
<LessonView
lesson={lesson} look={look} track={track} onTrackChange={setTrack} progress={progress} signedIn={signedIn}
onProgress={setState} onSaved={() => { void reload(); void refreshLibrary(); }}
initialTime={initialTime} initialSlide={initialSlide}
moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()}
/>
<FloatingActionBar>
<Pill variant="ghost" disabled={!lesson.prev} title={lesson.prev ? `Previous: ${lesson.prev.title}` : "First lesson"} onClick={() => lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}>
<Prev /><span className="sr">Previous lesson</span>
</Pill>
<Pill variant="ghost" aria-pressed={isDone} disabled={!signedIn || lesson.locked} title={signedIn ? undefined : "Sign in to keep your progress"}
onClick={() => void setState(isDone ? "not-started" : "done")}>
<Check size={14} />{isDone ? "Done" : "Mark done"}
</Pill>
<span className="sep" aria-hidden="true" />
<Pill variant="primary" disabled={!lesson.next} title={lesson.next ? `Next: ${lesson.next.title}` : "Last lesson"} onClick={() => lesson.next && navigate(lessonHref(courseId, lesson.next.id))}>
<span>Next<span className="lbl-long"> lesson</span></span><Next />
</Pill>
</FloatingActionBar>
</CourseFrame>
);
}
/* ---------- /c/:courseId/notes ---------- */
export function NotesPage() {
const { courseId = "" } = useParams();
const { course, error } = useCourse(courseId);
const { user, loaded } = useMe();
const [look] = useLook();
const notesApi = useApi<Note[]>(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null);
useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]);
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
if (!course) return null;
const titles = new Map(course.modules.flatMap((m) => m.lessons.map((l) => [l.id, { title: l.title, module: m.title }] as const)));
const order = new Map(course.modules.flatMap((m) => m.lessons).map((l, i) => [l.id, i]));
const notes = notesApi.data ?? [];
const groups = new Map<string, Note[]>();
for (const n of [...notes].sort((a, b) => (order.get(a.lessonId) ?? 1e9) - (order.get(b.lessonId) ?? 1e9) || anchorNum(a) - anchorNum(b) || a.createdAt.localeCompare(b.createdAt))) {
if (!groups.has(n.lessonId)) groups.set(n.lessonId, []);
groups.get(n.lessonId)!.push(n);
}
const remove = async (id: string) => {
try {
await api(`/api/notes/${encodeURIComponent(id)}`, { method: "DELETE" });
notesApi.set(notes.filter((n) => n.id !== id));
} catch (e) { toast(`The note was not deleted: ${(e as Error).message}`); }
};
const update = async (id: string, body: string) => {
try {
const n = await api<Note>(`/api/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: { body } });
notesApi.set(notes.map((x) => (x.id === id ? n : x)));
} catch (e) { toast(`The note was not saved: ${(e as Error).message}`); }
};
return (
<CourseFrame course={course} currentLessonId={null} look={look} lookMode={null}>
<article className="col" data-look="work" style={{ paddingBottom: 60 }}>
<p className="chap">{course.title}</p>
<h1 className="title">Your notes</h1>
<p className="source">Notes on lessons, video moments and slides, in course order. Only you see them.</p>
{!loaded ? null : !user ? (
<div className="notice"><Link to="/account">Sign in</Link> to keep notes.</div>
) : notesApi.loading && !notesApi.data ? <p className="muted">Loading…</p>
: groups.size === 0 ? <div className="notice">No notes yet. Add them under a video, on a slide or at the end of a lesson.</div>
: [...groups].map(([lid, ns]) => (
<section key={lid} className="notes-group">
<h2><Link to={lessonHref(courseId, lid)}><TitleText text={titles.get(lid)?.title ?? lid} /></Link></h2>
{titles.get(lid) ? <p className="source" style={{ margin: 0 }}>{titles.get(lid)!.module}</p> : null}
<ul className="note-list">
{ns.map((n) => <NoteItem key={n.id} note={n} notes={{ update, remove }} courseId={courseId} extra={<AnchorLink note={n} />} />)}
</ul>
</section>
))}
</article>
</CourseFrame>
);
}
const anchorNum = (n: Note) => (n.anchorKind === "none" ? -1 : Number(n.anchorValue ?? 0));
function AnchorLink({ note }: { note: Note }) {
const base = lessonHref(note.courseId, note.lessonId);
if (note.anchorKind === "video" && note.anchorValue !== null) return <Link className="anchor" to={`${base}?t=${Math.floor(Number(note.anchorValue))}`}>Video {fmtTime(Number(note.anchorValue))}</Link>;
if (note.anchorKind === "slide" && note.anchorValue !== null && note.deckId) return <Link className="anchor" to={`${base}?deck=${encodeURIComponent(note.deckId)}&slide=${note.anchorValue}`}>Slide {note.anchorValue}</Link>;
return <span className="anchor">Lesson</span>;
}
@@ -0,0 +1,15 @@
const A = { fill: "none", stroke: "currentColor", "aria-hidden": true } as const;
export const Chev = () => <svg width="10" height="10" viewBox="0 0 10 10" strokeWidth="1.6" {...A}><path d="M3.5 2l3 3-3 3" /></svg>;
export const Down = () => <svg width="10" height="10" viewBox="0 0 10 10" strokeWidth="1.5" {...A}><path d="M2 3.5l3 3 3-3" /></svg>;
export const Check = ({ size = 12 }: { size?: number }) => <svg width={size} height={size} viewBox="0 0 16 16" strokeWidth="1.8" {...A}><path d="M3 8.5l3.2 3L13 4.5" /></svg>;
export const Lock = () => <svg width="11" height="11" viewBox="0 0 16 16" strokeWidth="1.5" {...A}><rect x="3" y="7" width="10" height="7.5" rx="1.5" /><path d="M5.5 7V5a2.5 2.5 0 015 0v2" /></svg>;
export const FileI = () => <svg width="13" height="13" viewBox="0 0 16 16" strokeWidth="1.3" {...A}><path d="M4 1.5h5.5L13 5v9.5H4z" /><path d="M9.5 1.5V5H13" /></svg>;
export const DirI = () => <svg width="13" height="13" viewBox="0 0 16 16" strokeWidth="1.3" {...A}><path d="M1.5 3.5h5l1.5 1.5h6.5v8.5h-13z" /></svg>;
export const PdfI = () => <svg width="14" height="14" viewBox="0 0 16 16" strokeWidth="1.3" {...A}><path d="M3.5 1.5h6L13 5v9.5H3.5z" /><path d="M5.5 9h5M5.5 11.5h3.5" /></svg>;
export const SearchI = () => <svg width="14" height="14" viewBox="0 0 16 16" strokeWidth="1.5" {...A}><circle cx="7" cy="7" r="4.5" /><path d="M10.5 10.5L14 14" /></svg>;
export const MenuI = () => <svg width="16" height="16" viewBox="0 0 16 16" strokeWidth="1.5" {...A}><path d="M2 4h12M2 8h12M2 12h8" /></svg>;
export const Prev = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.6" {...A}><path d="M7.5 2.5L4 6l3.5 3.5" /></svg>;
export const Next = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.6" {...A}><path d="M4.5 2.5L8 6 4.5 9.5" /></svg>;
export const Full = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.5" {...A}><path d="M1.5 4.5v-3h3M7.5 1.5h3v3M10.5 7.5v3h-3M4.5 10.5h-3v-3" /></svg>;
export const DownloadI = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.5" {...A}><path d="M6 1.5v6.5M3.2 5.5L6 8.2l2.8-2.7M1.5 10.5h9" /></svg>;
+17
View File
@@ -0,0 +1,17 @@
import type { WebFeature } from "@stc/contracts";
export { LessonView, type LessonViewProps } from "./lesson/LessonView";
export { ensureStyles as ensureLearnerStyles } from "./api";
/** Learner routes: library, course pages, lessons, notes and the account pages. */
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: "/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: "/account", element: () => import("./account/AccountPage") },
{ path: "/account/admin", element: () => import("./account/AdminPage") },
],
};
@@ -0,0 +1,155 @@
import { useEffect, useMemo, useState } from "react";
import { Pill } from "@stc/ui";
import type { CodeListing, FileRef } from "../../shared/types";
import { mediaUrl } from "../../shared/types";
import { copyText, fmtSize, useApi } from "../api";
import { DirI, DownloadI, FileI, PdfI } from "../icons";
const MAX_VIEW = 400 * 1024;
const BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|jar|class|exe|dll|so|dylib|bin|mp4|mp3|wav|woff2?|ttf|otf|sqlite|db|pyc)$/i;
export const zipUrl = (courseId: string, moduleId: string) => `/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}/zip`;
interface TreeNode { name: string; path: string; dirs: Map<string, TreeNode>; files: { name: string; path: string; size: number }[] }
export function buildTree(files: { path: string; size: number }[]): TreeNode {
const root: TreeNode = { name: "", path: "", dirs: new Map(), files: [] };
for (const f of files) {
const parts = f.path.split("/");
let node = root;
for (const p of parts.slice(0, -1)) {
if (!node.dirs.has(p)) node.dirs.set(p, { name: p, path: node.path ? `${node.path}/${p}` : p, dirs: new Map(), files: [] });
node = node.dirs.get(p)!;
}
node.files.push({ name: parts[parts.length - 1]!, path: f.path, size: f.size });
}
return root;
}
/** Picks the first file to show: a README or the entry point, else the first text file. */
function firstFile(files: { path: string; size: number }[]): string | null {
const text = files.filter((f) => !BINARY.test(f.path) && f.size <= MAX_VIEW);
const pref = [/(^|\/)dockerfile[^/]*$/i, /(^|\/)(main|index|app)\.[^/]+$/i, /(^|\/)readme(\.md)?$/i];
for (const r of pref) { const f = text.find((x) => r.test(x.path)); if (f) return f.path; }
return text[0]?.path ?? null;
}
/**
* Companion code for a section: file tree, tabs, line numbers, copy, the zip download and the command
* that runs it locally. Files are read through the media route, so visibility checks apply.
*/
export function CodeFiles({ courseId, moduleId, dir, title = "Files for this section" }: { courseId: string; moduleId: string; dir?: string; title?: string }) {
const { data: listing, error } = useApi<CodeListing>(`/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}`);
const [tabs, setTabs] = useState<string[]>([]);
const [current, setCurrent] = useState<string | null>(null);
const [content, setContent] = useState<Record<string, string>>({});
useEffect(() => {
if (!listing) return;
const f = firstFile(listing.files);
setTabs(f ? [f] : []); setCurrent(f); setContent({});
}, [listing]);
useEffect(() => {
if (!listing || !current || current in content) return;
const f = listing.files.find((x) => x.path === current);
if (!f) return;
if (BINARY.test(f.path) || f.size > MAX_VIEW) { setContent((c) => ({ ...c, [current]: "" })); return; }
let live = true;
fetch(mediaUrl(courseId, `${listing.dir}/${f.path}`), { credentials: "same-origin" })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((t) => live && setContent((c) => ({ ...c, [current]: t })), () => live && setContent((c) => ({ ...c, [current]: "\u0000error" })));
return () => { live = false; };
}, [listing, current, content, courseId]);
const tree = useMemo(() => buildTree(listing?.files ?? []), [listing]);
const folder = (listing?.dir ?? dir ?? "").split("/").filter(Boolean).pop() ?? moduleId;
const head = (
<div className="blockhead">
<h2>{title}</h2>
<a className="stc-pill sm" href={zipUrl(courseId, moduleId)} download={`${folder}.zip`}><DownloadI />Download files for this section (zip)</a>
</div>
);
if (error) return null;
if (!listing) return <section className="lr-block" aria-label={title}>{head}<p className="muted">Loading files…</p></section>;
const open = (p: string) => { setTabs((t) => (t.includes(p) ? t : [...t.slice(-5), p])); setCurrent(p); };
const file = listing.files.find((f) => f.path === current);
const text = current ? content[current] : undefined;
const lines = text && !text.startsWith("\u0000") ? text.replace(/\n$/, "").split("\n") : null;
return (
<section className="lr-block" aria-label={title}>
{head}
{listing.files.length === 0 ? <p className="muted">This folder is empty.</p> : (
<div className="files">
<nav className="tree" aria-label="Files">
<div className="dir"><DirI />{folder}</div>
<TreeLevel node={tree} depth={1} current={current} onOpen={open} />
{listing.truncated ? <div className="dir">Only the first {listing.files.length} files are listed.</div> : null}
</nav>
<div className="viewer">
<div className="tabs" role="tablist">
{tabs.map((t) => (
<button key={t} type="button" role="tab" aria-selected={t === current} onClick={() => setCurrent(t)}>{t.split("/").pop()}</button>
))}
{lines ? <CopyButton text={text!} label="Copy" /> : null}
</div>
{file ? <div className="path">{folder}/{file.path} · {fmtSize(file.size)}</div> : null}
{!file ? <p className="path">Pick a file on the left.</p>
: text === undefined ? <p className="path">Loading…</p>
: lines ? <pre className="code"><code>{lines.map((l, i) => <span key={i} className="ln">{l || " "}</span>)}</code></pre>
: <p className="path">{text === "" ? "This file is not shown here (binary or large). It is in the zip." : "This file could not be loaded."} <a href={mediaUrl(courseId, `${listing.dir}/${file.path}`)} download>Download it</a></p>}
</div>
</div>
)}
{listing.command ? <CommandBlock command={listing.command} intro={`From the folder you unzipped (${folder}):`} /> : null}
</section>
);
}
function TreeLevel({ node, depth, current, onOpen }: { node: TreeNode; depth: number; current: string | null; onOpen: (p: string) => void }) {
const pad = { paddingLeft: 12 + depth * 14 };
return (
<>
{[...node.dirs.values()].map((d) => (
<div key={d.path}>
<div className="dir" style={pad}><DirI />{d.name}</div>
<TreeLevel node={d} depth={depth + 1} current={current} onOpen={onOpen} />
</div>
))}
{node.files.map((f) => (
<button key={f.path} type="button" style={pad} aria-current={f.path === current} onClick={() => onOpen(f.path)}><FileI />{f.name}</button>
))}
</>
);
}
export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<Pill className="sm" variant="ghost" onClick={async () => { if (await copyText(text)) { setDone(true); setTimeout(() => setDone(false), 1400); } }}>
{done ? "Copied" : label}
</Pill>
);
}
export function CommandBlock({ command, intro }: { command: string; intro?: string }) {
return (
<div style={{ marginTop: 18 }}>
{intro ? <p className="muted" style={{ fontSize: 13, margin: "0 0 8px" }}>{intro}</p> : null}
<div className="cmd"><pre className="code"><code>{command}</code></pre><CopyButton text={command} /></div>
</div>
);
}
export function Attachments({ files, title = "Attachments", note }: { files: FileRef[]; title?: string; note?: string }) {
if (!files.length) return null;
return (
<section className="lr-block" aria-label={title}>
<div className="blockhead"><h2>{title}</h2>{note ? <span>{note}</span> : null}</div>
<ul className="rows">
{files.map((f) => (
<li key={f.file}><PdfI /><a href={f.url} download={f.file.split("/").pop()}>{f.title}</a><span className="sz tnum">{fmtSize(f.size)}</span></li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pill } from "@stc/ui";
import type { DeckRef } from "../../shared/types";
import { Full, Next, Prev } from "../icons";
import type { NotesApi } from "./notes";
/** The pdf.js worker is served by the learner API from the installed pdfjs-dist. */
export const PDF_WORKER_URL = "/api/learner/pdf.worker.mjs";
// biome-ignore lint/suspicious/noExplicitAny: pdf.js document proxy (types are loaded lazily with the library)
type PdfDoc = any;
let pdfjsPromise: Promise<typeof import("pdfjs-dist")> | null = null;
function loadPdfjs() {
pdfjsPromise ??= import("pdfjs-dist").then((m) => { m.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL; return m; });
return pdfjsPromise;
}
/** Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). */
async function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number) {
const p = await doc.getPage(page);
const base = p.getViewport({ scale: 1 });
let scale = cssWidth / base.width;
if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const vp = p.getViewport({ scale: scale * dpr });
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
canvas.style.width = `${Math.floor(vp.width / dpr)}px`;
canvas.style.height = `${Math.floor(vp.height / dpr)}px`;
const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas });
await task.promise;
}
const isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']");
/**
* Slide deck viewer: 16:9 stage, previous/next, page counter, thumbnails, fullscreen, arrow keys (when
* `keyboard` is set) and a note per slide. Pages are rendered in the browser with pdf.js.
*/
export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, title }: {
deck: DeckRef; notes: NotesApi | null; initialPage?: number; keyboard?: boolean; title?: string;
}) {
const [doc, setDoc] = useState<PdfDoc | null>(null);
const [pages, setPages] = useState(0);
const [page, setPage] = useState(initialPage);
const [error, setError] = useState<string | null>(null);
const stage = useRef<HTMLElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let live = true, loaded: PdfDoc | null = null;
setDoc(null); setError(null); setPages(0);
loadPdfjs()
.then((pdfjs) => pdfjs.getDocument({ url: deck.url, withCredentials: true }).promise)
.then((d: PdfDoc) => {
loaded = d;
if (!live) { void d.destroy(); return; }
setDoc(d); setPages(d.numPages); setPage((p) => Math.min(Math.max(1, p), d.numPages));
})
.catch((e: unknown) => live && setError(`This deck could not be opened. ${(e as Error).message ?? ""}`.trim()));
return () => { live = false; if (loaded) void loaded.destroy(); };
}, [deck.url]);
// Render the current page to fit the stage.
useEffect(() => {
if (!doc || !canvas.current || !stage.current) return;
let live = true;
const draw = () => {
const r = stage.current!.getBoundingClientRect();
if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {});
};
draw();
const ro = new ResizeObserver(() => draw());
ro.observe(stage.current);
return () => { live = false; ro.disconnect(); };
}, [doc, page]);
const go = useCallback((p: number) => setPage((cur) => (pages ? Math.min(Math.max(1, p), pages) : cur)), [pages]);
useEffect(() => {
if (!keyboard) return;
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return;
if (e.key === "ArrowRight") { e.preventDefault(); setPage((p) => Math.min(p + 1, pages || p)); }
else if (e.key === "ArrowLeft") { e.preventDefault(); setPage((p) => Math.max(p - 1, 1)); }
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [keyboard, pages]);
useEffect(() => {
document.querySelector(`[data-deck="${CSS.escape(deck.id)}"] .thumbs [aria-current="true"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [page, deck.id]);
const fullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
else stage.current?.requestFullscreen?.().catch(() => {});
};
const slideNotes = notes?.notes.filter((n) => n.anchorKind === "slide" && n.deckId === deck.id) ?? [];
const withNote = new Set(slideNotes.map((n) => Number(n.anchorValue)));
return (
<section className="deck" data-deck={deck.id} aria-label={`Slides: ${title ?? deck.title}`}>
<figure className="stage" ref={stage}>
{error ? <span className="msg">{error} <a href={deck.url} target="_blank" rel="noopener">Open the PDF</a></span> : null}
{!error && !doc ? <span className="msg">Loading slides…</span> : null}
<canvas ref={canvas} hidden={!doc || !!error} role="img" aria-label={`Slide ${page} of ${pages}`} />
</figure>
<div className="deckbar">
<Pill className="sm" aria-label="Previous slide" disabled={page <= 1} onClick={() => go(page - 1)}><Prev /></Pill>
<span className="count tnum" aria-live="polite">{pages ? `${page} / ${pages}` : "– / –"}</span>
<Pill className="sm" aria-label="Next slide" disabled={!pages || page >= pages} onClick={() => go(page + 1)}><Next /></Pill>
<span className="gap" />
{keyboard ? <span className="hint">Arrow keys change slides</span> : null}
<a className="stc-pill stc-pill--ghost sm" href={deck.url} target="_blank" rel="noopener">PDF</a>
<Pill className="sm" onClick={fullscreen}><Full />Fullscreen</Pill>
</div>
{doc ? <Thumbs doc={doc} pages={pages} page={page} onPick={go} withNote={withNote} /> : null}
{notes ? <SlideNote notes={notes} deckId={deck.id} page={page} existing={slideNotes.filter((n) => Number(n.anchorValue) === page)} /> : null}
</section>
);
}
function Thumbs({ doc, pages, page, onPick, withNote }: { doc: PdfDoc; pages: number; page: number; onPick: (p: number) => void; withNote: Set<number> }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = ref.current;
if (!root) return;
const queue: HTMLCanvasElement[] = [];
let running = false, live = true;
const pump = async () => {
if (running) return;
running = true;
while (live && queue.length) {
const c = queue.shift()!;
await renderPage(doc, Number(c.dataset.p), c, 104).catch(() => {});
}
running = false;
};
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const c = e.target as HTMLCanvasElement;
io.unobserve(c);
queue.push(c);
}
void pump();
}, { root, rootMargin: "0px 400px" });
root.querySelectorAll("canvas").forEach((c) => io.observe(c));
return () => { live = false; io.disconnect(); };
}, [doc, pages]);
return (
<div className="thumbs" ref={ref} role="group" aria-label="Slides">
{Array.from({ length: pages }, (_, i) => (
<button key={i} type="button" aria-label={`Slide ${i + 1}${withNote.has(i + 1) ? ", has a note" : ""}`} aria-current={page === i + 1} className={withNote.has(i + 1) ? "has-note" : undefined} onClick={() => onPick(i + 1)}>
<canvas data-p={i + 1} />
</button>
))}
</div>
);
}
function SlideNote({ notes, deckId, page, existing }: { notes: NotesApi; deckId: string; page: number; existing: { id: string; body: string }[] }) {
const first = existing[0];
const [text, setText] = useState(first?.body ?? "");
const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle");
const busy = useRef(false);
useEffect(() => { setText(first?.body ?? ""); setSaved("idle"); }, [page, deckId, first?.id, first?.body]);
if (!notes.enabled) return null;
const dirty = text.trim() !== (first?.body ?? "").trim();
const save = async () => {
if (!dirty || busy.current) return;
busy.current = true;
setSaved("saving");
const body = text.trim();
try {
if (first && !body) await notes.remove(first.id);
else if (first) await notes.update(first.id, body);
else if (body) await notes.create({ anchorKind: "slide", anchorValue: page, deckId, body });
setSaved("saved");
} finally { busy.current = false; }
};
return (
<div className="slide-note">
<label htmlFor={`sn-${deckId}`}>Your note on slide {page}</label>
<textarea id={`sn-${deckId}`} className="note-ta" value={text} onChange={(e) => { setText(e.target.value); setSaved("idle"); }} onBlur={save}
placeholder="Only you see this. It comes back when you return to this slide." />
<div className="note-actions">
<span className="msg">{saved === "saving" ? "Saving…" : saved === "saved" ? "Saved" : ""}</span>
{existing.length > 1 ? <span className="msg">{existing.length - 1} more on this slide in your notes list</span> : null}
<Pill className="sm" disabled={!dirty} onClick={save}>Save note</Pill>
</div>
</div>
);
}
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import { Pill } from "@stc/ui";
import type { ExerciseRunner, TestResult } from "@stc/contracts";
import type { LessonPayload } from "../../shared/types";
import { api, toast } from "../api";
import { createBrowserRunner } from "../runner/runner";
import { CommandBlock } from "./CodeFiles";
import { Chev } from "../icons";
export const RUN_TIMEOUT_MS = 3000;
/** Text Enter inserts at `pos`: a newline plus the indentation of the line the cursor is on. */
export function newlineWithIndent(value: string, pos: number): string {
const lineStart = value.lastIndexOf("\n", pos - 1) + 1;
return "\n" + /^[ \t]*/.exec(value.slice(lineStart, pos))![0];
}
export function localCommandFor(cmd: LessonPayload["localCommand"], track: string | null): string | null {
if (!cmd) return null;
if (typeof cmd === "string") return cmd;
return (track && cmd[track]) || Object.values(cmd)[0] || null;
}
const attemptUrl = (l: LessonPayload, track: string) =>
`/api/courses/${encodeURIComponent(l.courseId)}/lessons/${encodeURIComponent(l.lesson.id)}/attempt?track=${encodeURIComponent(track)}`;
/**
* The right-hand side of an exercise page. With a browser exercise: editor, Run tests, Reset and per-test
* results (run in a Web Worker). Without one: the local command, plus optional pasted evidence.
*/
export function ExercisePanel({ lesson, track, signedIn, preview, onSaved, runner }: {
lesson: LessonPayload;
track: string | null;
signedIn: boolean;
preview?: boolean;
onSaved?: () => void;
runner?: ExerciseRunner;
}) {
if (lesson.browser) return <BrowserExercise lesson={lesson} track={track} save={signedIn && !preview} onSaved={onSaved} runner={runner} />;
return <LocalExercise lesson={lesson} track={track} save={signedIn && !preview} onSaved={onSaved} />;
}
function BrowserExercise({ lesson, track, save, onSaved, runner }: {
lesson: LessonPayload; track: string | null; save: boolean; onSaved?: () => void; runner?: ExerciseRunner;
}) {
const b = lesson.browser!;
const key = b.language;
const [code, setCode] = useState(b.starter);
const [results, setResults] = useState<TestResult[] | null>(null);
const [running, setRunning] = useState(false);
const ta = useRef<HTMLTextAreaElement>(null);
const gutter = useRef<HTMLPreElement>(null);
useEffect(() => {
setCode(b.starter); setResults(null);
if (!save) return;
let live = true;
api<{ code: string; result: TestResult[] | null } | null>(attemptUrl(lesson, key)).then((a) => {
if (!live || !a) return;
setCode(a.code);
if (Array.isArray(a.result)) setResults(a.result);
}, () => {});
return () => { live = false; };
}, [lesson.courseId, lesson.lesson.id, b.starter, key, save]);
const run = async () => {
setRunning(true);
try {
const r = runner ?? createBrowserRunner(b.language);
const res = await r.run(code, b.tests, RUN_TIMEOUT_MS);
setResults(res);
const passed = res.length > 0 && res.every((t) => t.passed);
if (save) {
try {
await api(attemptUrl(lesson, key).split("?")[0]!, { method: "PUT", body: { track: key, code, result: res, passed } });
onSaved?.();
} catch (e) { toast(`Your code was not saved: ${(e as Error).message}`); }
}
} finally { setRunning(false); }
};
const reset = () => {
if (code !== b.starter && !confirm("Reset the editor to the starting code? Your changes are lost.")) return;
setCode(b.starter); setResults(null); toast("Code reset to the starting code");
};
const lines = code.split("\n").length;
const n = results?.filter((t) => t.passed).length ?? 0;
const all = !!results && results.length > 0 && n === results.length;
return (
<div className="edit">
<div className="edit-bar">
<span className="f">{b.starterFile}</span>
<Pill className="sm" variant="ghost" onClick={reset}>Reset</Pill>
<Pill className="sm" variant="primary" onClick={run} disabled={running}>{running ? "Running…" : "Run tests"}</Pill>
</div>
<div className="editor">
<pre className="gutter" ref={gutter} aria-hidden="true">{Array.from({ length: lines }, (_, i) => i + 1).join("\n")}</pre>
<textarea
ref={ta} value={code} spellCheck={false} aria-label={`Code editor, ${b.starterFile}`}
onChange={(e) => setCode(e.target.value)}
onScroll={(e) => { if (gutter.current) gutter.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop; }}
onKeyDown={(e) => {
// Tab and Shift+Tab move focus as everywhere else (spec 4.1). Enter keeps the previous line's indentation.
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || e.nativeEvent.isComposing) return;
const t = e.currentTarget;
const insert = newlineWithIndent(t.value, t.selectionStart);
if (insert === "\n") return;
e.preventDefault();
t.setRangeText(insert, t.selectionStart, t.selectionEnd, "end");
setCode(t.value);
}}
/>
</div>
<div className="results" aria-live="polite">
{!results ? (
<div className="res-sum"><span>Not run yet. The tests run in your browser, in a sandboxed worker.</span></div>
) : (
<>
<div className="res-sum"><span><b className="tnum">{n} of {results.length}</b> passed{all ? ". This exercise is done." : ""}</span></div>
<ul className="res-list">
{results.map((t, i) => (
<li key={i} className={t.passed ? "pass" : "fail"}>
<div className="res-row"><span className="s">{t.passed ? "Pass" : "Fail"}</span><code>{t.name}</code></div>
{t.passed ? null : (
<dl className="diff">
{t.expected !== undefined ? <><dt>Expected</dt><dd>{t.expected}</dd></> : null}
{t.actual !== undefined ? <><dt>Actual</dt><dd>{t.actual}</dd></> : null}
{t.message && t.expected === undefined ? <><dt>Error</dt><dd>{t.message}</dd></> : null}
</dl>
)}
</li>
))}
</ul>
</>
)}
</div>
{b.solution ? (
<details className="fold" style={{ margin: 0, borderBottom: 0, padding: "0 14px" }}>
<summary><Chev /><span className="sum">Solution (only authors see this)</span></summary>
<div className="in"><pre className="code"><code>{b.solution}</code></pre></div>
</details>
) : null}
</div>
);
}
function LocalExercise({ lesson, track, save, onSaved }: { lesson: LessonPayload; track: string | null; save: boolean; onSaved?: () => void }) {
const cmd = localCommandFor(lesson.localCommand, track);
const key = track ?? "default";
const [evidence, setEvidence] = useState("");
const [saved, setSaved] = useState(false);
useEffect(() => {
setEvidence(""); setSaved(false);
if (!save) return;
let live = true;
api<{ code: string } | null>(attemptUrl(lesson, key)).then((a) => { if (live && a) { setEvidence(a.code); setSaved(true); } }, () => {});
return () => { live = false; };
}, [lesson.courseId, lesson.lesson.id, key, save]);
const submit = async () => {
try {
// Pasted passing output counts as a pass: the lesson is marked done.
await api(attemptUrl(lesson, key).split("?")[0]!, { method: "PUT", body: { track: key, code: evidence, result: { kind: "evidence" }, passed: true } });
setSaved(true);
onSaved?.();
toast("Output saved. The exercise is marked done.");
} catch (e) { toast(`Not saved: ${(e as Error).message}`); }
};
return (
<div className="local-panel">
<h2>Run it on your machine</h2>
<p>This exercise runs locally, in the course’s own project. Run the tests from the course folder; when they pass, mark the exercise done.</p>
{cmd ? <CommandBlock command={cmd} /> : <p>The course does not name a command for this exercise. Its instructions on the left say how to check your work.</p>}
{save ? (
<div style={{ marginTop: 18 }}>
<label className="muted" htmlFor="lr-evidence" style={{ display: "block", fontSize: 12.5, marginBottom: 6 }}>Passing output (optional). Paste it here to keep it with your progress.</label>
<textarea id="lr-evidence" className="note-ta" value={evidence} onChange={(e) => { setEvidence(e.target.value); setSaved(false); }} placeholder="# tests 5 / # pass 5" />
<div className="note-actions">
<span className="msg">{saved && evidence ? "Saved" : ""}</span>
<Pill className="sm" disabled={!evidence.trim() || saved} onClick={submit}>Save output and mark done</Pill>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,260 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { SegmentedToggle } from "@stc/ui";
import type { ExerciseRunner } from "@stc/contracts";
import type { LessonPayload, LessonProgress, ProgressState } from "../../shared/types";
import { ensureStyles, fmtMinutes, fmtTime, type Look } from "../api";
import { renderHintsHtml, renderLessonHtml } from "../markdown";
import { Attachments, CodeFiles, CommandBlock, zipUrl } from "./CodeFiles";
import { DeckViewer } from "./DeckViewer";
import { ExercisePanel, localCommandFor } from "./Exercise";
import { NotesPanel, useLessonNotes, type NotesApi } from "./notes";
import { Prose } from "./Prose";
import { DownloadI } from "../icons";
import { TitleText } from "./TitleText";
export interface LessonViewProps {
lesson: LessonPayload;
/** Reading or Work. Exercise pages always use Work. */
look?: Look;
track?: string | null;
onTrackChange?: (track: string) => void;
progress?: LessonProgress | null;
/** Signed-in learners get notes and saved attempts. */
signedIn?: boolean;
/** Studio preview: no notes, no saved attempts, no progress reports. */
preview?: boolean;
onProgress?: (state: ProgressState, positionSeconds?: number) => void;
/** An exercise attempt was saved (the server already updated progress). */
onSaved?: () => void;
/** Start the video here (seconds), e.g. from a timestamp note. */
initialTime?: number | null;
/** Open a deck at this page, e.g. from a slide note. */
initialSlide?: { deckId: string; page: number } | null;
/** Replaces the Web Worker runner (tests). */
runner?: ExerciseRunner;
/** Label for the module line above the title, e.g. "Module 3". */
moduleLabel?: string;
}
export { TitleText };
/**
* Renders one lesson of any type from its `LessonPayload`: text (track switcher, folds, hint tiers),
* video (player, notes with timestamps, transcript, companion deck), exercise (instructions, hints,
* browser runner or local command), plus decks, code files and attachments.
* Presentational: it fetches only notes, saved attempts and code listings. The studio uses it for previews.
*/
export function LessonView(props: LessonViewProps) {
ensureStyles();
const { lesson, preview = false, signedIn = false } = props;
const notes = useLessonNotes(lesson.courseId, lesson.lesson.id, signedIn && !preview && !lesson.locked);
const look: Look = lesson.lesson.type === "exercise" ? "work" : props.look ?? "reading";
const track = props.track ?? lesson.tracks[0]?.id ?? null;
const chap = props.moduleLabel ? `${props.moduleLabel}: ${lesson.module.title}` : lesson.module.title;
let body: ReactNode;
if (lesson.locked) body = <Locked lesson={lesson} chap={chap} />;
else if (lesson.lesson.type === "video") body = <VideoLesson {...props} chap={chap} notes={notes} />;
else if (lesson.lesson.type === "exercise") body = <ExerciseLesson {...props} chap={chap} track={track} />;
else body = <TextLesson {...props} chap={chap} track={track} notes={notes} />;
return <div className="lr lr-lesson" data-look={look} data-type={lesson.lesson.type}>{body}</div>;
}
function Locked({ lesson, chap }: { lesson: LessonPayload; chap: string }) {
return (
<article className="col">
<p className="chap">{chap}</p>
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
<div className="notice" role="note">
<strong>This module is locked.</strong> It opens when every lesson in the modules before it is done.
{lesson.prev ? <div><Link className="stc-pill" to={`/c/${encodeURIComponent(lesson.courseId)}/${encodeURIComponent(lesson.prev.id)}`}>Back to {lesson.prev.title}</Link></div> : null}
</div>
</article>
);
}
function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track: string | null; onChange?: (t: string) => void }) {
if (lesson.tracks.length < 2 || !track) return null;
return (
<div>
<dt>Track</dt>
<dd><SegmentedToggle label="Track" value={track} options={lesson.tracks.map((t) => ({ value: t.id, label: t.label }))} onChange={(v) => onChange?.(v)} /></dd>
</div>
);
}
function useLessonHtml(lesson: LessonPayload, track: string | null): 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]);
}
function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) {
if (!lesson.decks.length) return null;
return (
<>
{lesson.decks.map((d, i) => (
<div key={d.id} className="lr-block">
{lesson.decks.length > 1 || lesson.lesson.type !== "text" ? <div className="blockhead"><h2>{d.title}</h2><span>Slides</span></div> : null}
<DeckViewer deck={d} notes={notes} keyboard={keyboard && i === 0} initialPage={initialSlide?.deckId === d.id ? initialSlide.page : 1} />
</div>
))}
</>
);
}
function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const cmd = localCommandFor(lesson.localCommand, track);
const minutes = lesson.lesson.minutes;
const hasMeta = !!minutes || lesson.tracks.length > 1;
const decks = <Decks lesson={lesson} notes={p.preview ? null : p.notes} initialSlide={p.initialSlide} keyboard />;
return (
<article className="wide">
<div className="col">
<p className="chap">{p.chap}</p>
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
{hasMeta ? (
<dl className="meta">
{minutes ? <div><dt>Time</dt><dd>About {fmtMinutes(minutes)}</dd></div> : null}
<TrackSwitch lesson={lesson} track={track} onChange={p.onTrackChange} />
</dl>
) : null}
{cmd ? <CommandBlock command={cmd} intro="Start this module on your machine:" /> : null}
</div>
{!html ? decks : null}
{html ? <div className="col"><Prose html={html} style={{ marginTop: 30 }} /></div> : null}
{!html && !lesson.decks.length ? <div className="col"><p className="muted" style={{ marginTop: 30 }}>This lesson has no text yet.</p></div> : null}
{html ? decks : null}
{lesson.code ? <CodeFiles courseId={lesson.courseId} moduleId={lesson.code.moduleId} dir={lesson.code.dir} /> : null}
<div className="col">
<Attachments files={lesson.attachments} note="This section" />
{!p.preview ? <div className="lr-block"><NotesPanel notes={p.notes} /></div> : null}
</div>
</article>
);
}
function VideoLesson(p: LessonViewProps & { chap: string; notes: NotesApi }) {
const { lesson } = p;
const v = useRef<HTMLVideoElement>(null);
const [browse, setBrowse] = useState(false);
const transcriptHtml = useMemo(() => (lesson.transcript
? renderLessonHtml(lesson.transcript, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, lessonFiles: lesson.lessonFiles, stripFirstHeading: true })
: null), [lesson]);
const control = useMemo(() => ({
currentTime: () => v.current?.currentTime ?? 0,
seek: (s: number) => { const el = v.current; if (el) { el.currentTime = s; void el.play().catch(() => {}); el.scrollIntoView({ block: "nearest", behavior: "smooth" }); } },
}), []);
useVideoProgress(v, p);
const dur = lesson.video?.duration ?? (lesson.lesson.minutes ? lesson.lesson.minutes * 60 : null);
const folder = lesson.code?.dir.split("/").filter(Boolean).pop();
return (
<article className="wide">
<p className="chap">{p.chap}</p>
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
<p className="source"><strong>Video</strong>{dur ? `, ${fmtTime(dur)}` : ""}.{p.progress?.positionSeconds && p.progress.state !== "done" ? ` You stopped at ${fmtTime(p.progress.positionSeconds)}.` : ""}</p>
{lesson.video ? (
<div className="player"><video ref={v} controls preload="metadata" src={lesson.video.url} /></div>
) : <div className="notice">The video file for this lesson is missing.</div>}
<Decks lesson={lesson} notes={p.preview ? null : p.notes} initialSlide={p.initialSlide} keyboard />
<div className="split">
<div>
{!p.preview ? <NotesPanel notes={p.notes} video={lesson.video ? control : null} /> : null}
{transcriptHtml ? (
<details className="fold transcript" open={!!p.preview}>
<summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="M3.5 2l3 3-3 3" /></svg><span className="sum">Transcript</span></summary>
<div className="in"><Prose html={transcriptHtml} style={{ fontSize: "calc(var(--ps) * .9)" }} /></div>
</details>
) : null}
</div>
<aside>
<div className="blockhead"><h2>Files for this section</h2><span>{lesson.module.title}</span></div>
{lesson.attachments.length || lesson.code ? (
<ul className="rows">
{lesson.code ? (
<li><DownloadI /><a href={zipUrl(lesson.courseId, lesson.code.moduleId)} download={`${folder}.zip`}>{folder}.zip</a><button type="button" className="stc-pill stc-pill--ghost sm" onClick={() => setBrowse(!browse)} aria-expanded={browse}>{browse ? "Hide" : "Browse"}</button></li>
) : null}
{lesson.attachments.map((f) => (
<li key={f.file}><DownloadI /><a href={f.url} download={f.file.split("/").pop()}>{f.title}</a><span className="sz tnum">{f.size ? `${Math.max(1, Math.round(f.size / 1024))} KB` : ""}</span></li>
))}
</ul>
) : <p className="source" style={{ marginTop: 0 }}>This section has no files.</p>}
</aside>
</div>
{browse && lesson.code ? <CodeFiles courseId={lesson.courseId} moduleId={lesson.code.moduleId} dir={lesson.code.dir} title="Code for this section" /> : null}
</article>
);
}
/** Resume position, periodic position reports, and "done" near the end (spec 4.4). */
function useVideoProgress(v: React.RefObject<HTMLVideoElement | null>, p: LessonViewProps) {
const report = useRef(p.onProgress);
report.current = p.onProgress;
const doneRef = useRef(p.progress?.state === "done");
doneRef.current = p.progress?.state === "done";
const start = p.initialTime ?? (p.progress && p.progress.state !== "done" ? p.progress.positionSeconds : null);
useEffect(() => {
const el = v.current;
if (!el || p.preview) return;
let lastSent = -1;
const onMeta = () => { if (start && start > 0 && (!el.duration || start < el.duration - 2)) el.currentTime = start; };
const send = () => {
if (!el.duration || doneRef.current) return;
const t = el.currentTime;
if (t >= el.duration * 0.95 || el.duration - t < 10) { doneRef.current = true; report.current?.("done", t); return; }
if (Math.abs(t - lastSent) >= 5) { lastSent = t; report.current?.("in-progress", t); }
};
let tick = 0;
const onTime = () => { if (++tick % 40 === 0 || (el.duration && el.duration - el.currentTime < 10)) send(); };
if (el.readyState >= 1) onMeta(); else el.addEventListener("loadedmetadata", onMeta, { once: true });
el.addEventListener("pause", send);
el.addEventListener("ended", send);
el.addEventListener("timeupdate", onTime);
return () => {
el.removeEventListener("loadedmetadata", onMeta);
el.removeEventListener("pause", send);
el.removeEventListener("ended", send);
el.removeEventListener("timeupdate", onTime);
if (el.currentTime > 0 && !el.ended) send();
};
}, [p.lesson.courseId, p.lesson.lesson.id, p.preview, start, v]);
}
function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const hintsHtml = useMemo(() => (lesson.hints.length
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles })
: null), [lesson, track]);
const cmd = localCommandFor(lesson.localCommand, track);
return (
<article className="wide">
<div className="ex">
<div>
<p className="chap">{p.chap}</p>
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
{lesson.tracks.length > 1 && !lesson.browser ? <dl className="meta" style={{ paddingBottom: 16 }}><TrackSwitch lesson={lesson} track={track} onChange={p.onTrackChange} /></dl> : null}
{html ? <Prose html={html} style={{ marginTop: 20 }} /> : <p className="muted" style={{ marginTop: 20 }}>The instructions for this exercise are missing.</p>}
{hintsHtml ? (
<div className="hints">
<h2 className="sec-h" style={{ marginTop: 26 }}>Hints</h2>
<Prose html={hintsHtml} />
</div>
) : null}
{lesson.browser && cmd ? (
<p className="local"><strong>Runs in your browser; the full project runs on your machine.</strong> To run the whole module locally:<code>{cmd}</code></p>
) : null}
</div>
<ExercisePanel lesson={lesson} track={track} signedIn={!!p.signedIn} preview={p.preview} runner={p.runner}
onSaved={p.onSaved} />
</div>
<Attachments files={lesson.attachments} note="This section" />
</article>
);
}
@@ -0,0 +1,72 @@
import { useEffect, useRef, type CSSProperties } from "react";
import { useInRouterContext, useNavigate } from "react-router-dom";
/** Router navigation when a router is present (the studio may embed LessonView without one). */
export function useSafeNavigate(): ((to: string) => void) | null {
const inRouter = useInRouterContext();
// The router context of a mounted tree never changes, so the hook order is stable.
// eslint-disable-next-line react-hooks/rules-of-hooks
return inRouter ? useNavigate() : null;
}
/**
* Sets up hint tiers inside `root`: later tiers of a group wait (`data-wait`) until the previous tier was opened.
* Returns a cleanup function.
*/
export function wireHintTiers(root: HTMLElement): () => void {
const tiers = (group: string) => Array.from(root.querySelectorAll<HTMLDetailsElement>(`details.fold[data-group="${CSS.escape(group)}"]`));
for (const d of root.querySelectorAll<HTMLDetailsElement>("details.fold[data-tier]")) {
if (Number(d.dataset.tier) > 1 && !d.dataset.seen) {
d.dataset.wait = "1";
const sum = d.querySelector("summary");
sum?.setAttribute("title", `Open hint ${Number(d.dataset.tier) - 1} first`);
sum?.setAttribute("aria-disabled", "true");
}
}
const onClick = (e: MouseEvent) => {
const sum = (e.target as Element).closest?.("summary");
const d = sum?.parentElement as HTMLDetailsElement | null;
if (!d || !root.contains(d) || !d.matches("details.fold[data-wait]")) return;
e.preventDefault();
};
const onToggle = (e: Event) => {
const d = e.target as HTMLDetailsElement;
if (!d.matches?.("details.fold[data-group]") || !d.open) return;
if (d.dataset.wait) { d.open = false; return; }
d.dataset.seen = "1";
const next = tiers(d.dataset.group!).find((x) => Number(x.dataset.tier) === Number(d.dataset.tier) + 1);
if (next && next.dataset.wait) {
delete next.dataset.wait;
const sum = next.querySelector("summary");
sum?.removeAttribute("title");
sum?.removeAttribute("aria-disabled");
}
};
root.addEventListener("click", onClick);
root.addEventListener("toggle", onToggle, true);
return () => { root.removeEventListener("click", onClick); root.removeEventListener("toggle", onToggle, true); };
}
/** Sanitized lesson HTML (from `renderLessonHtml`) with hint tiers and in-app links. */
export function Prose({ html, className = "prose", style }: { html: string; className?: string; style?: CSSProperties }) {
const ref = useRef<HTMLDivElement>(null);
const navigate = useSafeNavigate();
useEffect(() => {
const root = ref.current;
if (!root) return;
const off = wireHintTiers(root);
const onLink = (e: MouseEvent) => {
const a = (e.target as Element).closest?.("a");
if (!a || !navigate || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const href = a.getAttribute("href") ?? "";
if (href.startsWith("/c/")) { e.preventDefault(); navigate(href); }
else if (href.startsWith("#")) {
const el = document.getElementById(decodeURIComponent(href.slice(1)));
if (el) { e.preventDefault(); el.scrollIntoView({ behavior: "smooth", block: "start" }); }
}
};
root.addEventListener("click", onLink);
return () => { off(); root.removeEventListener("click", onLink); };
}, [html, navigate]);
return <div ref={ref} className={className} style={style} dangerouslySetInnerHTML={{ __html: html }} />;
}
@@ -0,0 +1,7 @@
/** Inline `code` spans in titles such as "Exercise 1.1 — `range`". */
export function TitleText({ text }: { text: string }) {
const parts = text.split(/(`[^`]+`)/g);
return <>{parts.map((p, i) => (p.startsWith("`") && p.endsWith("`") && p.length > 2 ? <code key={i}>{p.slice(1, -1)}</code> : p))}</>;
}
export const plainTitle = (t: string) => t.replace(/`/g, "");
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { Pill } from "@stc/ui";
import type { Note } from "../../shared/types";
import { api, fmtTime, lessonHref, toast } from "../api";
export interface NotesApi {
enabled: boolean;
notes: Note[];
create(n: { anchorKind: Note["anchorKind"]; anchorValue?: number | null; deckId?: string | null; body: string }): Promise<Note | null>;
update(id: string, body: string): Promise<void>;
remove(id: string): Promise<void>;
}
/** The signed-in user's notes for one lesson. Disabled (no requests) when signed out or in a preview. */
export function useLessonNotes(courseId: string, lessonId: string, enabled: boolean): NotesApi {
const [notes, setNotes] = useState<Note[]>([]);
useEffect(() => {
setNotes([]);
if (!enabled) return;
let live = true;
api<Note[]>(`/api/courses/${encodeURIComponent(courseId)}/notes?lesson=${encodeURIComponent(lessonId)}`).then((n) => live && setNotes(n), () => {});
return () => { live = false; };
}, [courseId, lessonId, enabled]);
const create = useCallback<NotesApi["create"]>(async (n) => {
try {
const note = await api<Note>(`/api/courses/${encodeURIComponent(courseId)}/notes`, { method: "POST", body: { lessonId, ...n } });
setNotes((xs) => [...xs, note]);
return note;
} catch (e) { toast(`The note was not saved: ${(e as Error).message}`); return null; }
}, [courseId, lessonId]);
const update = useCallback<NotesApi["update"]>(async (id, body) => {
try {
const note = await api<Note>(`/api/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: { body } });
setNotes((xs) => xs.map((x) => (x.id === id ? note : x)));
} catch (e) { toast(`The note was not saved: ${(e as Error).message}`); }
}, []);
const remove = useCallback<NotesApi["remove"]>(async (id) => {
try {
await api(`/api/notes/${encodeURIComponent(id)}`, { method: "DELETE" });
setNotes((xs) => xs.filter((x) => x.id !== id));
} catch (e) { toast(`The note was not deleted: ${(e as Error).message}`); }
}, []);
return { enabled, notes, create, update, remove };
}
const STAMP = /\[(?:(\d+):)?(\d{1,2}):(\d{2})\]/g;
export function parseStamp(h: string | undefined, m: string, s: string): number {
return (h ? Number(h) * 3600 : 0) + Number(m) * 60 + Number(s);
}
/** First "[m:ss]" timestamp in a note body, in seconds. */
export function firstStamp(body: string): number | null {
const m = new RegExp(STAMP.source).exec(body);
return m ? parseStamp(m[1], m[2]!, m[3]!) : null;
}
/** Note text with "[m:ss]" timestamps turned into seek buttons (or links to the lesson at that time). */
export function NoteBody({ body, onSeek, courseId, lessonId }: { body: string; onSeek?: (s: number) => void; courseId?: string; lessonId?: string }) {
const parts: ReactNode[] = [];
let last = 0;
for (const m of body.matchAll(STAMP)) {
const t = parseStamp(m[1], m[2]!, m[3]!);
parts.push(body.slice(last, m.index));
parts.push(onSeek
? <button key={m.index} type="button" className="stamp" onClick={() => onSeek(t)} aria-label={`Play from ${fmtTime(t)}`}>{m[0]}</button>
: courseId && lessonId
? <Link key={m.index} className="stamp" to={`${lessonHref(courseId, lessonId)}?t=${t}`}>{m[0]}</Link>
: m[0]);
last = m.index! + m[0].length;
}
parts.push(body.slice(last));
return <div className="nb">{parts}</div>;
}
/** Your notes for a lesson: a composer (with "Insert time" for video) and the list of lesson and video notes. */
export function NotesPanel({ notes, video, label = "Your notes" }: {
notes: NotesApi;
video?: { currentTime: () => number; seek: (s: number) => void } | null;
label?: string;
}) {
const [draft, setDraft] = useState("");
const [now, setNow] = useState(0);
const [busy, setBusy] = useState(false);
const ta = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!video) return;
const t = setInterval(() => setNow(video.currentTime()), 500);
return () => clearInterval(t);
}, [video]);
if (!notes.enabled) {
return (
<section>
<div className="blockhead"><h2>{label}</h2></div>
<p className="muted" style={{ fontSize: 13.5, margin: 0 }}><Link to="/account">Sign in</Link> to keep notes on this lesson. Only you see them.</p>
</section>
);
}
const insertTime = () => {
const el = ta.current;
const ins = `[${fmtTime(video!.currentTime())}] `;
const p = el?.selectionStart ?? draft.length, e = el?.selectionEnd ?? draft.length;
const pre = draft.slice(0, p) + (p && draft[p - 1] !== "\n" ? "\n" : "");
const next = pre + ins + draft.slice(e);
setDraft(next);
requestAnimationFrame(() => { el?.focus(); const c = pre.length + ins.length; el?.setSelectionRange(c, c); });
};
const save = async () => {
const body = draft.trim();
if (!body) return;
setBusy(true);
const t = video ? firstStamp(body) : null;
const ok = await notes.create(t !== null ? { anchorKind: "video", anchorValue: t, body } : { anchorKind: "none", body });
setBusy(false);
if (ok) setDraft("");
};
const list = notes.notes.filter((n) => n.anchorKind !== "slide");
return (
<section>
<div className="blockhead">
<h2>{label}</h2>
{video ? <Pill className="sm" onClick={insertTime}>Insert time <span className="tnum">{fmtTime(now)}</span></Pill> : null}
</div>
<textarea
ref={ta} className="note-ta" value={draft} onChange={(e) => setDraft(e.target.value)}
aria-label="New note" placeholder={video ? "Only you see these. Insert time adds a link back to this point in the video." : "Only you see these."}
/>
<div className="note-actions">
<Pill className="sm" variant="primary" disabled={!draft.trim() || busy} onClick={save}>Save note</Pill>
</div>
{list.length ? (
<ul className="note-list">
{list.map((n) => <NoteItem key={n.id} note={n} notes={notes} onSeek={video?.seek} />)}
</ul>
) : null}
</section>
);
}
export function NoteItem({ note, notes, onSeek, courseId, extra }: { note: Note; notes: Pick<NotesApi, "update" | "remove">; onSeek?: (s: number) => void; courseId?: string; extra?: ReactNode }) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(note.body);
useEffect(() => { setText(note.body); }, [note.body]);
if (editing) {
return (
<li>
<textarea className="note-ta" value={text} onChange={(e) => setText(e.target.value)} aria-label="Edit note" autoFocus
onKeyDown={(e) => { if (e.key === "Escape") { setEditing(false); setText(note.body); } }} />
<div className="note-actions">
<Pill className="sm" variant="ghost" onClick={() => { setEditing(false); setText(note.body); }}>Cancel</Pill>
<Pill className="sm" variant="primary" disabled={!text.trim()} onClick={async () => { await notes.update(note.id, text.trim()); setEditing(false); }}>Save</Pill>
</div>
</li>
);
}
return (
<li>
<NoteBody body={note.body} onSeek={onSeek} courseId={courseId ?? note.courseId} lessonId={note.lessonId} />
<div className="nm">
{extra}
<span>{new Date(note.updatedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}</span>
<button type="button" onClick={() => setEditing(true)}>Edit</button>
<button type="button" onClick={() => { if (confirm("Delete this note?")) void notes.remove(note.id); }}>Delete</button>
</div>
</li>
);
}
@@ -0,0 +1,221 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { Link } from "react-router-dom";
import { CourseCover, ProgressBar, SegmentedToggle } from "@stc/ui";
import type { CourseCard, ContinueItem, Format, LibraryPayload, SearchHit } from "../../shared/types";
import { api, courseHref, ensureStyles, fmtMinutes, lessonHref, refreshLibrary, useLibrary, useSearch } from "../api";
import { HeaderExtras } from "../shell/HeaderExtras";
import { accentStyle } from "../shell/CourseFrame";
import { TitleText } from "../lesson/TitleText";
const FORMAT_LABEL: Record<Format, string> = { text: "Text", video: "Video", slides: "Slides", exercises: "Exercises" };
type LengthFilter = "any" | "short" | "medium" | "long";
const LENGTHS: { value: LengthFilter; label: string; test: (m: number) => boolean }[] = [
{ value: "any", label: "Any length", test: () => true },
{ value: "short", label: "Under 2 h", test: (m) => m < 120 },
{ value: "medium", label: "2–10 h", test: (m) => m >= 120 && m <= 600 },
{ value: "long", label: "Over 10 h", test: (m) => m > 600 },
];
export interface LibraryFilters { format: Format | "all"; length: LengthFilter; inProgress: boolean }
export function filterCourses(courses: CourseCard[], f: LibraryFilters): CourseCard[] {
const len = LENGTHS.find((l) => l.value === f.length)!;
return courses.filter((c) => (f.format === "all" || c.formats.includes(f.format)) && len.test(c.minutes) && (!f.inProgress || (c.started && c.progress < 1)));
}
export default function LibraryPage() {
ensureStyles();
const lib = useLibrary();
useEffect(() => { void refreshLibrary(); }, []);
useEffect(() => { document.title = "Library – STCourses"; }, []);
return (
<div className="lr" data-look="work">
<HeaderExtras course={null} lookMode={null} />
{lib ? <LibraryView lib={lib} /> : <div className="lr-lib"><p className="lib-sub">Loading the library…</p></div>}
</div>
);
}
export function LibraryView({ lib, initialQuery }: { lib: LibraryPayload; initialQuery?: string }) {
const [storeQ] = useSearch();
const q = (initialQuery ?? storeQ).trim();
const [filters, setFilters] = useState<LibraryFilters>({ format: "all", length: "any", inProgress: false });
const [hits, setHits] = useState<SearchHit[] | null>(null);
useEffect(() => {
if (!q) { setHits(null); return; }
let live = true;
const t = setTimeout(() => {
api<SearchHit[]>(`/api/library/search?q=${encodeURIComponent(q)}`).then((h) => live && setHits(h), () => live && setHits([]));
}, 180);
return () => { live = false; clearTimeout(t); };
}, [q]);
const byId = useMemo(() => new Map(lib.courses.map((c) => [c.id, c])), [lib]);
const filtering = filters.format !== "all" || filters.length !== "any" || filters.inProgress;
const browsing = !q && !filtering;
const formats = useMemo(() => (["text", "video", "slides", "exercises"] as Format[]).filter((f) => lib.courses.some((c) => c.formats.includes(f))), [lib]);
const n = lib.courses.length;
let results: CourseCard[] = filterCourses(lib.courses, filters);
if (q && hits) {
const matched = new Set(hits.filter((h) => h.course).map((h) => h.courseId));
results = results.filter((c) => matched.has(c.id));
}
const lessonHits = (hits ?? []).filter((h) => h.lessons.length && byId.has(h.courseId) && filterCourses([byId.get(h.courseId)!], filters).length);
return (
<div className="lr-lib">
<h1 className="lib-h">Library</h1>
<p className="lib-sub">{n === 0 ? "No courses you can open yet." : n === 1 ? "One course on this server." : `${n} courses on this server.`}{!lib.signedIn && n > 0 ? " Sign in to keep your progress and notes." : ""}</p>
{browsing && lib.featured ? <Featured c={lib.featured} cont={lib.continue.find((x) => x.courseId === lib.featured!.id) ?? null} /> : null}
{browsing && lib.continue.length ? (
<section aria-labelledby="lr-cont">
<h2 className="sec-h" id="lr-cont">Continue</h2>
<div className="continue-list">
{lib.continue.slice(0, 3).map((c) => <ContinueRow key={c.courseId} item={c} course={byId.get(c.courseId)} />)}
</div>
</section>
) : null}
{n > 0 ? (
<div className="filters" role="group" aria-label="Filters">
{formats.length > 1 ? (
<SegmentedToggle label="Format" value={filters.format} onChange={(v) => setFilters({ ...filters, format: v as LibraryFilters["format"] })}
options={[{ value: "all", label: "All formats" }, ...formats.map((f) => ({ value: f, label: FORMAT_LABEL[f] }))]} />
) : null}
<SegmentedToggle label="Length" value={filters.length} onChange={(v) => setFilters({ ...filters, length: v as LengthFilter })}
options={LENGTHS.map((l) => ({ value: l.value, label: l.label }))} />
{lib.signedIn ? (
<SegmentedToggle label="Progress" value={filters.inProgress ? "in" : "all"} onChange={(v) => setFilters({ ...filters, inProgress: v === "in" })}
options={[{ value: "all", label: "Everything" }, { value: "in", label: "In progress" }]} />
) : null}
</div>
) : null}
{browsing ? (
<>
<Strip title="New in the library" courses={lib.newest.map((id) => byId.get(id)!).filter(Boolean).slice(0, 12)} signedIn={lib.signedIn} />
{lib.collections.map((col) => (
<Strip key={col.id} title={col.title} courses={col.courseIds.map((id) => byId.get(id)!).filter(Boolean)} signedIn={lib.signedIn} />
))}
<h2 className="sec-h">All courses</h2>
<CourseTable courses={lib.courses} signedIn={lib.signedIn} />
</>
) : (
<>
{results.length || !lessonHits.length ? <h2 className="sec-h">{q ? `Courses matching “${q}”` : "Courses"}</h2> : null}
{q && hits === null ? <p className="lr-nohit">Searching…</p>
: results.length ? <CourseTable courses={results} signedIn={lib.signedIn} />
: !lessonHits.length ? <p className="lr-nohit">Nothing matches. Try fewer words, or clear the filters.</p> : null}
{lessonHits.length ? (
<>
<h2 className="sec-h" style={{ marginTop: results.length ? 36 : 0 }}>{q ? `Lessons matching “${q}”` : "Lessons"}</h2>
<ul className="hits">
{lessonHits.flatMap((h) => h.lessons.map((l) => (
<li key={`${h.courseId}/${l.id}`}>
<Link to={lessonHref(h.courseId, l.id)}>
<span><span className="h1"><TitleText text={l.title} /></span><span className="h2" style={{ display: "block" }}>{h.courseTitle} · {l.moduleTitle}</span></span>
</Link>
</li>
)))}
</ul>
</>
) : null}
</>
)}
</div>
);
}
function facts(c: CourseCard): string[] {
const out = [c.lessonCount === 1 ? "1 lesson" : `${c.lessonCount} lessons`];
if (c.minutes) out.push(`about ${fmtMinutes(c.minutes)}`);
if (c.tracks.length > 1) out.push(`${c.tracks.length} tracks`);
return out;
}
function Featured({ c, cont }: { c: CourseCard; cont: ContinueItem | null }) {
const target = cont ? lessonHref(c.id, cont.lessonId) : courseHref(c.id);
return (
<section className="feat" aria-label="Featured course" style={accentStyle(c.accent)}>
<Link to={courseHref(c.id)} tabIndex={-1} aria-hidden="true" style={{ "--cw": "168px" } as CSSProperties}>
<CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} />
</Link>
<div>
<p className="k">Featured</p>
<h2><Link to={courseHref(c.id)}>{c.title}</Link></h2>
{c.pitch ? <p className="pitch">{c.pitch}</p> : null}
<div className="facts">{facts(c).map((f) => <span key={f}>{f}</span>)}{c.formats.map((f) => <span key={f}>{FORMAT_LABEL[f]}</span>)}</div>
<div className="actions">
<Link className="stc-pill stc-pill--primary" to={target}>{cont ? "Continue" : c.started ? "Open" : "Start"}</Link>
{c.started ? <span className="lr-prog" style={{ margin: 0, minWidth: 160 }}><ProgressBar value={c.progress} label="Progress" /><span className="tnum">{Math.round(c.progress * 100)}%</span></span> : null}
</div>
</div>
</section>
);
}
function ContinueRow({ item, course }: { item: ContinueItem; course: CourseCard | undefined }) {
const title = course?.title ?? item.courseId;
return (
<div className="continue" style={accentStyle(course?.accent)}>
<span style={{ "--cw": "56px" } as CSSProperties}><CourseCover title={title} image={course?.coverUrl ?? undefined} accent={course?.accent ?? undefined} /></span>
<div style={{ minWidth: 0 }}>
<div className="t">{title}</div>
<div className="s">{item.moduleTitle}. Next up: {item.lessonTitle}</div>
<div className="lr-prog"><ProgressBar value={item.moduleTotal ? item.moduleDone / item.moduleTotal : 0} label="Module progress" /><span className="tnum">{item.moduleDone} of {item.moduleTotal} in this module</span></div>
</div>
<Link className="stc-pill stc-pill--primary" to={lessonHref(item.courseId, item.lessonId)}>Continue</Link>
</div>
);
}
function Strip({ title, courses, signedIn }: { title: string; courses: CourseCard[]; signedIn: boolean }) {
if (!courses.length) return null;
return (
<section className="strip-wrap" aria-label={title}>
<h2 className="sec-h">{title}</h2>
<div className="strip">
{courses.map((c) => (
<Link key={c.id} className="tile" to={courseHref(c.id)} style={accentStyle(c.accent)}>
<CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} />
<span className="tt">{c.title}</span>
<span className="ts">{facts(c).slice(0, 2).join(", ")}</span>
{signedIn && c.started ? <ProgressBar value={c.progress} label={`${c.title} progress`} /> : null}
</Link>
))}
</div>
</section>
);
}
function CourseTable({ courses, signedIn }: { courses: CourseCard[]; signedIn: boolean }) {
return (
<>
<div className="lib-cols" aria-hidden="true"><span>Course</span><span>Lessons</span><span>Contains</span><span>{signedIn ? "Progress" : "Length"}</span></div>
<div className="lib">
{courses.map((c) => (
<Link key={c.id} className="lib-row" to={courseHref(c.id)} style={accentStyle(c.accent)}>
<span className="lib-main">
<span style={{ "--cw": "44px" } as CSSProperties}><CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} /></span>
<span style={{ minWidth: 0 }}>
<span className="lt">{c.title}</span>
<span className="ls">{c.pitch ?? c.authors.join(", ")}</span>
</span>
</span>
<span className="lnum tnum">{c.lessonCount}</span>
<span className="tags">
{c.status === "error" ? <span className="tag bad">Has problems</span> : null}
{c.formats.map((f) => <span key={f} className="tag">{FORMAT_LABEL[f]}</span>)}
</span>
{signedIn
? <span className="lr-prog"><ProgressBar value={c.progress} label={`${c.title} progress`} /><span className="tnum" style={{ minWidth: "3ch", textAlign: "right" }}>{Math.round(c.progress * 100)}%</span></span>
: <span className="lnum">{c.minutes ? fmtMinutes(c.minutes) : ""}</span>}
</Link>
))}
</div>
</>
);
}
@@ -0,0 +1,302 @@
import { Marked, type Token, type Tokens } from "marked";
import { slugify } from "@stc/course-format";
import sanitize, { type IOptions as SanitizeOptions } from "sanitize-html";
import { mediaUrl } from "../shared/types";
/**
* Lesson Markdown → HTML for the reader.
*
* - The rendered HTML goes through `sanitizeHtml` (sanitize-html allowlist). Raw HTML tags outside the allowlist
* are shown as text, so course files cannot inject script or styles. Link and image URLs are limited to
* http(s), mailto, in-page anchors and course-relative paths.
* - Consecutive code blocks in two or more track languages (and their bold "**Scala**" labels) form a
* variant group; only the chosen track's variant is kept. A group without that track keeps every variant.
* - `<details>` become folds. Consecutive hint folds ("… — nudge", "Hint 2 — concept") become one tier group
* (`data-tier`, `data-tiers`) that the reader opens in order.
* - Relative links to another lesson file become reader links; other relative paths go to `/api/media`.
*/
export interface RenderOptions {
courseId: string;
baseFile: string | null;
tracks?: { id: string; label: string }[];
track?: string | null;
lessonFiles?: Record<string, string>;
/** Drop a leading heading (the page shows the lesson title itself). */
stripFirstHeading?: boolean;
}
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** Tags a lesson may contain after rendering. Everything else is escaped (shown as text). */
export const ALLOWED_TAGS = [
"h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", "dl", "dt", "dd", "code", "pre", "a", "img",
"details", "summary", "table", "thead", "tbody", "tr", "th", "td", "blockquote", "em", "strong", "b", "i",
"s", "del", "sub", "sup", "mark", "kbd", "br", "hr", "span", "div",
];
const ALLOWED_SET = new Set(ALLOWED_TAGS);
const HEADING_ATTRS = ["id"];
const SANITIZE: SanitizeOptions = {
allowedTags: ALLOWED_TAGS,
allowedAttributes: {
h1: HEADING_ATTRS, h2: HEADING_ATTRS, h3: HEADING_ATTRS, h4: HEADING_ATTRS, h5: HEADING_ATTRS, h6: HEADING_ATTRS,
a: ["href", "title", "target", "rel"],
img: ["src", "alt", "title", "loading"],
pre: ["class", "data-lang", "data-t"],
details: ["class", "open", "data-hint", "data-group", "data-tier", "data-tiers"],
span: ["class"], div: ["class"], ol: ["start"], th: ["align"], td: ["align"],
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: { img: ["http", "https"] },
allowedSchemesAppliedToAttributes: ["href", "src"],
allowProtocolRelative: false,
allowedClasses: { pre: ["code"], span: ["sum", "tier", "chev"], div: ["in"], details: ["fold"] },
disallowedTagsMode: "escape",
transformTags: {
// Any link that opens a new window must not get a handle on this page.
a: (tagName, attribs) => (attribs.target ? { tagName, attribs: { ...attribs, target: "_blank", rel: "noopener noreferrer" } } : { tagName, attribs }),
},
};
/**
* The security boundary for lesson HTML: a parser-based allowlist (sanitize-html, htmlparser2) over the fully
* rendered output. Only the tags and attributes the reader needs survive; no event handlers, no style, no
* svg/math/iframe/object/embed/form; URLs are limited to http(s), mailto (links), relative paths and anchors.
*/
export function sanitizeHtml(html: string): string {
return sanitize(html, SANITIZE);
}
/**
* Escapes the `<` of raw HTML that is not an exact allowlisted lowercase tag (and of comments), so text such as
* `IO<A>` or `<Stream>` in prose stays visible. This only ever escapes; `sanitizeHtml` is still applied after.
*/
function escapeForeignTags(html: string): string {
return html.replace(/<(?=!|\/?([A-Za-z][\w-]*))(\/?)?/g, (m, name: string | undefined) => (name && ALLOWED_SET.has(name) ? m : `&lt;${m.slice(1)}`));
}
const LANG_ALIASES: Record<string, string[]> = {
js: ["js", "javascript", "mjs", "cjs", "jsx", "node"],
ts: ["ts", "typescript", "tsx"],
scala: ["scala", "scala3", "sc"],
fsharp: ["fsharp", "fs", "f#", "fsx"],
rust: ["rust", "rs"],
python: ["python", "py"],
go: ["go", "golang"],
java: ["java"],
kotlin: ["kotlin", "kt"],
csharp: ["csharp", "cs", "c#"],
};
/** Track id for a code fence language, when the course has that track. */
export function trackForLang(lang: string | undefined, tracks: { id: string; label: string }[]): string | null {
if (!lang) return null;
const l = lang.trim().split(/\s+/)[0]!.toLowerCase();
for (const t of tracks) {
const aliases = new Set([t.id.toLowerCase(), t.label.toLowerCase(), ...(LANG_ALIASES[t.id.toLowerCase()] ?? [])]);
for (const [k, v] of Object.entries(LANG_ALIASES)) if (v.includes(t.id.toLowerCase()) || v.includes(t.label.toLowerCase())) { aliases.add(k); for (const x of v) aliases.add(x); }
if (aliases.has(l)) return t.id;
}
return null;
}
/** Track id for a paragraph that is only a bold track label, such as "**Scala**" or "**F#**". */
function trackForLabel(tok: Token, tracks: { id: string; label: string }[]): string | null {
if (tok.type !== "paragraph") return null;
const p = tok as Tokens.Paragraph;
if (p.tokens?.length !== 1 || p.tokens[0]!.type !== "strong") return null;
const text = (p.tokens[0] as Tokens.Strong).text.trim().replace(/:$/, "").toLowerCase();
for (const t of tracks) {
const label = t.label.toLowerCase();
if (text === label || label.startsWith(text + " ") || text === t.id.toLowerCase() || trackForLang(text, [t]) === t.id) return t.id;
}
return null;
}
function trackOf(tok: Token, tracks: { id: string; label: string }[]): string | null {
if (tok.type === "code") return trackForLang((tok as Tokens.Code).lang, tracks);
return trackForLabel(tok, tracks);
}
/** Removes the other tracks' variants from groups of consecutive track-tagged blocks. */
export function selectTrack(tokens: Token[], tracks: { id: string; label: string }[], track: string | null | undefined): Token[] {
if (tracks.length < 2 || !track) return tokens;
const out: Token[] = [];
let i = 0;
while (i < tokens.length) {
if (trackOf(tokens[i]!, tracks) === null) { out.push(tokens[i]!); i++; continue; }
let j = i;
const run: Token[] = [];
while (j < tokens.length && (tokens[j]!.type === "space" || trackOf(tokens[j]!, tracks) !== null)) run.push(tokens[j++]!);
while (run.length && run[run.length - 1]!.type === "space") { run.pop(); j--; }
const present = new Set(run.map((t) => trackOf(t, tracks)).filter(Boolean));
if (present.size >= 2 && present.has(track)) out.push(...run.filter((t) => t.type === "space" || trackOf(t, tracks) === track));
else out.push(...run);
i = j;
}
return out;
}
const HINT = /\b(hint|nudge|stronger|near[- ]?solution|solution)\b/i;
const OPEN_DETAILS = /^\s*<details(\s+open)?\s*>\s*(?:<summary(?:\s[^>]*)?>([\s\S]*?)<\/summary>)?/i;
interface FoldPlan { hint: boolean; group: string | null; tier: number; tiers: number }
/** Splits html tokens so each `<details>` opening and `</details>` closing sits at the start of its own token. */
function splitHtmlTokens(tokens: Token[]): Token[] {
const out: Token[] = [];
for (const tok of tokens) {
if (tok.type !== "html") { out.push(tok); continue; }
const parts = (tok as Tokens.HTML).text.split(/(?=<\/details>)|(?<=<\/details>)|(?=<details[\s>])/i).filter((p) => p.trim() !== "");
if (parts.length <= 1) { out.push(tok); continue; }
for (const p of parts) out.push({ ...(tok as Tokens.HTML), raw: p, text: p } as Token);
}
return out;
}
/** Plans folds for top-level `<details>` html tokens and groups consecutive hint folds into tiers. */
function planFolds(tokens: Token[]): Map<Token, FoldPlan> {
const plans = new Map<Token, FoldPlan>();
type Item = { tok: Token; hint: boolean; prefix: string | null; gapBefore: boolean };
const items: Item[] = [];
let sinceClose = true; // non-space content since the last closing </details>
for (const tok of tokens) {
if (tok.type === "html" && OPEN_DETAILS.test((tok as Tokens.HTML).text)) {
const summary = (OPEN_DETAILS.exec((tok as Tokens.HTML).text)![2] ?? "").replace(/<[^>]+>/g, "").trim();
const dash = summary.split(/\s+[—–-]\s+/);
const prefix = dash.length > 1 ? dash[0]!.replace(/\s*\d+$/, "").trim().toLowerCase() : null;
items.push({ tok, hint: HINT.test(summary), prefix, gapBefore: sinceClose });
sinceClose = false;
if (/<\/details>\s*$/i.test((tok as Tokens.HTML).text)) sinceClose = false;
continue;
}
if (tok.type === "html" && /^\s*<\/details>/i.test((tok as Tokens.HTML).text)) { sinceClose = false; continue; }
if (tok.type !== "space") sinceClose = true;
}
let g = 0;
let current: Item[] = [];
const flush = () => {
const id = `h${g++}`;
current.forEach((it, k) => plans.set(it.tok, { hint: true, group: current.length > 1 ? id : null, tier: k + 1, tiers: current.length }));
current = [];
};
for (const it of items) {
if (!it.hint) { if (current.length) flush(); plans.set(it.tok, { hint: false, group: null, tier: 0, tiers: 0 }); continue; }
const prev = current[current.length - 1];
if (prev && (it.gapBefore || (it.prefix !== null && prev.prefix !== null && it.prefix !== prev.prefix))) flush();
current.push(it);
}
if (current.length) flush();
return plans;
}
const CHEVRON = '<span class="chev" aria-hidden="true"></span>';
function dirOf(file: string | null): string {
if (!file) return "";
const i = file.lastIndexOf("/");
return i < 0 ? "" : file.slice(0, i + 1);
}
/** Resolves `rel` against `base` (both course-relative), or null if it climbs above the course root. */
export function joinPath(base: string, rel: string): string | null {
const parts = (base + rel).split("/");
const out: string[] = [];
for (const p of parts) {
if (p === "" || p === ".") continue;
if (p === "..") { if (!out.length) return null; out.pop(); } else out.push(p);
}
return out.join("/");
}
function safeDecode(s: string): string {
try { return decodeURI(s); } catch { return s; }
}
export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "image"): { url: string; external: boolean } | null {
const h = href.trim();
if (/^https?:\/\//i.test(h)) return { url: h, external: true };
if (kind === "link" && /^mailto:/i.test(h)) return { url: h, external: true };
if (kind === "image" && /^data:image\/(png|jpe?g|gif|webp);/i.test(h)) return { url: h, external: false };
if (h.startsWith("#")) return kind === "link" ? { url: h, external: false } : null;
if (/^[a-z][a-z0-9+.-]*:/i.test(h) || h.startsWith("//")) return null; // javascript:, file:, protocol-relative …
const [pathPart, hash] = h.split("#", 2) as [string, string | undefined];
const rel = h.startsWith("/") ? joinPath("", safeDecode(pathPart)) : joinPath(dirOf(opts.baseFile), safeDecode(pathPart));
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 };
}
export function renderLessonHtml(md: string, opts: RenderOptions): string {
const tracks = opts.tracks ?? [];
const marked = new Marked({ gfm: true });
let tokens: Token[] = marked.lexer(md);
if (opts.stripFirstHeading) {
const first = tokens.findIndex((t) => t.type !== "space");
if (first >= 0 && tokens[first]!.type === "heading") tokens = [...tokens.slice(0, first), ...tokens.slice(first + 1)];
}
tokens = splitHtmlTokens(selectTrack(tokens, tracks, opts.track ?? tracks[0]?.id));
const folds = planFolds(tokens);
const slugs = new Map<string, number>();
const renderer = {
heading(this: { parser: { parseInline(t: Token[]): string } }, { tokens: inl, depth, text }: Tokens.Heading) {
const base = slugify(text);
const n = slugs.get(base) ?? 0;
slugs.set(base, n + 1);
const id = n === 0 ? base : `${base}-${n}`;
return `<h${depth} id="${escapeHtml(id)}">${this.parser.parseInline(inl)}</h${depth}>\n`;
},
code({ text, lang }: Tokens.Code) {
const l = (lang ?? "").trim().split(/\s+/)[0] ?? "";
const t = trackForLang(l, tracks);
return `<pre class="code"${l ? ` data-lang="${escapeHtml(l)}"` : ""}${t ? ` data-t="${escapeHtml(t)}"` : ""}><code>${escapeHtml(text)}</code></pre>\n`;
},
link(this: { parser: { parseInline(t: Token[]): string } }, { href, title, tokens: inl }: Tokens.Link) {
const inner = this.parser.parseInline(inl);
const r = resolveHref(href, opts, "link");
if (!r) return inner;
const t = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(r.url)}"${t}${r.external ? ' target="_blank" rel="noopener noreferrer"' : ""}>${inner}</a>`;
},
image({ href, title, text }: Tokens.Image) {
const r = resolveHref(href, opts, "image");
if (!r) return escapeHtml(text);
return `<img src="${escapeHtml(r.url)}" alt="${escapeHtml(text)}"${title ? ` title="${escapeHtml(title)}"` : ""} loading="lazy">`;
},
html(token: Tokens.HTML | Tokens.Tag) {
const plan = folds.get(token as Token);
let text = token.text;
if (plan) {
const m = OPEN_DETAILS.exec(text)!;
const summary = escapeForeignTags(m[2] ?? (plan.hint ? "Hint" : "Details"));
const attrs = plan.hint
? ` data-hint="1"${plan.group ? ` data-group="${plan.group}" data-tier="${plan.tier}" data-tiers="${plan.tiers}"` : ""}`
: "";
const tier = plan.group ? `<span class="tier">${plan.tier} of ${plan.tiers}</span>` : "";
const head = `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${summary}</span>${tier}</summary><div class="in">`;
text = head + escapeForeignTags(text.slice(m[0].length)).replace(/<\/details>/gi, "</div></details>");
return text;
}
if (/^\s*<\/details>/i.test(text)) return escapeForeignTags(text).replace(/<\/details>/i, "</div></details>");
return escapeForeignTags(text);
},
};
marked.use({ renderer: renderer as never });
return sanitizeHtml(marked.parser(tokens) as string);
}
const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s);
/** Exercise hints (already matched by the server) as one tier group of folds. */
export function renderHintsHtml(hints: { label: string; body: string }[], opts: RenderOptions): string {
const n = hints.length;
return sanitizeHtml(hints.map((h, i) => {
const label = h.label ? cap(h.label) : `Hint ${i + 1}`;
const tier = n > 1 ? `<span class="tier">${i + 1} of ${n}</span>` : "";
const attrs = n > 1 ? ` data-hint="1" data-group="ex" data-tier="${i + 1}" data-tiers="${n}"` : ' data-hint="1"';
return `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${escapeHtml(label)}</span>${tier}</summary><div class="in">${renderLessonHtml(h.body, opts)}</div></details>`;
}).join("\n"));
}
@@ -0,0 +1,130 @@
import type { ExerciseLanguage, ExerciseRunner, TestResult } from "@stc/contracts";
import { WORKER_SOURCE } from "./worker-source";
/** Where the worker runs. The browser default is a sandboxed iframe (`frameHost`); tests may pass a plain Worker. */
export interface RunnerHost {
onmessage: ((ev: { data: unknown }) => void) | null;
onerror: ((ev: { message: string; preventDefault?: () => void }) => void) | null;
postMessage(data: { code: string; tests: string }): void;
terminate(): void;
}
export interface RunnerOptions {
/** TypeScript → JavaScript. Defaults to the server's `POST /api/learner/transpile` (Bun.Transpiler). */
transpile?: (code: string) => Promise<string>;
createWorker?: () => RunnerHost | Worker;
}
export const RUNNER_FRAME_URL = "/api/learner/runner-frame";
/**
* Runs the exercise worker inside `<iframe sandbox="allow-scripts">` (opaque origin, no cookies, CSP with
* connect-src 'none'; see src/server/runnerFrame.ts). Messages are accepted only from that frame's window, and
* `terminate()` removes the frame, which ends the worker.
*/
export function frameHost(url = RUNNER_FRAME_URL): RunnerHost {
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-scripts");
iframe.setAttribute("aria-hidden", "true");
iframe.tabIndex = -1;
iframe.title = "Exercise runner";
iframe.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden";
iframe.src = url;
let ready = false;
const queue: { code: string; tests: string }[] = [];
const onMsg = (ev: MessageEvent) => {
// Only the frame's own window, and only while it is sandboxed (an opaque origin serialises as "null").
if (ev.source !== iframe.contentWindow || ev.origin !== "null") return;
const m = ev.data as { type?: string; data?: unknown; message?: string } | null;
if (!m) return;
if (m.type === "ready") {
ready = true;
for (const d of queue.splice(0)) host.postMessage(d);
} else if (m.type === "worker") host.onmessage?.({ data: m.data });
else if (m.type === "crash") host.onerror?.({ message: String(m.message ?? "The exercise worker crashed") });
};
const host: RunnerHost = {
onmessage: null,
onerror: null,
postMessage(d) {
if (ready) iframe.contentWindow?.postMessage({ type: "run", code: d.code, tests: d.tests }, "*");
else queue.push(d);
},
terminate() {
window.removeEventListener("message", onMsg);
iframe.remove();
},
};
window.addEventListener("message", onMsg);
document.body.append(iframe);
return host;
}
export async function serverTranspile(code: string): Promise<string> {
const r = await fetch("/api/learner/transpile", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code }) });
const b = (await r.json()) as { code?: string; error?: string };
if (!r.ok || typeof b.code !== "string") throw new Error(b.error ?? "TypeScript could not be compiled");
return b.code;
}
/** In a browser: the sandboxed frame. Elsewhere (bun test): a plain module worker. */
const defaultWorker = (): RunnerHost | Worker => (typeof document !== "undefined"
? frameHost()
: new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" }));
/**
* Runs learner code against a tests module in a fresh Web Worker (never in the page; in a browser, inside a
* sandboxed iframe), then terminates it.
* When `timeoutMs` passes, the worker is killed and every test without a result is reported as timed out.
*/
export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOptions = {}): ExerciseRunner {
const transpile = opts.transpile ?? serverTranspile;
const make = opts.createWorker ?? defaultWorker;
return {
language,
async run(learnerCode, testsModule, timeoutMs) {
let code = learnerCode, tests = testsModule;
if (language === "ts") {
try { code = await transpile(learnerCode); } catch (e) {
return [{ name: "Your code did not compile", passed: false, message: (e as Error).message }];
}
tests = await transpile(testsModule);
}
const worker = make();
return new Promise<TestResult[]>((resolve) => {
let names: string[] = [];
const results: TestResult[] = [];
let settled = false;
const finish = (extra: TestResult[] = []) => {
if (settled) return;
settled = true;
clearTimeout(timer);
worker.terminate();
resolve([...results, ...extra]);
};
const pending = (message: string) => names.slice(results.length).map((name) => ({ name, passed: false, message }));
const timer = setTimeout(() => {
const rest = pending(`Timed out after ${timeoutMs} ms. Is there an endless loop?`);
finish(rest.length ? rest : [{ name: "Timed out", passed: false, message: `No result after ${timeoutMs} ms` }]);
}, timeoutMs);
worker.onmessage = (ev: { data: unknown }) => {
const m = ev.data as { type: string; names?: string[]; result?: TestResult; stage?: string; message?: string };
if (m.type === "tests") names = m.names ?? [];
else if (m.type === "result" && m.result) results.push(m.result);
else if (m.type === "done") finish();
else if (m.type === "error") {
if (m.stage === "code") {
const rest = pending(`Your code did not load. ${m.message}`);
finish(rest.length ? rest : [{ name: "Your code did not load", passed: false, message: m.message }]);
} else finish([{ name: "The tests did not load", passed: false, message: m.message }]);
}
};
worker.onerror = (ev: { message: string; preventDefault?: () => void }) => {
ev.preventDefault?.();
finish(pending(ev.message || "The exercise worker crashed"));
};
worker.postMessage({ code, tests });
});
},
};
}
@@ -0,0 +1,107 @@
/**
* Source of the exercise Web Worker, kept as a string so it can be started from a blob URL without a
* separate bundle. Protocol (worker → page): {type:"tests", names}, {type:"result", result}, {type:"done"},
* {type:"error", stage:"tests"|"code", message}. Page → worker: {code, tests} (plain JavaScript ES modules).
*/
export const WORKER_SOURCE = String.raw`
"use strict";
// Second layer only: the worker runs inside a sandboxed, opaque-origin iframe whose CSP has connect-src 'none'
// and allows scripts only from blob: (runnerFrame.ts). These stubs give a clear message for the common cases.
const blocked = () => { throw new Error("Network access is turned off while exercises run"); };
for (const k of ["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "importScripts", "Worker", "SharedWorker"]) {
try { Object.defineProperty(self, k, { value: blocked, configurable: false, writable: false }); } catch (_) {}
}
try { Object.defineProperty(self, "caches", { value: undefined, configurable: false, writable: false }); } catch (_) {}
class AssertionError extends Error {
constructor(message, expected, actual) { super(message); this.name = "AssertionError"; this.expected = expected; this.actual = actual; this.hasValues = arguments.length > 1; }
}
function fmt(v, seen = new Set()) {
if (v === undefined) return "undefined";
if (v === null) return "null";
if (typeof v === "string") return JSON.stringify(v);
if (typeof v === "number") return Object.is(v, -0) ? "-0" : String(v);
if (typeof v === "bigint") return v + "n";
if (typeof v === "function") return "[Function " + (v.name || "anonymous") + "]";
if (typeof v === "symbol") return v.toString();
if (typeof v !== "object") return String(v);
if (seen.has(v)) return "[Circular]";
seen.add(v);
let out;
if (Array.isArray(v)) out = "[" + v.map((x) => fmt(x, seen)).join(", ") + "]";
else if (v instanceof Map) out = "Map {" + [...v].map(([k, x]) => fmt(k, seen) + " => " + fmt(x, seen)).join(", ") + "}";
else if (v instanceof Set) out = "Set {" + [...v].map((x) => fmt(x, seen)).join(", ") + "}";
else if (v instanceof Date) out = "Date(" + v.toISOString() + ")";
else if (v instanceof Error) out = v.name + ": " + v.message;
else out = "{" + Object.keys(v).map((k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)) + ": " + fmt(v[k], seen)).join(", ") + "}";
seen.delete(v);
return out;
}
function deepEq(a, b, seen = new Map()) {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
if (seen.get(a) === b) return true;
seen.set(a, b);
if (a instanceof Date) return a.getTime() === b.getTime();
if (a instanceof Map || a instanceof Set) {
if (a.size !== b.size) return false;
if (a instanceof Set) { for (const x of a) if (!b.has(x)) return false; return true; }
for (const [k, x] of a) if (!b.has(k) || !deepEq(x, b.get(k), seen)) return false;
return true;
}
const ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEq(a[k], b[k], seen)) return false;
return true;
}
const assert = {
equal(actual, expected, message) {
if (!Object.is(actual, expected) && !(actual === expected)) throw new AssertionError(message || "Expected values to be strictly equal", expected, actual);
},
deepEqual(actual, expected, message) {
if (!deepEq(actual, expected)) throw new AssertionError(message || "Expected values to be deeply equal", expected, actual);
},
ok(value, message) {
if (!value) throw new AssertionError(message || "Expected a truthy value", true, value);
},
};
const load = (src) => import(URL.createObjectURL(new Blob([src], { type: "text/javascript" })));
const describe = (e) => (e && e.name && e.message ? e.name + ": " + e.message : String(e));
self.onmessage = async (ev) => {
const { code, tests } = ev.data;
let list;
try {
const t = await load(tests);
list = Array.isArray(t.tests) ? t.tests : Array.isArray(t.default) ? t.default : null;
if (!list) throw new Error("the tests module must export \"tests\" (an array)");
} catch (e) {
self.postMessage({ type: "error", stage: "tests", message: describe(e) });
return;
}
self.postMessage({ type: "tests", names: list.map((t, i) => String(t && t.name || "test " + (i + 1))) });
let mod;
try { mod = await load(code); } catch (e) {
self.postMessage({ type: "error", stage: "code", message: describe(e) });
return;
}
for (let i = 0; i < list.length; i++) {
const t = list[i];
const name = String(t && t.name || "test " + (i + 1));
try {
await t.run(mod, assert);
self.postMessage({ type: "result", result: { name, passed: true } });
} catch (e) {
const r = { name, passed: false, message: describe(e) };
if (e instanceof AssertionError && e.hasValues) { r.expected = fmt(e.expected); r.actual = fmt(e.actual); r.message = e.message; }
self.postMessage({ type: "result", result: r });
}
}
self.postMessage({ type: "done" });
};
`;
@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { Link, useLocation } from "react-router-dom";
import { CourseCover, ProgressBar } from "@stc/ui";
import type { CoursePayload, OutlineLesson, OutlineModule } from "../../shared/types";
import { courseHref, ensureStyles, fmtMinutes, fmtTime, lessonHref, useSearch, type Look } from "../api";
import { Check, Chev, Lock } from "../icons";
import { HeaderExtras } from "./HeaderExtras";
import { TitleText } from "../lesson/TitleText";
const fold = (s: string) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase();
export function lessonKind(l: OutlineLesson): string {
if (l.type === "video") return l.minutes ? (l.minutes < 60 ? fmtTime(l.minutes * 60) : fmtMinutes(l.minutes)) : "Video";
if (l.type === "exercise") return "Exercise";
return l.hasDeck ? "Slides" : "Text";
}
export function courseDone(c: CoursePayload): { done: number; total: number } {
const all = c.modules.flatMap((m) => m.lessons);
return { done: all.filter((l) => c.progress[l.id]?.state === "done").length, total: all.length };
}
/** Display number of a module: courses whose first module id ends in 0 ("m00") count from 0. */
export function moduleNumber(course: { modules: { id: string }[] }, index: number): number {
return /(^|\D)0+$/.test(course.modules[0]?.id ?? "") ? index : index + 1;
}
export function accentStyle(accent: string | null | undefined): CSSProperties | undefined {
return accent ? ({ "--accent": accent } as CSSProperties) : undefined;
}
/** The course page frame: outline sidebar (an overlay on narrow screens) and the main column. */
export function CourseFrame({ course, currentLessonId, look, lookMode, children }: {
course: CoursePayload;
currentLessonId: string | null;
look: Look;
lookMode: "toggle" | "work" | null;
children: ReactNode;
}) {
ensureStyles();
const [open, setOpen] = useState(false);
const loc = useLocation();
useEffect(() => { setOpen(false); }, [loc.pathname]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<div className={open ? "lr lr-shell side-open" : "lr lr-shell"} data-look={look} style={accentStyle(course.accent)}>
<HeaderExtras
course={{ id: course.id, title: course.title, coverUrl: course.coverUrl, accent: course.accent }}
lookMode={lookMode}
outline={{ open, toggle: () => setOpen(!open) }}
/>
<aside className="lr-side" id="lr-side" aria-label="Course outline">
<Outline course={course} currentLessonId={currentLessonId} />
</aside>
<div className="lr-scrim" onClick={() => setOpen(false)} />
<main className="lr-main">{children}</main>
</div>
);
}
export function Outline({ course, currentLessonId }: { course: CoursePayload; currentLessonId: string | null }) {
const [q] = useSearch();
const query = fold(q.trim());
const current = course.modules.find((m) => m.lessons.some((l) => l.id === currentLessonId));
const [openMods, setOpenMods] = useState<Set<string>>(() => new Set(current ? [current.id] : course.modules[0] ? [course.modules[0].id] : []));
useEffect(() => { if (current) setOpenMods((s) => (s.has(current.id) ? s : new Set([...s, current.id]))); }, [current?.id]);
const loc = useLocation();
useEffect(() => {
document.querySelector(".lr-les[aria-current='page']")?.scrollIntoView({ block: "nearest" });
}, [currentLessonId]);
const { done, total } = courseDone(course);
const pct = total ? done / total : 0;
const notesPath = `${courseHref(course.id)}/notes`;
const filtered = useMemo(() => course.modules
.map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons }))
.filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]);
const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? "";
const errors = course.diagnostics.filter((d) => d.level === "error");
return (
<>
<div className="lr-side-head">
<span style={{ "--cw": "36px" } as CSSProperties}><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<h2><Link to={courseHref(course.id)}>{course.title}</Link></h2>
{sourceLine ? <div className="src">{sourceLine}</div> : null}
{course.signedIn ? (
<div className="lr-prog"><ProgressBar value={pct} label="Course progress" /><span className="tnum">{Math.round(pct * 100)}%</span></div>
) : null}
<div className="lr-side-links">
<Link to={courseHref(course.id)} aria-current={loc.pathname === courseHref(course.id) ? "page" : undefined}>Overview</Link>
{course.signedIn ? <Link to={notesPath} aria-current={loc.pathname === notesPath ? "page" : undefined}>Your notes</Link> : null}
</div>
</div>
</div>
{errors.length ? (
<div className="lr-diag" role="note">
<b>{errors.length === 1 ? "1 problem" : `${errors.length} problems`} in this course</b> (only authors see this)
<ul>{errors.slice(0, 6).map((d, i) => <li key={i}>{d.lessonId ? `${d.lessonId}: ` : ""}{d.message}{d.path ? ` (${d.path})` : ""}</li>)}</ul>
</div>
) : null}
{filtered.map(({ m, lessons }) => (
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
))}
{query && filtered.length === 0 ? <p className="lr-nohit">No lessons match “{q.trim()}”.</p> : null}
</>
);
}
function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: {
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
}) {
const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
const pct = m.lessons.length ? d / m.lessons.length : 0;
const number = String(moduleNumber(course, m.index));
return (
<div className={current ? "lr-mod is-current" : "lr-mod"}>
<button type="button" className="lr-mod-head" aria-expanded={open} onClick={onToggle}>
<span className="n tnum">{m.locked ? <Lock /> : number}</span>
<span>{m.title}{m.locked ? <span className="sr"> (locked)</span> : null}</span>
<span className="c tnum">{course.signedIn ? `${d}/${m.lessons.length}` : m.lessons.length}</span>
<Chev />
</button>
{course.signedIn ? <ProgressBar value={pct} dim={pct === 1 || !current} label={`${m.title} progress`} /> : <span style={{ display: "block", height: 4 }} />}
{open ? (
<ol className="lr-lessons">
{lessons.map((l) => {
const isDone = course.progress[l.id]?.state === "done";
const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
return (
<li key={l.id}>
<Link className={cls} to={lessonHref(course.id, l.id)} aria-current={l.id === currentLessonId ? "page" : undefined}>
<span className="m">{isDone ? <Check /> : m.locked ? <Lock /> : null}</span>
<span><TitleText text={l.title} /><span className="sr">{isDone ? ", done" : l.id === currentLessonId ? ", current" : ""}</span></span>
<span className="d tnum">{lessonKind(l)}</span>
</Link>
</li>
);
})}
</ol>
) : null}
</div>
);
}
@@ -0,0 +1,138 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link, useLocation } from "react-router-dom";
import { CourseCover, SegmentedToggle } from "@stc/ui";
import { courseHref, toast, useLibrary, useLook, useMe, useSearch, useToast } from "../api";
import { DirI, Down, MenuI, SearchI } from "../icons";
export interface HeaderCourse { id: string; title: string; coverUrl: string | null; accent: string | null }
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/**
* Course switcher, search, Reading/Work and the account link, portalled into the Phase 0 header (`.stc-top`).
* Rendered only after mount, so server-side rendering of a page skips it.
*/
export function HeaderExtras({ course, lookMode, outline }: {
course: HeaderCourse | null;
/** "toggle": show Reading/Work; "work": shown but fixed to Work (exercise pages); null: hidden. */
lookMode: "toggle" | "work" | null;
outline?: { open: boolean; toggle: () => void } | null;
}) {
const [host, setHost] = useState<Element | null>(null);
useIsoLayoutEffect(() => { setHost(document.querySelector(".stc-top")); }, []);
const msg = useToast();
if (!host) return null;
return (
<>
{createPortal(
<div className="lr lr-h">
{outline ? (
<button type="button" className="lr-icon" aria-label="Course outline" aria-expanded={outline.open} aria-controls="lr-side" onClick={outline.toggle}><MenuI /></button>
) : null}
<CourseSwitcher course={course} />
<SearchBox placeholder={course ? "Search this course" : "Search the library"} />
{lookMode ? <LookToggle mode={lookMode} /> : null}
<AccountLink />
</div>,
host,
)}
{msg ? <div className="lr lr-toast" role="status">{msg}</div> : null}
</>
);
}
function LookToggle({ mode }: { mode: "toggle" | "work" }) {
const [look, setLook] = useLook();
const value = mode === "work" ? "work" : look;
return (
<SegmentedToggle
label="Look"
value={value}
options={[{ value: "reading", label: "Reading" }, { value: "work", label: "Work" }]}
onChange={(v) => {
setLook(v as "reading" | "work");
if (mode === "work" && v === "reading") toast("Exercises always use Work. Reading applies to text lessons.");
}}
/>
);
}
function SearchBox({ placeholder }: { placeholder: string }) {
const [q, setQ] = useSearch();
const loc = useLocation();
const path = useRef(loc.pathname.split("/").slice(0, 3).join("/"));
// Clear the query when moving to another course or to the library.
useEffect(() => {
const p = loc.pathname.split("/").slice(0, 3).join("/");
if (p !== path.current) { path.current = p; setQ(""); }
}, [loc.pathname, setQ]);
return (
<label className="lr-search">
<SearchI />
<span className="sr">Search</span>
<input
type="search" value={q} placeholder={placeholder} autoComplete="off"
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => { if (e.key === "Escape") { setQ(""); (e.target as HTMLInputElement).blur(); } }}
/>
</label>
);
}
function CourseSwitcher({ course }: { course: HeaderCourse | null }) {
const [open, setOpen] = useState(false);
const lib = useLibrary();
const ref = useRef<HTMLDivElement>(null);
const loc = useLocation();
useEffect(() => { setOpen(false); }, [loc.pathname]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { setOpen(false); ref.current?.querySelector<HTMLButtonElement>("button")?.focus(); }
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
ref.current?.querySelector<HTMLAnchorElement>(".lr-menu a")?.focus();
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
}, [open]);
const n = lib?.courses.length ?? 0;
return (
<div className="lr-switch" ref={ref}>
<button type="button" className="lr-switch-btn" aria-haspopup="true" aria-expanded={open} onClick={() => setOpen(!open)}>
{course
? <span className="lr-cover-tiny"><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
: <span className="lr-tile"><DirI /></span>}
<span className="t">{course ? course.title : "All courses"}</span>
<Down />
</button>
{open ? (
<ul className="lr-menu">
<li>
<Link to="/" aria-current={course ? undefined : "page"}>
<span className="lr-tile"><DirI /></span>
<span><span className="mt">Library</span><span className="ms">{n === 1 ? "1 course" : `${n} courses`}</span></span>
</Link>
</li>
{(lib?.courses ?? []).map((c) => (
<li key={c.id}>
<Link to={courseHref(c.id)} aria-current={course?.id === c.id ? "page" : undefined}>
<span className="lr-cover-tiny"><CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} /></span>
<span style={{ minWidth: 0 }}><span className="mt">{c.title}</span><span className="ms">{c.authors.join(", ") || (c.source === "authored" ? "Authored course" : c.source ? `Imported (${c.source})` : "")}</span></span>
</Link>
</li>
))}
</ul>
) : null}
</div>
);
}
function AccountLink() {
const { loaded, user } = useMe();
if (!loaded) return null;
return user
? <Link className="stc-pill stc-pill--ghost lr-acct" to="/account">{user.name || user.email}</Link>
: <Link className="stc-pill lr-acct" to="/account">Sign in</Link>;
}
+375
View File
@@ -0,0 +1,375 @@
// The learner stylesheet, injected once by ensureStyles(). Kept as a string (not a .css text import) because
// Bun's dev server loses text imports on hot rebuilds. Scoped under .lr; no colours beyond the @stc/ui tokens.
export const learnerCss = String.raw`
/* Learner app. Scoped under .lr so the studio can embed LessonView without clashes.
Tokens come from @stc/ui (tokens.css); this file adds no colours of its own. */
.lr { --accent-dim: color-mix(in srgb, var(--accent) 55%, var(--ground)); }
.lr[data-look="reading"], .lr [data-look="reading"] { --pf: var(--serif); --ps: 19px; --pl: 1.75; --pc: var(--read); --col: 660px; --wide: 900px; --tf: var(--serif); --h1: 40px; }
.lr[data-look="work"], .lr [data-look="work"] { --pf: var(--sans); --ps: 15.5px; --pl: 1.6; --pc: var(--text); --col: 760px; --wide: 1180px; --tf: var(--sans); --h1: 28px; }
.lr [hidden] { display: none !important; }
.lr .tnum { font-variant-numeric: tabular-nums; }
/* ---------- header extras (portalled into .stc-top) ---------- */
.lr-h { display: contents; }
.stc-top > a:first-child { flex: 0 0 calc(var(--sidew) - 30px); }
.lr-icon { display: none; width: 34px; height: 34px; border-radius: 18px; border: 1px solid var(--line2); background: none; place-items: center; color: var(--muted); flex: none; }
.lr-icon:hover { background: var(--raised); }
.lr-switch { position: relative; flex: none; }
.lr-switch-btn { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 12px 0 5px; border: 1px solid var(--line2); border-radius: 18px; background: none; font-weight: 500; font-size: 13px; max-width: 300px; }
.lr-switch-btn:hover { background: var(--raised); }
.lr-switch-btn .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lr-switch-btn svg { color: var(--faint); flex: none; }
.lr-menu { position: absolute; top: 42px; left: 0; width: 330px; max-height: calc(100vh - 80px); overflow-y: auto; margin: 0; padding: 6px; list-style: none; background: var(--side); border: 1px solid var(--line2); border-radius: 14px; z-index: 50; }
.lr-menu a { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border-radius: 10px; text-decoration: none; }
.lr-menu a:hover, .lr-menu a:focus-visible { background: var(--raised); }
.lr-menu a[aria-current] .mt { color: var(--head); }
.lr-menu .mt { font-weight: 500; font-size: 13.5px; display: block; }
.lr-menu .ms { display: block; font-size: 12px; color: var(--faint); }
.lr-tile { width: 24px; display: grid; place-items: center; color: var(--faint); flex: none; }
.lr-search { flex: 1; max-width: 380px; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 18px; color: var(--faint); margin-left: auto; }
.lr-search:focus-within { border-color: var(--faint); }
.lr-search input { flex: 1; min-width: 0; border: 0; background: none; outline: 0; color: var(--text); font-size: 13px; }
.lr-search input::placeholder { color: var(--faint); }
.lr-search input::-webkit-search-cancel-button { filter: grayscale(1); }
.lr-acct { flex: none; }
.lr-cover-tiny { --cw: 24px; }
.lr-cover-tiny .stc-cover { border-radius: 4px; font-size: 9px; }
/* ---------- course shell ---------- */
.stc-main:has(> .lr-shell) { padding: 0; }
.lr-shell { display: flex; align-items: flex-start; }
.lr-side { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
.lr-scrim { display: none; }
.lr-main { flex: 1; min-width: 0; padding: 44px 40px 0; }
.lr-side-head { display: flex; gap: 12px; padding: 18px 16px 16px; border-bottom: 1px solid var(--line); }
.lr-side-head h2 { margin: 0; font-size: 14.5px; font-weight: 600; color: var(--head); line-height: 1.3; }
.lr-side-head h2 a { text-decoration: none; }
.lr-side-head .src { font-size: 12px; color: var(--faint); margin-top: 2px; }
.lr-side-links { display: flex; gap: 14px; margin-top: 8px; font-size: 12px; }
.lr-side-links a { color: var(--muted); text-decoration: none; }
.lr-side-links a:hover, .lr-side-links a[aria-current] { color: var(--head); }
.lr-prog { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12px; color: var(--muted); }
.lr-mod { border-bottom: 1px solid var(--line); }
.lr-mod-head { width: 100%; display: grid; grid-template-columns: 22px 1fr auto 12px; gap: 6px; align-items: baseline; padding: 11px 16px 7px; border: 0; background: none; text-align: left; font-size: 13.5px; color: var(--text); }
.lr-mod-head:hover { color: var(--head); }
.lr-mod-head .n, .lr-mod-head .c { color: var(--faint); font-size: 12px; }
.lr-mod-head svg { color: var(--faint); align-self: center; }
.lr-mod-head > svg { transition: transform var(--fast); }
.lr-mod-head[aria-expanded="true"] > svg { transform: rotate(90deg); }
.lr-mod.is-current .lr-mod-head { color: var(--head); font-weight: 500; }
.lr-mod > .stc-bar { margin: 0 16px 11px 44px; flex: none; }
.lr-lessons { list-style: none; margin: -3px 0 0; padding: 0 0 8px; }
.lr-les { display: grid; grid-template-columns: 22px 1fr auto; gap: 6px; align-items: center; padding: 6px 16px; font-size: 13px; line-height: 1.35; color: var(--muted); text-decoration: none; }
.lr-les:hover { background: color-mix(in srgb, var(--raised) 60%, transparent); color: var(--text); }
.lr-les .m { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--line2); justify-self: center; display: grid; place-items: center; }
.lr-les.done .m { border: 0; width: 12px; height: 12px; color: var(--faint); }
.lr-les.locked { color: var(--faint); }
.lr-les.locked .m { border: 0; width: 12px; height: 12px; color: var(--faint); }
.lr-les .d { font-size: 12px; color: var(--faint); }
.lr-les code, .lr-menu code { font-size: .92em; }
.lr-les[aria-current="page"] { background: var(--raised); color: var(--head); box-shadow: inset 2px 0 0 var(--accent); }
.lr-les[aria-current="page"] .m { border: 0; background: var(--accent); width: 8px; height: 8px; }
.lr-les[aria-current="page"] .m svg { display: none; }
.lr-les[aria-current="page"].done .m svg { display: block; }
.lr-les[aria-current="page"].done .m { background: none; color: var(--accent); width: 12px; height: 12px; }
.lr-nohit { padding: 16px; color: var(--faint); font-size: 13px; }
.lr-diag { margin: 12px 16px; padding: 10px 12px; border: 1px solid var(--line2); border-radius: 10px; font-size: 12px; color: var(--muted); }
.lr-diag b { color: var(--bad); font-weight: 500; }
.lr-diag ul { margin: 6px 0 0; padding-left: 16px; }
/* ---------- columns and headings ---------- */
.lr .col { max-width: var(--col); margin: 0 auto; }
.lr .wide { max-width: var(--wide); margin: 0 auto; }
.lr .chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
.lr h1.title { font: 600 var(--h1)/1.12 var(--tf); color: var(--head); margin: 0; letter-spacing: -.012em; text-wrap: balance; }
.lr h1.title code { font-size: .86em; }
.lr .meta { display: flex; flex-wrap: wrap; gap: 14px 30px; margin: 22px 0 0; padding-bottom: 22px; border-bottom: 1px solid var(--line); align-items: end; }
.lr .meta div { min-width: 0; }
.lr .meta dt { font-size: 12px; color: var(--faint); margin-bottom: 2px; }
.lr .meta dd { margin: 0; font-size: 13.5px; color: var(--text); }
.lr .meta dd code { font-size: 12.5px; }
.lr .source { font-size: 12.5px; color: var(--faint); margin: 14px 0 0; }
.lr .source strong { font-weight: 500; color: var(--muted); }
.lr .blockhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0 0 10px; flex-wrap: wrap; }
.lr .blockhead h2 { margin: 0; font: 600 15px/1.3 var(--sans); color: var(--head); }
.lr .blockhead > span { font-size: 12px; color: var(--faint); }
.lr .sec-h { font: 600 14px var(--sans); color: var(--muted); margin: 0 0 10px; }
.lr .muted { color: var(--faint); }
.lr .lr-block { margin-top: 40px; }
/* ---------- prose ---------- */
.lr .prose { font-family: var(--pf); font-size: var(--ps); line-height: var(--pl); color: var(--pc); overflow-wrap: break-word; }
.lr .prose p, .lr .prose ul, .lr .prose ol, .lr .prose dl, .lr .prose table { margin: 0 0 1em; }
.lr .prose > :first-child { margin-top: 0; }
.lr .prose h2 { font: 600 1.32em/1.25 var(--tf); color: var(--head); margin: 2em 0 .55em; letter-spacing: -.005em; }
.lr .prose h3 { font: 600 1.08em/1.3 var(--tf); color: var(--head); margin: 1.7em 0 .45em; }
.lr .prose h4, .lr .prose h5, .lr .prose h6 { font: 600 1em/1.35 var(--tf); color: var(--head); margin: 1.4em 0 .4em; }
.lr .prose strong { font-weight: 600; color: var(--head); }
.lr .prose li { margin-bottom: .35em; padding-left: .2em; }
.lr .prose a { color: var(--head); text-decoration-color: var(--line2); text-underline-offset: 3px; }
.lr .prose a:hover { text-decoration-color: var(--accent); }
.lr .prose img { max-width: 100%; height: auto; border-radius: 8px; }
.lr .prose blockquote { border-left: 2px solid var(--line2); padding-left: 18px; color: var(--muted); font-size: .92em; margin: 0 0 1.2em; }
.lr .prose blockquote strong { color: var(--text); }
.lr .prose hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
.lr .prose table { border-collapse: collapse; font-family: var(--sans); font-size: 14px; display: block; overflow-x: auto; }
.lr .prose th, .lr .prose td { border-bottom: 1px solid var(--line); padding: 6px 12px 6px 0; text-align: left; vertical-align: top; }
.lr .prose th { color: var(--muted); font-weight: 500; }
.lr .prose :not(pre) > code { font-size: .78em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; color: var(--text); }
.lr pre.code { font-size: 13.5px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin: 0 0 1.2em; overflow-x: auto; color: var(--text); tab-size: 2; }
.lr pre.code code { font-size: inherit; padding: 0; background: none; }
.lr .prose kbd { font: 500 .7em/1 var(--mono); padding: 2px 5px; border: 1px solid var(--line2); border-radius: 4px; }
/* folds and hint tiers */
.lr details.fold { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 0 0 1.4em; }
.lr details.fold + details.fold { margin-top: -1.4em; border-top: 0; }
.lr details.fold > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 12px 2px; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
.lr details.fold > summary::-webkit-details-marker { display: none; }
.lr details.fold > summary svg { color: var(--faint); transition: transform var(--fast); flex: none; }
.lr details.fold[open] > summary svg, .lr details.fold[open] > summary .chev { transform: rotate(90deg); }
.lr details.fold > summary .chev { flex: none; width: 10px; height: 10px; display: grid; place-items: center; color: var(--faint); transition: transform var(--fast); }
.lr details.fold > summary .chev::before { content: ""; width: 5px; height: 5px; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: translateX(-1px) rotate(-45deg); }
.lr details.fold > summary:hover { color: var(--head); }
.lr details.fold > summary .tier { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: 400; white-space: nowrap; }
.lr details.fold[data-wait] > summary { color: var(--faint); cursor: default; }
.lr details.fold > .in { padding: 2px 0 14px 24px; }
.lr .hints .fold > .in { font-size: .95em; }
/* ---------- pills ---------- */
.lr .stc-pill.sm { height: 28px; padding: 0 11px; font-size: 12.5px; }
.lr a.stc-pill { color: inherit; }
.lr a.stc-pill--primary { color: var(--on-accent); }
/* ---------- floating action bar ---------- */
.lr .stc-dock [aria-pressed="true"] { color: var(--accent); }
.lr .stc-dock .sep { width: 1px; background: var(--line2); margin: 7px 2px; }
.lr .stc-dock a.stc-pill[aria-disabled="true"] { opacity: .45; pointer-events: none; }
/* ---------- video ---------- */
.lr .player { aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; margin-top: 22px; }
.lr .player video { display: block; width: 100%; height: 100%; }
.lr .split { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 40px; margin-top: 28px; }
.lr .note-ta { width: 100%; min-height: 110px; resize: vertical; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; color: var(--text); font-size: 14px; line-height: 1.55; display: block; }
.lr .note-ta:focus { outline: 0; border-color: var(--faint); }
.lr .note-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; align-items: center; }
.lr .note-actions .msg { margin-right: auto; font-size: 12px; color: var(--faint); }
.lr .note-list { list-style: none; margin: 18px 0 0; padding: 0; border-top: 1px solid var(--line); }
.lr .note-list > li { padding: 12px 2px; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 1.55; }
.lr .note-list .nb { white-space: pre-wrap; overflow-wrap: anywhere; }
.lr .note-list .nm { display: flex; gap: 10px; align-items: center; margin-top: 6px; font-size: 12px; color: var(--faint); }
.lr .note-list .nm button { border: 0; background: none; padding: 0; color: var(--faint); font-size: 12px; }
.lr .note-list .nm button:hover { color: var(--text); }
.lr .stamp { border: 0; background: none; padding: 0 2px; color: var(--accent); font: inherit; font-variant-numeric: tabular-nums; text-decoration: underline; text-decoration-color: var(--accent-dim); text-underline-offset: 3px; }
.lr .transcript { margin-top: 22px; }
/* rows (attachments) */
.lr .rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.lr .rows li { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); font-size: 13.5px; }
.lr .rows li svg { color: var(--faint); }
.lr .rows a { overflow-wrap: anywhere; text-decoration: none; }
.lr .rows a:hover { text-decoration: underline; text-underline-offset: 3px; }
.lr .rows .sz { white-space: nowrap; font-size: 12px; color: var(--faint); }
/* ---------- slides ---------- */
.lr .deck { margin-top: 28px; }
.lr .stage { position: relative; aspect-ratio: 16 / 9; background: var(--side); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: grid; place-items: center; margin: 0; }
.lr .stage canvas { display: block; max-width: 100%; max-height: 100%; }
.lr .stage .msg { color: var(--faint); font-size: 13px; padding: 20px; text-align: center; }
.lr .stage:fullscreen { border-radius: 0; background: #000; border: 0; }
.lr .deckbar { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; flex-wrap: wrap; }
.lr .deckbar .count { font-size: 13px; color: var(--muted); margin: 0 6px; min-width: 58px; text-align: center; }
.lr .deckbar .gap { flex: 1; }
.lr .deckbar .hint { font-size: 12.5px; color: var(--faint); }
.lr .thumbs { display: flex; gap: 8px; margin: 16px 0 0; overflow-x: auto; padding: 3px 3px 6px; }
.lr .thumbs button { flex: none; width: 104px; aspect-ratio: 16 / 9; padding: 0; border: 1px solid var(--line2); border-radius: 5px; overflow: hidden; background: var(--side); opacity: .6; transition: opacity var(--fast); display: grid; place-items: center; color: var(--faint); font-size: 11px; position: relative; }
.lr .thumbs button:hover { opacity: .9; }
.lr .thumbs button[aria-current="true"] { opacity: 1; outline: 2px solid var(--accent); outline-offset: 2px; }
.lr .thumbs canvas { width: 100%; height: 100%; object-fit: contain; display: block; }
.lr .thumbs .has-note::after { content: ""; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.lr .slide-note { margin-top: 16px; }
.lr .slide-note label { display: block; font-size: 12px; color: var(--faint); margin-bottom: 6px; }
.lr .slide-note .note-ta { min-height: 72px; }
/* ---------- code files ---------- */
.lr .files { display: grid; grid-template-columns: 220px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin: 0 0 1.4em; font-family: var(--sans); }
.lr .tree { background: var(--side); border-right: 1px solid var(--line); padding: 8px 0; font-size: 13px; max-height: 520px; overflow: auto; }
.lr .tree .dir { padding: 5px 12px; color: var(--muted); display: flex; gap: 7px; align-items: center; white-space: nowrap; }
.lr .tree button { display: flex; gap: 7px; align-items: center; width: 100%; border: 0; background: none; text-align: left; padding: 5px 12px; color: var(--muted); font-size: 13px; white-space: nowrap; }
.lr .tree button:hover { color: var(--text); }
.lr .tree button[aria-current="true"] { background: var(--raised); color: var(--head); }
.lr .tree svg { flex: none; color: var(--faint); }
.lr .viewer { min-width: 0; display: flex; flex-direction: column; background: var(--ground); }
.lr .tabs { display: flex; align-items: center; border-bottom: 1px solid var(--line); padding: 0 6px 0 0; overflow-x: auto; min-height: 40px; }
.lr .tabs [role="tab"] { border: 0; background: none; padding: 10px 14px; font-size: 12.5px; color: var(--faint); border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; font-family: var(--mono); }
.lr .tabs [role="tab"][aria-selected="true"] { color: var(--head); border-bottom-color: var(--accent); }
.lr .tabs .stc-pill { margin-left: auto; }
.lr .path { font-size: 12px; color: var(--faint); padding: 8px 16px 0; overflow-wrap: anywhere; }
.lr .viewer pre.code { border: 0; border-radius: 0; background: none; margin: 0; counter-reset: ln; max-height: 520px; overflow: auto; }
.lr .ln { display: block; counter-increment: ln; }
.lr .ln::before { content: counter(ln); display: inline-block; width: 2.2em; margin-right: 1em; text-align: right; color: var(--faint); opacity: .6; }
.lr .cmd { position: relative; }
.lr .cmd pre.code { padding-right: 80px; white-space: pre-wrap; overflow-wrap: anywhere; }
.lr .cmd .stc-pill { position: absolute; top: 8px; right: 8px; }
/* ---------- exercise ---------- */
.lr .ex { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 36px; align-items: start; }
.lr .ex .prose { font-size: 15px; }
.lr .edit { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: sticky; top: calc(var(--top) + 20px); }
.lr .edit-bar { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 14px; background: var(--side); border-bottom: 1px solid var(--line); }
.lr .edit-bar .f { font: 500 12.5px var(--mono); color: var(--muted); margin-right: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lr .editor { display: grid; grid-template-columns: 44px 1fr; background: var(--ground); height: 300px; }
.lr .gutter { margin: 0; padding: 12px 10px 12px 0; text-align: right; color: var(--faint); opacity: .6; font: 13.5px/1.6 var(--mono); overflow: hidden; user-select: none; }
.lr .editor textarea { border: 0; resize: none; outline: 0; background: none; padding: 12px 14px 12px 6px; font: 13.5px/1.6 var(--mono); color: var(--text); white-space: pre; overflow: auto; tab-size: 2; }
.lr .editor:focus-within { box-shadow: inset 0 0 0 1px var(--faint); }
.lr .results { border-top: 1px solid var(--line); background: var(--side); }
.lr .res-sum { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; font-size: 13px; color: var(--muted); }
.lr .res-sum b { font-weight: 600; color: var(--head); }
.lr .res-list { list-style: none; margin: 0; padding: 0; max-height: 300px; overflow: auto; border-top: 1px solid var(--line); }
.lr .res-list li { padding: 8px 14px; border-bottom: 1px solid var(--line); font-size: 13px; }
.lr .res-list li:last-child { border-bottom: 0; }
.lr .res-row { display: grid; grid-template-columns: 40px 1fr; gap: 8px; }
.lr .res-row .s { font-weight: 600; font-size: 12px; }
.lr .pass .s { color: var(--ok); }
.lr .fail .s { color: var(--bad); }
.lr .res-row code { font-size: 12.5px; color: var(--text); }
.lr .diff { display: grid; grid-template-columns: 64px 1fr; gap: 2px 8px; margin: 6px 0 2px 48px; font-size: 12.5px; }
.lr .diff dt { color: var(--faint); }
.lr .diff dd { margin: 0; font-family: var(--mono); color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.lr .local { margin: 20px 0 0; padding: 12px 0 0; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); }
.lr .local strong { color: var(--text); font-weight: 500; }
.lr .local code { display: block; margin-top: 6px; font-size: 12.5px; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.lr .local-panel { border: 1px solid var(--line); border-radius: 10px; padding: 16px; background: var(--side); }
.lr .local-panel h2 { margin: 0 0 6px; font: 600 15px/1.3 var(--sans); color: var(--head); }
.lr .local-panel p { margin: 0 0 12px; font-size: 13.5px; color: var(--muted); }
.lr .local-panel .cmd pre.code { background: var(--ground); }
.lr .local-panel textarea { min-height: 90px; font-family: var(--mono); font-size: 12.5px; }
/* ---------- lock, empty, error ---------- */
.lr .notice { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 18px 2px; margin-top: 28px; color: var(--muted); font-size: 14px; }
.lr .notice strong { color: var(--head); font-weight: 500; }
.lr .notice .stc-pill { margin-top: 12px; }
/* ---------- library ---------- */
.lr-lib { max-width: 1080px; margin: 0 auto; padding-bottom: 60px; }
.lr .lib-h { font: 600 24px/1.2 var(--sans); color: var(--head); margin: 0 0 4px; }
.lr .lib-sub { color: var(--faint); margin: 0 0 32px; font-size: 13.5px; }
.lr .feat { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 28px; align-items: end; padding: 0 0 32px; border-bottom: 1px solid var(--line); margin-bottom: 36px; }
.lr a:has(> .stc-cover), .lr a .stc-cover { text-decoration: none; }
.lr .feat .k { font-size: 12.5px; color: var(--faint); margin: 0 0 8px; }
.lr .feat h2 { margin: 0; font: 600 30px/1.15 var(--sans); color: var(--head); letter-spacing: -.01em; text-wrap: balance; }
.lr .feat h2 a { text-decoration: none; }
.lr .feat p.pitch { margin: 10px 0 0; font-size: 15px; color: var(--muted); max-width: 560px; }
.lr .feat .facts { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 14px 0 18px; font-size: 12.5px; color: var(--faint); }
.lr .feat .actions { display: flex; gap: 8px; align-items: center; }
.lr .continue { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; padding: 18px 0; border-bottom: 1px solid var(--line); }
.lr .continue-list { border-top: 1px solid var(--line); margin-bottom: 44px; }
.lr .continue .t { font-size: 16px; font-weight: 600; color: var(--head); }
.lr .continue .s { font-size: 13.5px; color: var(--muted); margin: 2px 0 10px; }
.lr .continue .lr-prog { max-width: 360px; margin: 0; }
.lr .filters { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin: 0 0 30px; }
.lr .filters .lbl { font-size: 12px; color: var(--faint); margin-right: -6px; }
.lr .lib-row-h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.lr .strip-wrap { margin-bottom: 40px; }
.lr .strip { display: flex; gap: 22px; overflow-x: auto; padding: 4px 2px 12px; scroll-snap-type: x proximity; }
.lr .tile { flex: none; width: 150px; text-decoration: none; scroll-snap-align: start; }
.lr .tile .stc-cover { --cw: 150px; }
.lr .tile .tt { display: block; margin-top: 10px; font-size: 13.5px; font-weight: 600; color: var(--text); line-height: 1.3; }
.lr .tile:hover .tt { color: var(--head); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line2); }
.lr .tile .ts { display: block; font-size: 12px; color: var(--faint); margin-top: 2px; }
.lr .tile .stc-bar { margin-top: 8px; }
.lr .lib { border-top: 1px solid var(--line); }
.lr .lib-row, .lr .lib-cols { display: grid; grid-template-columns: minmax(0, 1fr) 90px 210px 180px; gap: 24px; align-items: center; }
.lr .lib-cols { padding: 0 0 8px; font-size: 12px; color: var(--faint); }
.lr .lib-row { padding: 16px 0; border-bottom: 1px solid var(--line); text-decoration: none; }
.lr .lib-row:hover .lt { color: var(--head); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line2); }
.lr .lib-main { display: flex; gap: 16px; align-items: center; min-width: 0; }
.lr .lt { font-size: 15px; font-weight: 600; color: var(--text); }
.lr .ls { display: block; font-size: 13px; color: var(--faint); margin-top: 2px; }
.lr .lnum { font-size: 13.5px; color: var(--muted); }
.lr .tags { display: flex; flex-wrap: wrap; gap: 5px; }
.lr .tag { font-size: 11.5px; color: var(--muted); border: 1px solid var(--line2); border-radius: 10px; padding: 1px 8px; }
.lr .tag.bad { color: var(--bad); }
.lr .lib-row .lr-prog { margin: 0; }
.lr .hits { list-style: none; margin: 0 0 36px; padding: 0; border-top: 1px solid var(--line); }
.lr .hits li a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 10px 2px; border-bottom: 1px solid var(--line); text-decoration: none; font-size: 13.5px; }
.lr .hits li a:hover .h1 { color: var(--head); text-decoration: underline; text-underline-offset: 3px; }
.lr .hits .h2 { font-size: 12px; color: var(--faint); }
/* ---------- notes page, account ---------- */
.lr .notes-group { margin-top: 30px; }
.lr .notes-group h2 { font: 600 15px/1.3 var(--sans); margin: 0 0 6px; }
.lr .notes-group h2 a { color: var(--head); text-decoration: none; }
.lr .notes-group h2 a:hover { text-decoration: underline; text-underline-offset: 3px; }
.lr .notes-group .note-list { margin-top: 8px; }
.lr .anchor { font-size: 12px; color: var(--muted); text-decoration: none; border: 1px solid var(--line2); border-radius: 10px; padding: 1px 8px; }
.lr .anchor:hover { color: var(--head); }
.lr .form { display: grid; gap: 14px; max-width: 380px; }
.lr .form label { display: grid; gap: 5px; font-size: 12.5px; color: var(--muted); }
.lr .form input, .lr .field { height: 38px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 10px; background: var(--side); color: var(--text); font-size: 14px; }
.lr .form input:focus, .lr .field:focus { outline: 0; border-color: var(--faint); }
.lr select.field { padding-right: 8px; }
.lr .form .err, .lr .err { color: var(--bad); font-size: 13px; margin: 0; }
.lr .ok-msg { color: var(--ok); font-size: 13px; margin: 0; }
.lr .or { display: flex; align-items: center; gap: 12px; color: var(--faint); font-size: 12px; margin: 26px 0 16px; max-width: 380px; }
.lr .or::before, .lr .or::after { content: ""; flex: 1; border-top: 1px solid var(--line); }
.lr .prov { display: flex; flex-wrap: wrap; gap: 8px; max-width: 380px; }
.lr .acct-dl { display: grid; grid-template-columns: 90px 1fr; gap: 8px 16px; margin: 22px 0; font-size: 14px; }
.lr .acct-dl dt { color: var(--faint); }
.lr .acct-dl dd { margin: 0; }
.lr .admin-sec { margin-top: 44px; }
.lr .admin-sec > h2 { font: 600 16px/1.3 var(--sans); color: var(--head); margin: 0 0 12px; }
.lr .table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.lr .table th { text-align: left; font-weight: 500; font-size: 12px; color: var(--faint); padding: 0 12px 8px 0; border-bottom: 1px solid var(--line); }
.lr .table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
.lr .table td .ls { margin: 0; }
.lr .checks { display: flex; flex-wrap: wrap; gap: 6px 16px; }
.lr .checks label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--muted); }
.lr input[type="checkbox"] { accent-color: var(--accent); }
.lr .row-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0 0; }
.lr-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60; background: var(--raised); border: 1px solid var(--line2); border-radius: 18px; padding: 9px 16px; font-size: 13px; color: var(--text); }
/* ---------- responsive ---------- */
@media (max-width: 1100px) {
.lr .ex { grid-template-columns: minmax(0, 1fr); }
.lr .edit { position: static; }
.lr .split { grid-template-columns: 1fr; gap: 28px; }
.lr .lib-row, .lr .lib-cols { grid-template-columns: minmax(0, 1fr) 70px 150px; }
.lr .lib-row > .tags, .lr .lib-cols > :nth-child(3) { display: none; }
}
@media (max-width: 960px) {
.stc-top > a:first-child { flex: none; }
.lr-icon { display: inline-grid; }
.lr-side { position: fixed; top: var(--top); left: 0; bottom: 0; height: auto; z-index: 40; transform: translateX(-100%); transition: transform 160ms ease-out; }
.lr-shell.side-open .lr-side { transform: none; }
.lr-shell.side-open .lr-scrim { display: block; position: fixed; inset: var(--top) 0 0 0; z-index: 35; background: rgb(10 11 15 / .6); }
.lr-main { padding: 32px 24px 0; }
}
@media (max-width: 680px) {
.stc-top { gap: 8px; padding: 0 10px; }
.stc-top > a:first-child { display: none; }
.lr-search { display: none; }
.lr-switch-btn { max-width: 170px; }
.lr-switch-btn .t { font-size: 12.5px; }
.lr .stc-seg button { padding: 0 10px; }
.lr-main { padding: 26px 18px 0; }
.lr[data-look="reading"], .lr [data-look="reading"] { --ps: 18px; --h1: 31px; }
.lr .meta { gap: 12px 22px; }
.lr .files { grid-template-columns: 1fr; }
.lr .tree { border-right: 0; border-bottom: 1px solid var(--line); max-height: 200px; }
.lr .continue { grid-template-columns: auto 1fr; }
.lr .continue .stc-pill { grid-column: 1 / -1; justify-self: start; }
.lr .feat { grid-template-columns: 1fr; }
.lr .lib-row, .lr .lib-cols { grid-template-columns: minmax(0, 1fr) 92px; gap: 14px; }
.lr .lib-row > .lnum, .lr .lib-cols > :nth-child(2) { display: none; }
.lr-menu { width: min(330px, calc(100vw - 20px)); left: -48px; }
.lr .stc-dock { bottom: 12px; }
.lr .stc-dock .lbl-long { display: none; }
.lr .thumbs button { width: 84px; }
}
@media (prefers-reduced-motion: reduce) {
.lr *, .lr *::before, .lr *::after, .lr-side { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
`;
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { SignInForms } from "../src/web/account/AccountPage";
import AdminPage from "../src/web/account/AdminPage";
test("the sign-in page has email and password fields and a create-account switch", () => {
const out = renderToString(<MemoryRouter initialEntries={["/account?next=/c/sample"]}><SignInForms /></MemoryRouter>);
expect(out).toContain('type="email"');
expect(out).toContain('type="password"');
expect(out).toContain('autoComplete="current-password"');
expect(out).toContain("Create account");
});
test("the admin page renders its frame before the session is known", () => {
const out = renderToString(<MemoryRouter><AdminPage /></MemoryRouter>);
expect(out).toContain("Users, groups and library");
expect(out).not.toContain("Users and roles"); // owner-only sections wait for the session
});
@@ -0,0 +1,79 @@
import { expect } from "bun:test";
import type { Db } from "@stc/db";
import { bunSqlAdapter } from "../src/server/auth/adapter";
import { forEachDb } from "./helpers";
// biome-ignore lint/suspicious/noExplicitAny: adapter is generic over Better Auth options
const make = (db: Db): any => bunSqlAdapter(db)({});
async function seed(a: ReturnType<typeof make>) {
const people = [
{ name: "Ada", email: "[email protected]", emailVerified: true },
{ name: "Bob", email: "[email protected]", emailVerified: false },
{ name: "Cyd", email: "[email protected]", emailVerified: false },
];
const out = [];
for (const p of people) out.push(await a.create({ model: "user", data: { ...p, createdAt: new Date(), updatedAt: new Date() } }));
return out;
}
forEachDb("adapter create and findOne round-trip types", async (db) => {
const a = make(db);
const [ada] = await seed(a);
expect(typeof ada.id).toBe("string");
const found = await a.findOne({ model: "user", where: [{ field: "email", value: "[email protected]" }] });
expect(found.name).toBe("Ada");
expect(found.emailVerified).toBe(true);
expect(found.createdAt).toBeInstanceOf(Date);
expect(await a.findOne({ model: "user", where: [{ field: "email", value: "nobody@x" }] })).toBeNull();
});
forEachDb("adapter where operators", async (db) => {
const a = make(db);
await seed(a);
const names = async (where: unknown[], extra: Record<string, unknown> = {}) =>
(await a.findMany({ model: "user", where, sortBy: { field: "name", direction: "asc" }, ...extra })).map((u: { name: string }) => u.name);
expect(await names([{ field: "name", operator: "ne", value: "Ada" }])).toEqual(["Bob", "Cyd"]);
expect(await names([{ field: "name", operator: "in", value: ["Ada", "Cyd"] }])).toEqual(["Ada", "Cyd"]);
expect(await names([{ field: "name", operator: "not_in", value: ["Ada"] }])).toEqual(["Bob", "Cyd"]);
expect(await names([{ field: "name", operator: "in", value: [] }])).toEqual([]);
expect(await names([{ field: "email", operator: "contains", value: "example" }])).toEqual(["Ada", "Cyd"]);
expect(await names([{ field: "email", operator: "contains", value: "Example" }])).toEqual(["Bob"]);
expect(await names([{ field: "email", operator: "contains", value: "EXAMPLE.ORG", mode: "insensitive" }])).toEqual(["Bob"]);
expect(await names([{ field: "email", operator: "starts_with", value: "cy" }])).toEqual(["Cyd"]);
expect(await names([{ field: "email", operator: "ends_with", value: ".com" }])).toEqual(["Ada", "Cyd"]);
expect(await names([{ field: "email", value: "[email protected]", mode: "insensitive" }])).toEqual(["Bob"]);
expect(await names([{ field: "name", operator: "gt", value: "Ada" }])).toEqual(["Bob", "Cyd"]);
expect(await names([{ field: "name", operator: "lte", value: "Bob" }])).toEqual(["Ada", "Bob"]);
expect(await names([{ field: "emailVerified", value: false }])).toEqual(["Bob", "Cyd"]);
// AND clauses AND-ed, OR clauses grouped
expect(await names([
{ field: "emailVerified", value: false },
{ field: "name", value: "Ada", connector: "OR" },
{ field: "name", value: "Cyd", connector: "OR" },
])).toEqual(["Cyd"]);
expect(await names([], { limit: 1, offset: 1 })).toEqual(["Bob"]);
expect(await names([], { limit: 2, sortBy: { field: "name", direction: "desc" } })).toEqual(["Cyd", "Bob"]);
expect(await names([{ field: "image", value: null }])).toEqual(["Ada", "Bob", "Cyd"]);
});
forEachDb("adapter update, count, delete, consume, increment", async (db) => {
const a = make(db);
const [ada, bob] = await seed(a);
const up = await a.update({ model: "user", where: [{ field: "id", value: ada.id }], update: { name: "Ada L." } });
expect(up.name).toBe("Ada L.");
expect(await a.updateMany({ model: "user", where: [{ field: "emailVerified", value: false }], update: { image: "x.png" } })).toBe(2);
expect(await a.count({ model: "user", where: [{ field: "image", value: "x.png" }] })).toBe(2);
expect(await a.count({ model: "user" })).toBe(3);
await a.delete({ model: "user", where: [{ field: "id", value: bob.id }] });
expect(await a.count({ model: "user" })).toBe(2);
expect(await a.deleteMany({ model: "user", where: [{ field: "image", value: "x.png" }] })).toBe(1);
const exp = new Date(Date.now() + 60_000);
await a.create({ model: "verification", data: { identifier: "k", value: "v1", expiresAt: exp, createdAt: new Date(), updatedAt: new Date() } });
const consumed = await a.consumeOne({ model: "verification", where: [{ field: "identifier", value: "k" }] });
expect(consumed?.value).toBe("v1");
expect(await a.consumeOne({ model: "verification", where: [{ field: "identifier", value: "k" }] })).toBeNull();
const live = await a.findMany({ model: "verification", where: [{ field: "expiresAt", operator: "gt", value: new Date() }] });
expect(live.length).toBe(0);
});
@@ -0,0 +1,43 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { makeApp } from "./app";
forEachDb("admin API is owner-only and manages roles, groups, access and library", async (db) => {
const t = await makeApp(db);
const owner = await t.signUp("[email protected]");
const learner = await t.signUp("[email protected]");
expect((await t.call("GET", "/api/admin/users")).status).toBe(401);
expect((await t.call("GET", "/api/admin/users", { cookie: learner })).status).toBe(403);
const users = await (await t.call("GET", "/api/admin/users", { cookie: owner })).json();
expect(users.map((u: { email: string }) => u.email)).toEqual(["[email protected]", "[email protected]"]);
const [o, l] = users;
// the last owner keeps the role
expect((await t.call("PUT", `/api/admin/users/${o.id}/roles`, { cookie: owner, body: { roles: ["learner"] } })).status).toBe(409);
expect((await t.call("PUT", `/api/admin/users/${l.id}/roles`, { cookie: owner, body: { roles: ["bogus"] } })).status).toBe(400);
const r = await t.call("PUT", `/api/admin/users/${l.id}/roles`, { cookie: owner, body: { roles: ["author", "learner"] } });
expect((await r.json()).roles).toEqual(["author", "learner"]);
expect((await (await t.call("GET", "/api/me", { cookie: learner })).json()).user.roles).toEqual(["author", "learner"]);
const g = await (await t.call("POST", "/api/admin/groups", { cookie: owner, body: { name: "DevOps Team" } })).json();
expect(g.id).toBe("devops-team");
await t.call("PUT", `/api/admin/groups/${g.id}/members`, { cookie: owner, body: { userIds: [l.id] } });
const groups = await (await t.call("GET", "/api/admin/groups", { cookie: owner })).json();
expect(groups).toEqual([{ id: "devops-team", name: "DevOps Team", members: [l.id] }]);
expect((await (await t.call("GET", "/api/me", { cookie: learner })).json()).user.groups).toEqual(["devops-team"]);
await t.call("PUT", "/api/admin/courses/sample/access", { cookie: owner, body: { acl: [{ principal_type: "group", principal_id: g.id }], authors: [l.id] } });
const access = await (await t.call("GET", "/api/admin/courses/sample/access", { cookie: owner })).json();
expect(access).toEqual({ visibility: "public", acl: [{ principal_type: "group", principal_id: "devops-team" }], authors: [l.id] });
expect((await t.call("GET", "/api/admin/courses/nope/access", { cookie: owner })).status).toBe(404);
const libr = await (await t.call("PUT", "/api/admin/library", { cookie: owner, body: { featured: "sample", collections: [{ title: "Getting started", courseIds: ["sample"] }] } })).json();
expect(libr.featured).toBe("sample");
expect(libr.collections).toEqual([{ id: "getting-started", title: "Getting started", sort: 0, courseIds: ["sample"] }]);
const cleared = await (await t.call("PUT", "/api/admin/library", { cookie: owner, body: { collections: [] } })).json();
expect(cleared.collections).toEqual([]);
await t.call("DELETE", `/api/admin/groups/${g.id}`, { cookie: owner });
expect(await (await t.call("GET", "/api/admin/groups", { cookie: owner })).json()).toEqual([]);
}, 30_000);
+48
View File
@@ -0,0 +1,48 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AppConfig, JobQueue, ServerContext } from "@stc/contracts";
import { createApp } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import type { Db } from "@stc/db";
import { learnerFeature } from "../src/server/feature";
export const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses");
export const ORIGIN = "http://localhost:3000";
const fakeJobs: JobQueue = { enqueue: async () => "j", get: async () => null, list: async () => [] };
export async function makeApp(db: Db, courseDirs: string[] = [FIXTURES]) {
const config: AppConfig = {
port: 0, databaseUrl: "", dataDir: mkdtempSync(join(tmpdir(), "stc-learner-")), courseDirs,
secret: "test-secret-0123456789abcdef0123456789", publicUrl: ORIGIN,
};
const ctx: ServerContext = {
config, db, courses: await createCourseRepository(courseDirs), jobs: fakeJobs,
auth: { userFromRequest: async () => null },
};
const feature = learnerFeature(ctx);
ctx.auth = feature.auth;
const app = createApp(ctx, [feature]);
async function call(method: string, path: string, opts: { body?: unknown; cookie?: string; headers?: Record<string, string> } = {}) {
const headers: Record<string, string> = { origin: ORIGIN, ...opts.headers };
if (opts.cookie) headers.cookie = opts.cookie;
let body: BodyInit | undefined;
if (opts.body !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(opts.body); }
return app.fetch(new Request(ORIGIN + path, { method, headers, body }));
}
/** Signs up and returns a cookie header for the new session. */
async function signUp(email: string, name = email.split("@")[0]!, password = "correct horse battery") {
const r = await call("POST", "/api/auth/sign-up/email", { body: { email, name, password } });
if (r.status !== 200) throw new Error(`sign-up failed ${r.status}: ${await r.text()}`);
return cookieFrom(r);
}
return { app, ctx, feature, call, signUp };
}
export function cookieFrom(r: Response): string {
return r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; ");
}
@@ -0,0 +1,11 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { makeApp } from "./app";
forEachDb("the pdf.js worker is served for client-side deck rendering", async (db) => {
const { call } = await makeApp(db);
const r = await call("GET", "/api/learner/pdf.worker.mjs");
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toContain("javascript");
expect((await r.text()).length).toBeGreaterThan(100_000);
});
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { assignInitialRole } from "../src/server/auth";
import { syncClaimGroups, groupsFromClaims } from "../src/server/auth/groups";
import { forEachDb } from "./helpers";
import { cookieFrom, makeApp } from "./app";
forEachDb("first sign-up becomes owner, later ones learners", async (db) => {
const t = await makeApp(db);
const ownerCookie = await t.signUp("[email protected]");
const me = await (await t.call("GET", "/api/me", { cookie: ownerCookie })).json();
expect(me.user.email).toBe("[email protected]");
expect(me.user.roles).toEqual(["owner"]);
const second = await t.signUp("[email protected]");
expect((await (await t.call("GET", "/api/me", { cookie: second })).json()).user.roles).toEqual(["learner"]);
expect((await (await t.call("GET", "/api/me")).json()).user).toBeNull();
}, 20_000);
forEachDb("sign in with password and sign out", async (db) => {
const t = await makeApp(db);
await t.signUp("[email protected]", "A", "pw-123456789");
const bad = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "[email protected]", password: "nope-nope-nope" } });
expect(bad.status).toBe(401);
const ok = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "[email protected]", password: "pw-123456789" } });
expect(ok.status).toBe(200);
const cookie = cookieFrom(ok);
expect((await (await t.call("GET", "/api/me", { cookie })).json()).user.name).toBe("A");
const out = await t.call("POST", "/api/auth/sign-out", { cookie, body: {} });
expect(out.status).toBe(200);
// the session row is gone: the old token no longer resolves once the cookie cache is dropped
const sessionOnly = cookie.split("; ").filter((c) => !c.includes("session_data")).join("; ");
expect((await (await t.call("GET", "/api/me", { cookie: sessionOnly })).json()).user).toBeNull();
}, 20_000);
forEachDb("owner claim is exclusive under concurrency", async (db) => {
const roles = await Promise.all(["u1", "u2", "u3", "u4"].map((u) => assignInitialRole(db, u)));
expect(roles.filter((r) => r === "owner").length).toBe(1);
});
forEachDb("SSO provider administration is owner-only", async (db) => {
const t = await makeApp(db);
await t.signUp("[email protected]");
const learner = await t.signUp("[email protected]");
expect((await t.call("POST", "/api/auth/sso/register", { cookie: learner, body: {} })).status).toBe(403);
expect((await t.call("POST", "/api/auth/sso/register", { body: {} })).status).toBe(403);
}, 20_000);
forEachDb("claim groups sync keeps manual memberships", async (db) => {
await db.sql`INSERT INTO groups (id, name) VALUES ('staff', 'Staff')`;
await db.sql`INSERT INTO group_members (group_id, user_id) VALUES ('staff', 'u1')`;
await syncClaimGroups(db, "u1", ["Staff", "DevOps Team"]);
const ids = async () => (await db.sql`SELECT group_id FROM group_members WHERE user_id = 'u1' ORDER BY group_id`).map((r: { group_id: string }) => r.group_id);
expect(await ids()).toEqual(["devops-team", "staff"]);
await syncClaimGroups(db, "u1", []);
expect(await ids()).toEqual(["staff"]); // manual one stays, claim one goes
});
test("groupsFromClaims reads arrays and strings", () => {
expect(groupsFromClaims({ groups: ["a", "b", "a"] })).toEqual(["a", "b"]);
expect(groupsFromClaims({ roles: "x, y" }, "roles")).toEqual(["x", "y"]);
expect(groupsFromClaims({ extraFields: { groups: ["z"] } })).toEqual(["z"]);
expect(groupsFromClaims(null)).toEqual([]);
});
@@ -0,0 +1,220 @@
// Headless-Chrome checks for the two security findings (C1 stored XSS, C2 runner isolation).
// Skipped when no Chrome is installed.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { connect, type Db } from "@stc/db";
import { runMigrations } from "../src/server/migrate";
import { makeApp, FIXTURES } from "./app";
import { Chrome, chromePath } from "./chrome";
interface Hit { path: string; cookie: string | null }
let chrome: Chrome, db: Db, app: Bun.Server<undefined>, probe: Bun.Server<undefined>;
const hits: Hit[] = [];
let P = "", ORIGIN = "";
const run = chromePath ? describe : describe.skip;
/** Navigates to a harness page and waits until its bundle has loaded. */
async function openHarness(path: string) {
await chrome.goto(`${ORIGIN}${path}`, 200);
for (let i = 0; i < 100; i++) {
// The old document still has stcReady until the navigation commits, so check the location as well.
if (await chrome.eval<boolean>(`location.pathname === ${JSON.stringify(path)} && window.stcReady === true`).catch(() => false)) return;
await Bun.sleep(100);
}
throw new Error(`harness ${path} did not load`);
}
run("browser isolation (headless Chrome)", () => {
beforeAll(async () => {
// Bundled in a separate process: Bun.build inside a full bun test run is unreliable (EISDIR on node_modules).
const out = join(mkdtempSync(join(tmpdir(), "stc-harness-")), "harness.js");
const build = Bun.spawnSync([process.execPath, "build", join(import.meta.dir, "e2e/harness.tsx"), "--target=browser", "--outfile", out], { stdout: "pipe", stderr: "pipe" });
if (build.exitCode !== 0) throw new Error(`harness build failed: ${build.stderr.toString()}`);
const js = await Bun.file(out).text();
// A separate origin that records every request (and the cookie it carried). Cookies ignore ports, so a
// request from a same-site context would carry the page's cookie here.
probe = Bun.serve({
hostname: "localhost", port: 0,
fetch(req) {
const u = new URL(req.url);
hits.push({ path: u.pathname, cookie: req.headers.get("cookie") });
const js = u.pathname.endsWith(".js");
return new Response(js ? "export default 1;" : "ok", { headers: { "Access-Control-Allow-Origin": "*", "Content-Type": js ? "text/javascript" : "text/plain" } });
},
});
P = `http://localhost:${probe.port}`;
db = await connect("sqlite://:memory:");
await runMigrations(db);
const { app: learner } = await makeApp(db, [FIXTURES]);
// The SPA's own CSP meta tag, taken from apps/web/index.html (the policy the learner app runs under).
const meta = /<meta\s+http-equiv="Content-Security-Policy"[^>]*>/i.exec(readFileSync(join(import.meta.dir, "../../../apps/web/index.html"), "utf8"))![0];
const page = (csp: boolean) => `<!doctype html><html><head><meta charset="utf-8">${csp ? meta : ""}<script type="module" src="/harness.js"></script></head><body><div id="root"></div></body></html>`;
app = Bun.serve({
hostname: "localhost", port: 0,
fetch(req) {
const u = new URL(req.url);
// The harness page gets the same CSP as the SPA document.
if (u.pathname === "/harness.html") return new Response(page(true), { headers: { "Content-Type": "text/html", "Set-Cookie": "stc_probe=secret-session; Path=/; SameSite=Lax" } });
// The same page without any CSP: the runner must isolate learner code on its own.
if (u.pathname === "/harness-nocsp.html") return new Response(page(false), { headers: { "Content-Type": "text/html" } });
// Same-origin probe: a request here from learner code would carry the session cookie.
if (u.pathname.startsWith("/cookie-probe")) {
hits.push({ path: `app:${u.pathname}`, cookie: req.headers.get("cookie") });
return new Response("export default 1;", { headers: { "Content-Type": "text/javascript" } });
}
if (u.pathname === "/harness.js") return new Response(js, { headers: { "Content-Type": "text/javascript" } });
return learner.fetch(req);
},
});
ORIGIN = `http://localhost:${app.port}`;
chrome = await Chrome.launch();
await chrome.goto(`${ORIGIN}/harness.html`, 2000);
expect(await chrome.eval<boolean>("window.stcReady === true")).toBe(true);
}, 30_000);
afterAll(async () => {
await chrome?.close();
app?.stop(true);
probe?.stop(true);
await db?.close();
});
test("control: the probe sees requests from this browser, with the page's cookie", async () => {
// The SPA CSP (connect-src 'self') blocks fetches from the page itself, so the control is a top-level
// navigation. It proves that a leak would be recorded, and would carry the cookie.
await chrome.goto(`${P}/control`, 500);
await openHarness("/harness.html");
expect(await chrome.eval<boolean>("window.stcReady === true")).toBe(true);
const c = hits.find((h) => h.path === "/control");
expect(c).toBeDefined();
expect(c!.cookie ?? "").toContain("stc_probe=secret-session");
});
test("the SPA CSP itself blocks a page-level fetch to another origin", async () => {
const before = hits.length;
const r = await chrome.eval<string>(`fetch(${JSON.stringify(P + "/page-fetch")}).then(() => "open", (e) => "blocked")`);
expect(r).toBe("blocked");
expect(hits.slice(before)).toEqual([]);
});
test("the runner still works inside the sandboxed frame", async () => {
const dir = join(FIXTURES, "sample/exercises/add");
const tests = readFileSync(join(dir, "tests.js"), "utf8"), starter = readFileSync(join(dir, "starter.js"), "utf8");
const fail = await chrome.eval<{ passed: boolean; expected?: string; actual?: string }[]>(`stcRun(${JSON.stringify(starter)}, ${JSON.stringify(tests)}, 5000)`);
expect(fail.map((r) => r.passed)).toEqual([false, false]);
expect(fail[0]).toMatchObject({ expected: "5", actual: "-1" });
const pass = await chrome.eval<{ passed: boolean }[]>(`stcRun(${JSON.stringify(starter.replace("a - b", "a + b"))}, ${JSON.stringify(tests)}, 5000)`);
expect(pass.map((r) => r.passed)).toEqual([true, true]);
expect(await chrome.eval<number>("document.querySelectorAll('iframe').length")).toBe(0);
}, 20_000);
test("the timeout destroys the frame and its worker", async () => {
const tests = `export const tests = [{ name: "loop", run: (m) => m.spin() }];`;
const r = await chrome.eval<{ passed: boolean; message?: string }[]>(`stcRun("export function spin() { while (true) {} }", ${JSON.stringify(tests)}, 800)`);
expect(r[0]!.passed).toBe(false);
expect(r[0]!.message).toContain("Timed out");
expect(await chrome.eval<number>("document.querySelectorAll('iframe').length")).toBe(0);
}, 20_000);
const attack = (who: string) => `
const P = ${JSON.stringify(P)} + "/${who}";
const tryIt = (f) => { try { const r = f(); if (r && typeof r.then === "function") r.then(() => {}, () => {}); } catch (_) {} };
tryIt(() => fetch(P + "-fetch", { credentials: "include" }));
tryIt(() => import(P + "-import.js"));
tryIt(() => import(URL.createObjectURL(new Blob(['import "' + P + '-static-import.js";'], { type: "text/javascript" }))));
tryIt(() => caches.open("x").then((c) => c.add(P + "-cache")));
tryIt(() => new Worker(URL.createObjectURL(new Blob(['fetch("' + P + '-nested", { credentials: "include" })'], { type: "text/javascript" }))));
tryIt(() => { const i = new Image(); i.src = P + "-img"; });
tryIt(() => new EventSource(P + "-es", { withCredentials: true }));
tryIt(() => new WebSocket(P.replace("http", "ws") + "-ws"));
tryIt(() => { const x = new XMLHttpRequest(); x.open("GET", P + "-xhr"); x.send(); });
tryIt(() => importScripts(P + "-importscripts.js"));
tryIt(() => navigator.sendBeacon && navigator.sendBeacon(P + "-beacon", "x"));
tryIt(() => import(${JSON.stringify(ORIGIN)} + "/cookie-probe-${who}.js"));
tryIt(() => fetch(${JSON.stringify(ORIGIN)} + "/cookie-probe-${who}-fetch", { credentials: "include" }));
`;
const code = () => `${attack("code")}\nexport const add = (a, b) => a + b;`;
const tests = () => `${attack("tests")}\nexport const tests = [{ name: "wait", run: async () => { ${attack("run")} await new Promise((r) => setTimeout(r, 1500)); } }];`;
const leaksFrom = async (fn: "stcRun" | "stcRunPlain") => {
const before = hits.length;
await chrome.eval(`${fn}(${JSON.stringify(code())}, ${JSON.stringify(tests())}, 4000)`);
await Bun.sleep(1500);
return hits.slice(before);
};
let controlEnd = 0;
test("negative control: without the sandbox, a same-origin worker on a page without CSP leaks", async () => {
await openHarness("/harness-nocsp.html");
const leaked = await leaksFrom("stcRunPlain");
// Dynamic import gets through the stubs, to other origins and, with the session cookie, to the app itself.
expect(leaked.map((h) => h.path)).toContain("/code-import.js");
expect(leaked.some((h) => h.path === "app:/cookie-probe-code.js" && (h.cookie ?? "").includes("stc_probe"))).toBe(true);
controlEnd = hits.length;
}, 30_000);
test("C2: learner and test code reach nothing from the sandboxed runner, even on a page without CSP", async () => {
await openHarness("/harness-nocsp.html");
expect(await leaksFrom("stcRun")).toEqual([]);
}, 30_000);
test("C2: learner and test code reach nothing from the sandboxed runner on the SPA (with its CSP)", async () => {
await openHarness("/harness.html");
expect(await leaksFrom("stcRun")).toEqual([]);
// Only the control navigation (and the favicon request Chrome makes for it) ever carried the cookie.
// (The negative control's own leaks are excluded; they ran the old design on purpose.)
expect(hits.slice(controlEnd).filter((h) => (h.cookie ?? "").includes("stc_probe"))).toEqual([]);
}, 30_000);
const exploitMd = () => [
"<details>\n<summary>Think first</summary>\n<img src=x onerror=\"window.__xss=1;fetch('" + P + "/xss')\"\n\nNext paragraph\n</details>",
"<div>\n<img src=x onerror=window.__xss=2\n\nAnother\n</div>",
"<svg onload=\"window.__xss=3\"></svg>",
"<details><summary onclick=\"window.__xss=4\">Hint 1 — nudge</summary>\n\nx\n\n</details>",
"[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) <a href=\"jav&#x61;script:window.__xss=7\">c</a>",
"<iframe src=\"javascript:parent.__xss=8\"></iframe><script>window.__xss=9</script>",
].join("\n\n");
async function renderExploit(pagePath: string) {
await openHarness(pagePath);
const before = hits.length;
const html = await chrome.eval<string>(`stcRenderLesson(${JSON.stringify(exploitMd())}, ${JSON.stringify("<img src=x onerror=window.__xss=10")})`);
// Click every link and summary, as a learner would.
await chrome.eval(`(() => { for (const a of document.querySelectorAll("#root a, #root summary")) { try { a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) {} } })()`);
await Bun.sleep(800);
return { html, xss: await chrome.eval("window.__xss ?? null"), leaked: hits.slice(before).filter((h) => h.path === "/xss") };
}
test("C1, first layer: the exploit lesson runs no script on a page without CSP (sanitizer alone)", async () => {
const r = await renderExploit("/harness-nocsp.html");
expect(r.xss).toBeNull();
expect(chrome.dialogs).toEqual([]);
expect(r.leaked).toEqual([]);
expect(r.html).toContain("Next paragraph");
expect(r.html).not.toMatch(/<[a-z][^>]*\son[a-z]+=/i);
}, 30_000);
test("C1, both layers: the exploit lesson runs no script under the SPA's meta CSP", async () => {
const r = await renderExploit("/harness.html");
expect(r.xss).toBeNull();
expect(r.leaked).toEqual([]);
expect(r.html).toContain("Next paragraph");
}, 30_000);
const RAW = `<img src=x onerror="window.__xss='raw'"><svg><animate onbegin="window.__xss='svg'" attributeName="x" dur="1s"></animate></svg><a href="javascript:void(window.__xss='href')">a</a>`;
test("C1, second layer: the meta CSP alone blocks unsanitized handlers (control: they run without it)", async () => {
await openHarness("/harness-nocsp.html");
await chrome.eval(`stcRawHtml(${JSON.stringify(RAW)})`);
expect(await chrome.eval("window.__xss ?? null")).not.toBeNull(); // the raw payload does run without a CSP
await openHarness("/harness.html");
const violations = await chrome.eval<number>(`stcRawHtml(${JSON.stringify(RAW)})`);
expect(await chrome.eval("window.__xss ?? null")).toBeNull();
expect(violations).toBeGreaterThan(0);
}, 30_000);
});
@@ -0,0 +1,61 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
forEachDb("library shows only visible courses, with formats and lengths", async (db) => {
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
const anon = await (await t.call("GET", "/api/library")).json();
expect(anon.signedIn).toBe(false);
expect(anon.courses.map((c: { id: string }) => c.id)).toEqual(["sample"]);
const sample = anon.courses[0];
expect(sample.formats).toEqual(["text", "video", "slides", "exercises"]);
expect(sample.lessonCount).toBe(5);
expect(sample.minutes).toBeGreaterThan(14);
expect(anon.featured.id).toBe("sample");
const owner = await t.signUp("[email protected]");
const learner = await t.signUp("[email protected]");
const asOwner = await (await t.call("GET", "/api/library", { cookie: owner })).json();
expect(asOwner.courses.map((c: { id: string }) => c.id).sort()).toEqual(["gated", "sample", "secret"]);
const asLearner = await (await t.call("GET", "/api/library", { cookie: learner })).json();
expect(asLearner.courses.map((c: { id: string }) => c.id).sort()).toEqual(["gated", "sample"]);
// manifest collections become rows; restricted courses do not leak into them
expect(asLearner.collections).toEqual([
{ id: "devops", title: "DevOps", courseIds: ["gated"] },
{ id: "functional-programming", title: "Functional programming", courseIds: ["gated"] },
]);
expect((await t.call("GET", "/api/courses/secret", { cookie: learner })).status).toBe(404);
expect((await t.call("GET", "/api/courses/secret/lessons/s1", { cookie: learner })).status).toBe(404);
// continue row: after touching a lesson
await db.sql`INSERT INTO progress (user_id, course_id, lesson_id, state, position_seconds, updated_at)
SELECT id, 'gated', 'g1a', 'done', NULL, '2026-10-30T00:00:00Z' FROM auth_user WHERE email = '[email protected]'`;
const again = await (await t.call("GET", "/api/library", { cookie: learner })).json();
expect(again.continue).toEqual([{ courseId: "gated", lessonId: "g1b", lessonTitle: "Then this", moduleTitle: "First", moduleIndex: 0, moduleDone: 1, moduleTotal: 2, progress: 1 / 3 }]);
expect(again.featured.id).toBe("gated");
}, 30_000);
forEachDb("search matches titles, pitch and lesson titles", async (db) => {
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
const hits = await (await t.call("GET", "/api/library/search?q=clip")).json();
expect(hits).toEqual([{ courseId: "sample", courseTitle: "Sample course", course: false, lessons: [{ id: "m1-video", title: "Watch the clip", moduleTitle: "First steps" }] }]);
const tiny = await (await t.call("GET", "/api/library/search?q=TINY")).json();
expect(tiny[0].course).toBe(true);
expect(await (await t.call("GET", "/api/library/search?q=")).json()).toEqual([]);
}, 30_000);
forEachDb("course outline carries gating locks and progress", async (db) => {
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
await t.signUp("[email protected]");
const learner = await t.signUp("[email protected]");
const c = await (await t.call("GET", "/api/courses/gated", { cookie: learner })).json();
expect(c.modules.map((m: { locked: boolean }) => m.locked)).toEqual([false, true]);
expect(c.modules[1].lessons[0].hasDeck).toBe(true);
expect(c.canEdit).toBe(false);
expect(c.diagnostics).toEqual([]);
expect((await t.call("GET", "/api/courses/gated")).status).toBe(404); // signed-in only
const locked = await (await t.call("GET", "/api/courses/gated/lessons/g2a", { cookie: learner })).json();
expect(locked.locked).toBe(true);
expect(locked.markdown).toBeNull();
}, 30_000);
+68
View File
@@ -0,0 +1,68 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Path of a usable Chrome, or null (the browser tests are skipped without one). */
export const chromePath = Bun.which("google-chrome") ?? Bun.which("chromium") ?? Bun.which("chromium-browser");
/** A minimal Chrome DevTools Protocol driver for headless end-to-end checks. */
export class Chrome {
private ws!: WebSocket;
private id = 0;
private pending = new Map<number, (v: { result?: any; error?: any }) => void>();
readonly console: string[] = [];
readonly dialogs: string[] = [];
private proc!: Bun.Subprocess;
private dir!: string;
static async launch(): Promise<Chrome> {
const c = new Chrome();
c.dir = mkdtempSync(join(tmpdir(), "stc-chrome-"));
const port = 9400 + Math.floor(Math.random() * 500);
c.proc = Bun.spawn([chromePath!, "--headless=new", "--no-sandbox", "--disable-gpu", "--no-first-run", `--remote-debugging-port=${port}`, `--user-data-dir=${c.dir}`, "about:blank"], { stdout: "ignore", stderr: "ignore" });
let url: string | null = null;
for (let i = 0; i < 100 && !url; i++) {
await Bun.sleep(100);
try {
const list = (await (await fetch(`http://127.0.0.1:${port}/json`)).json()) as { type: string; webSocketDebuggerUrl: string }[];
url = list.find((t) => t.type === "page")?.webSocketDebuggerUrl ?? null;
} catch { /* not up yet */ }
}
if (!url) throw new Error("chrome did not start");
c.ws = new WebSocket(url);
await new Promise((r) => c.ws.addEventListener("open", r, { once: true }));
c.ws.addEventListener("message", (ev) => {
const m = JSON.parse(String(ev.data));
if (m.id && c.pending.has(m.id)) { c.pending.get(m.id)!(m); c.pending.delete(m.id); }
if (m.method === "Runtime.consoleAPICalled") c.console.push(`${m.params.type}: ${m.params.args.map((a: any) => a.value ?? a.description).join(" ")}`);
if (m.method === "Runtime.exceptionThrown") c.console.push(`exception: ${m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text}`);
if (m.method === "Page.javascriptDialogOpening") { c.dialogs.push(m.params.message); void c.send("Page.handleJavaScriptDialog", { accept: true }); }
});
await c.send("Page.enable");
await c.send("Runtime.enable");
return c;
}
send(method: string, params: object = {}): Promise<{ result?: any; error?: any }> {
return new Promise((r) => { const i = ++this.id; this.pending.set(i, r); this.ws.send(JSON.stringify({ id: i, method, params })); });
}
async goto(url: string, settleMs = 1500) {
await this.send("Page.navigate", { url });
await Bun.sleep(settleMs);
}
/** Evaluates `js` in the page (awaiting promises) and returns the JSON value. */
async eval<T = unknown>(js: string): Promise<T> {
const r = await this.send("Runtime.evaluate", { expression: js, awaitPromise: true, returnByValue: true });
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description ?? "evaluation failed");
return r.result?.result?.value as T;
}
async close() {
try { this.ws.close(); } catch { /* closed */ }
this.proc.kill();
await this.proc.exited;
rmSync(this.dir, { recursive: true, force: true });
}
}
@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import { mkdtempSync, symlinkSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { zip } from "../src/server/zip";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
async function unzipList(bytes: Uint8Array): Promise<string> {
const dir = mkdtempSync(join(tmpdir(), "stc-zip-"));
await Bun.write(join(dir, "a.zip"), bytes);
const out = await Bun.$`unzip -l ${join(dir, "a.zip")}`.text();
await Bun.$`unzip -o -q ${join(dir, "a.zip")} -d ${join(dir, "x")}`;
return out + "\n" + (await Bun.$`cd ${join(dir, "x")} && find . -type f -exec cat {} +`.text());
}
test("zip round-trips through unzip with non-ASCII names", async () => {
const big = new TextEncoder().encode("abc".repeat(1000));
const listing = await unzipList(zip([
{ name: "S08 - Containerizing Applications/Æble notes.txt", data: new TextEncoder().encode("hej") },
{ name: "S08 - Containerizing Applications/big.txt", data: big },
]));
expect(listing).toContain("S08 - Containerizing Applications/Æble notes.txt");
expect(listing).toContain("hej");
expect(listing).toContain("abcabc");
});
forEachDb("code listing and zip for a module, skipping escaping symlinks", async (db) => {
const root = mkdtempSync(join(tmpdir(), "stc-code-"));
const outside = mkdtempSync(join(tmpdir(), "stc-outside-"));
writeFileSync(join(outside, "secret.txt"), "TOP SECRET");
mkdirSync(join(root, "c/S01 - Ünïcode/sub"), { recursive: true });
mkdirSync(join(root, "c/S01 - Ünïcode/node_modules"), { recursive: true });
writeFileSync(join(root, "c/S01 - Ünïcode/run.sh"), "echo hi");
writeFileSync(join(root, "c/S01 - Ünïcode/sub/app.py"), "print(1)");
writeFileSync(join(root, "c/S01 - Ünïcode/node_modules/x.js"), "x");
symlinkSync(join(outside, "secret.txt"), join(root, "c/S01 - Ünïcode/leak.txt"));
symlinkSync(join(root, "c/S01 - Ünïcode/run.sh"), join(root, "c/S01 - Ünïcode/alias.sh"));
writeFileSync(join(root, "stcourse.yaml"), `schema: 1\nid: codey\ntitle: Codey\nvisibility: public\nmodules:\n - id: m1\n title: M\n code: { dir: "c/S01 - Ünïcode", command: "sh run.sh" }\n lessons: [{ id: l1, title: L, type: exercise }]\n`);
const t = await makeApp(db, [FIXTURES, root]);
const list = await (await t.call("GET", "/api/courses/codey/code/m1")).json();
expect(list.files.map((f: { path: string }) => f.path)).toEqual(["alias.sh", "run.sh", "sub/app.py"]);
expect(list.command).toBe("sh run.sh");
const r = await t.call("GET", "/api/courses/codey/code/m1/zip");
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toBe("application/zip");
expect(r.headers.get("content-disposition")).toContain("filename*=UTF-8''S01%20-%20%C3%9Cn%C3%AFcode.zip");
const listing = await unzipList(new Uint8Array(await r.arrayBuffer()));
expect(listing).toContain("S01 - Ünïcode/sub/app.py");
expect(listing).not.toContain("TOP SECRET");
expect(listing).not.toContain("node_modules");
expect((await t.call("GET", "/api/courses/codey/code/nope")).status).toBe(404);
expect((await t.call("GET", "/api/courses/sample/code/m1")).status).toBe(200);
expect((await t.call("GET", "/api/courses/sample/code/m2")).status).toBe(404);
}, 30_000);
+48
View File
@@ -0,0 +1,48 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Writes extra test courses to a temp dir: a restricted one and a gated one with a deck on a module. */
export async function extraCourses(): Promise<string> {
const dir = mkdtempSync(join(tmpdir(), "stc-courses-"));
await Bun.write(join(dir, "secret/stcourse.yaml"), `schema: 1
id: secret
title: Secret course
visibility: restricted
collections: [DevOps]
modules:
- id: m1
title: Only module
lessons:
- { id: s1, title: Hidden lesson, type: text, file: a.md }
`);
await Bun.write(join(dir, "secret/a.md"), "# Hidden\n\nwords here");
await Bun.write(join(dir, "gated/stcourse.yaml"), `schema: 1
id: gated
title: Gated course
pitch: Learn things in order
visibility: signed-in
gating: sequential
collections: [DevOps, Functional programming]
decks:
- { id: dk, file: "Æble slides/deck one.pdf", title: Deck one, attachTo: g2 }
attachments:
- { file: "Æble slides/cheat sheet.pdf", title: Cheat sheet, module: g1 }
modules:
- id: g1
title: First
lessons:
- { id: g1a, title: Start here, type: text, file: "lessons/one.md" }
- { id: g1b, title: Then this, type: text, file: "lessons/two.md" }
- id: g2
title: Second
lessons:
- { id: g2a, title: Locked lesson, type: text, file: "lessons/three.md" }
`);
await Bun.write(join(dir, "gated/lessons/one.md"), "# One\n\nSee [two](two.md).\n");
await Bun.write(join(dir, "gated/lessons/two.md"), "# Two\n");
await Bun.write(join(dir, "gated/lessons/three.md"), "# Three\n\nsecret content");
await Bun.write(join(dir, "gated/Æble slides/deck one.pdf"), "%PDF-1.4\n");
await Bun.write(join(dir, "gated/Æble slides/cheat sheet.pdf"), "%PDF-1.4\ncheat");
return dir;
}
+96
View File
@@ -0,0 +1,96 @@
import { expect, test } from "bun:test";
import { Glob } from "bun";
import { join } from "node:path";
import { connect } from "@stc/db";
import { RUNNER_FRAME_CSP, RUNNER_FRAME_PATH } from "../src/server/runnerFrame";
import { runMigrations } from "../src/server/migrate";
import { makeApp } from "./app";
import { forEachDb } from "./helpers";
const ROOT = join(import.meta.dir, "../../..");
const INDEX = join(ROOT, "apps/web/index.html");
/** The CSP meta tag's policy from an HTML document, as a directive map. */
export function metaCsp(html: string): Map<string, string[]> | null {
const m = /<meta\s+http-equiv="Content-Security-Policy"\s+content="([^"]+)"\s*\/?>/i.exec(html);
if (!m) return null;
return new Map(m[1]!.split(";").map((d) => d.trim()).filter(Boolean).map((d) => {
const [name, ...values] = d.split(/\s+/);
return [name!, values] as const;
}));
}
function expectSpaPolicy(p: Map<string, string[]> | null) {
expect(p).not.toBeNull();
const script = p!.get("script-src")!;
expect(script[0]).toBe("'self'");
// No inline script: only 'self' plus exact hashes (Bun's dev-server helper), never unsafe-inline/eval.
for (const s of script.slice(1)) expect(s).toMatch(/^'sha256-[A-Za-z0-9+/=]+'$/);
expect(p!.get("default-src")).toEqual(["'self'"]);
expect(p!.get("connect-src")).toEqual(["'self'"]);
expect(p!.get("object-src")).toEqual(["'none'"]);
expect(p!.get("base-uri")).toEqual(["'self'"]);
expect(p!.get("frame-src")).toEqual(["'self'"]);
// Directives a meta tag cannot carry are not present (they would be ignored with a console warning).
for (const d of ["frame-ancestors", "sandbox", "report-uri"]) expect(p!.has(d)).toBe(false);
}
test("apps/web/index.html carries the SPA CSP as a meta tag", async () => {
const html = await Bun.file(INDEX).text();
expectSpaPolicy(metaCsp(html));
// The meta tag must come before any script, so it governs all of them.
expect(html.indexOf('http-equiv="Content-Security-Policy"')).toBeLessThan(html.indexOf("<script"));
});
for (const mode of ["prod", "dev"]) {
test(`the served index.html contains the meta CSP (${mode})`, () => {
const p = Bun.spawnSync([process.execPath, join(import.meta.dir, "e2e/serve-index.ts"), mode], { stdout: "pipe", stderr: "pipe", cwd: ROOT });
expect(p.exitCode).toBe(0);
const out = JSON.parse(p.stdout.toString().trim().split("\n").pop()!) as { status: number; type: string; html: string };
expect(out.status).toBe(200);
expect(out.type).toContain("text/html");
const policy = metaCsp(out.html);
expectSpaPolicy(policy);
// Every inline script Bun injects is covered by a hash in the policy (dev: the /_bun/unref helper).
const allowed = new Set(policy!.get("script-src")!.slice(1));
for (const m of out.html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)) {
expect(allowed.has(`'sha256-${new Bun.CryptoHasher("sha256").update(m[1]!).digest("base64")}'`)).toBe(true);
}
}, 60_000);
}
test("the runner frame is served sandboxed, with no connections and only its own script", async () => {
const db = await connect("sqlite://:memory:");
await runMigrations(db);
const { call } = await makeApp(db);
const r = await call("GET", RUNNER_FRAME_PATH);
expect(r.status).toBe(200);
const csp = r.headers.get("content-security-policy")!;
expect(csp).toBe(RUNNER_FRAME_CSP);
expect(csp).toContain("connect-src 'none'");
expect(csp).toContain("sandbox allow-scripts");
expect(csp).not.toContain("allow-same-origin");
expect(csp.split(";").map((d) => d.trim()).find((d) => d.startsWith("script-src"))).toMatch(/^script-src 'sha256-[A-Za-z0-9+/=]+' blob:$/);
await db.close();
});
const PROXY_VARS = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"];
test("no source file changes proxy environment variables", async () => {
const offenders: string[] = [];
const assign = new RegExp(`process\\.env(\\.(${PROXY_VARS.join("|")})|\\[[^\\]]+\\])\\s*(=(?!=)|\\?\\?=|\\|\\|=)|delete\\s+process\\.env|Object\\.assign\\(\\s*process\\.env`);
for (const dir of ["packages", "apps"]) {
for await (const f of new Glob("*/src/**/*.{ts,tsx}").scan({ cwd: join(ROOT, dir) })) {
const src = await Bun.file(join(ROOT, dir, f)).text();
if (assign.test(src)) offenders.push(`${dir}/${f}`);
}
}
expect(offenders).toEqual([]);
});
forEachDb("starting the learner feature and serving requests leaves proxy variables untouched", async (db) => {
const before = Object.fromEntries(PROXY_VARS.map((k) => [k, process.env[k]]));
const { call } = await makeApp(db);
for (const p of ["/api/library", "/api/courses/sample", RUNNER_FRAME_PATH, "/api/learner/pdf.worker.mjs"]) expect((await call("GET", p)).status).toBe(200);
expect(Object.fromEntries(PROXY_VARS.map((k) => [k, process.env[k]]))).toEqual(before);
});
@@ -0,0 +1,52 @@
// Browser harness for the headless-Chrome tests: the real runner and the real LessonView, bundled for the browser.
import { createRoot } from "react-dom/client";
import { createBrowserRunner } from "../../src/web/runner/runner";
import { WORKER_SOURCE } from "../../src/web/runner/worker-source";
import { LessonView } from "../../src/web/lesson/LessonView";
import type { LessonPayload } from "../../src/shared/types";
declare global {
interface Window {
stcRun(code: string, tests: string, timeoutMs: number): Promise<unknown>;
/** Negative control only: the old design, a same-origin blob worker in the page. */
stcRunPlain(code: string, tests: string, timeoutMs: number): Promise<unknown>;
stcRenderLesson(markdown: string, hintBody: string): Promise<string>;
/** Sets unsanitized HTML with innerHTML (bypasses the sanitizer) to test the CSP layer on its own. */
stcRawHtml(html: string): Promise<number>;
stcReady: boolean;
}
}
window.stcRun = (code, tests, timeoutMs) => createBrowserRunner("js").run(code, tests, timeoutMs);
window.stcRunPlain = (code, tests, timeoutMs) => createBrowserRunner("js", {
createWorker: () => new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" }),
}).run(code, tests, timeoutMs);
window.stcRenderLesson = async (markdown, hintBody) => {
const lesson: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "x", title: "Exploit", type: "exercise", minutes: null },
module: { id: "m", title: "M", index: 0 }, locked: false, markdown, baseFile: "course/x.md", trackMarkdown: {}, tracks: [],
video: null, transcript: null, hints: [{ label: "nudge", body: hintBody }], decks: [], code: null, attachments: [], browser: null,
localCommand: null, prev: null, next: null, lessonFiles: {},
};
const el = document.getElementById("root")!;
createRoot(el).render(<LessonView lesson={lesson} preview />);
await new Promise((r) => setTimeout(r, 300));
// Open every fold so nested content is live in the DOM too.
for (const d of document.querySelectorAll("details")) d.open = true;
await new Promise((r) => setTimeout(r, 500));
return el.innerHTML;
};
window.stcRawHtml = async (html) => {
let violations = 0;
document.addEventListener("securitypolicyviolation", () => { violations++; });
const el = document.createElement("div");
el.innerHTML = html;
document.body.append(el);
for (const a of el.querySelectorAll("a, summary, img")) { try { a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) { /* ignore */ } }
await new Promise((r) => setTimeout(r, 600));
return violations;
};
window.stcReady = true;
@@ -0,0 +1,12 @@
// Run by csp.test.ts in a subprocess (bundling an HTML import inside a full bun test run is unreliable):
// serves apps/web/index.html the way main.ts does, as a plain Bun HTML import, and prints the document.
import indexHtml from "../../../../apps/web/index.html";
const development = process.argv[2] === "dev";
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, routes: { "/*": indexHtml }, development });
try {
const r = await fetch(`http://127.0.0.1:${server.port}/c/sample/m1-intro`);
console.log(JSON.stringify({ status: r.status, type: r.headers.get("content-type"), html: await r.text() }));
} finally {
server.stop(true);
}
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { parseManifest } from "@stc/course-format";
import { moduleLocks } from "../src/server/gating";
const m = (gating: string) => {
const r = parseManifest(`schema: 1\nid: g\ntitle: G\ngating: ${gating}\nmodules:\n - { id: a, title: A, lessons: [{ id: a1, title: A1, type: exercise }, { id: a2, title: A2, type: exercise }] }\n - { id: b, title: B, lessons: [{ id: b1, title: B1, type: exercise }] }\n - { id: c, title: C, lessons: [{ id: c1, title: C1, type: exercise }] }\n`);
if (!r.ok) throw new Error(r.errors.join());
return r.manifest;
};
test("no gating unlocks everything", () => {
expect(moduleLocks(m("none"), {})).toEqual([false, false, false]);
});
test("sequential gating locks until the previous module is done", () => {
expect(moduleLocks(m("sequential"), {})).toEqual([false, true, true]);
expect(moduleLocks(m("sequential"), { a1: { state: "done" } })).toEqual([false, true, true]);
expect(moduleLocks(m("sequential"), { a1: { state: "done" }, a2: { state: "done" } })).toEqual([false, false, true]);
// done later modules do not unlock past an unfinished one
expect(moduleLocks(m("sequential"), { a1: { state: "done" }, a2: { state: "done" }, c1: { state: "done" } })).toEqual([false, false, true]);
expect(moduleLocks(m("sequential"), {}, true)).toEqual([false, false, false]);
});
+35
View File
@@ -0,0 +1,35 @@
import { test } from "bun:test";
import { connect, migrate, type Db } from "@stc/db";
import { runMigrations } from "../src/server/migrate";
const PG = process.env.STC_TEST_PG_URL;
/** Fresh migrated in-memory SQLite (and a fresh Postgres schema when STC_TEST_PG_URL is set), with learner migrations applied. */
export function forEachDb(name: string, fn: (db: Db) => Promise<void>, timeout?: number): void {
test(`${name} [sqlite]`, async () => {
const db = await connect("sqlite://:memory:");
try { await migrate(db); await runMigrations(db); await fn(db); } finally { await db.close(); }
}, timeout);
if (!PG) return;
test(`${name} [postgres]`, async () => {
const { db, drop } = await pgSchemaDb();
try { await migrate(db); await runMigrations(db); await fn(db); } finally { await drop(); }
}, timeout);
}
export async function pgSchemaDb(): Promise<{ db: Db; drop(): Promise<void> }> {
const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`;
const admin = await connect(PG!);
await admin.sql.unsafe(`CREATE SCHEMA ${schema}`);
const u = new URL(PG!);
u.searchParams.set("options", `-c search_path=${schema}`);
const db = await connect(u.toString());
return {
db,
async drop() {
await db.close();
await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`);
await admin.close();
},
};
}
@@ -0,0 +1,117 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { connect } from "@stc/db";
import { LessonView } from "../src/web";
import { LessonView as FromIndex } from "../src/index";
import type { LessonPayload } from "../src/shared/types";
import { localCommandFor } from "../src/web/lesson/Exercise";
import { firstStamp } from "../src/web/lesson/notes";
import { buildTree } from "../src/web/lesson/CodeFiles";
import { runMigrations } from "../src/server/migrate";
import { makeApp } from "./app";
async function sampleLessons(ids: string[]): Promise<Record<string, LessonPayload>> {
const db = await connect("sqlite://:memory:");
await runMigrations(db);
const { call } = await makeApp(db);
const out: Record<string, LessonPayload> = {};
for (const id of ids) out[id] = (await (await call("GET", `/api/courses/sample/lessons/${id}`)).json()) as LessonPayload;
await db.close();
return out;
}
const html = (el: React.ReactElement) => renderToString(<MemoryRouter>{el}</MemoryRouter>);
test("LessonView is exported from the package index and the web entry", () => {
expect(FromIndex).toBe(LessonView);
});
test("LessonView renders every sample lesson type", async () => {
const l = await sampleLessons(["m1-intro", "m1-video", "m2-ex-add"]);
const text = html(<LessonView lesson={l["m1-intro"]!} />);
expect(text).toContain('data-type="text"');
expect(text).toContain('data-look="reading"');
expect(text).toContain("<h1 class=\"title\">Introduction</h1>");
expect(text).toContain("Welcome to the sample course");
expect(text).toContain("Files for this section"); // module m1 has companion code
expect(text).toContain("/api/courses/sample/code/m1/zip");
const video = html(<LessonView lesson={l["m1-video"]!} signedIn={false} />);
expect(video).toContain('data-type="video"');
expect(video).toMatch(/<video[^>]+src="\/api\/media\/sample\/media\/clip\.mp4"/);
expect(video).toContain('data-deck="d1"'); // companion deck
expect(video).toContain("Transcript");
expect(video).toContain("Sign in"); // notes need an account
const ex = html(<LessonView lesson={l["m2-ex-add"]!} look="reading" signedIn />);
expect(ex).toContain('data-look="work"'); // exercises always use Work
expect(ex).toContain("Run tests");
expect(ex).toContain("return a - b;"); // the starter is in the editor
expect(ex).toContain('data-tier="1"');
expect(ex).toContain('data-tiers="2"');
expect(ex).toContain("node --test");
expect(ex).not.toContain("return a + b"); // no solution for learners
});
test("preview mode renders without notes or saved-attempt UI", async () => {
const l = await sampleLessons(["m1-video"]);
const out = html(<LessonView lesson={l["m1-video"]!} preview />);
expect(out).not.toContain("Your notes");
expect(out).not.toContain("Insert time");
});
test("a locked lesson shows the lock notice and no content", () => {
const locked: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "l2", title: "Later", type: "text", minutes: null },
module: { id: "m2", title: "Two", index: 1 }, locked: true, markdown: null, baseFile: null, trackMarkdown: {}, tracks: [],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null,
prev: { id: "l1", title: "Earlier" }, next: null, lessonFiles: {},
};
const out = html(<LessonView lesson={locked} signedIn />);
expect(out).toContain("This module is locked.");
expect(out).toMatch(/Back to (<!-- -->)?Earlier/);
});
test("track variants: the chosen track's markdown and code are shown", () => {
const lesson: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "t", title: "Tracks `x`", type: "text", minutes: 5 },
module: { id: "m", title: "M", index: 0 }, locked: false,
markdown: "# T\n\n**JavaScript**\n\n```js\nconst a = 1\n```\n\n**Scala 3**\n\n```scala\nval a = 1\n```\n",
baseFile: "course/t.md", trackMarkdown: {}, tracks: [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null,
localCommand: { js: "npm test", scala: "sbt test" }, prev: null, next: null, lessonFiles: {},
};
const js = html(<LessonView lesson={lesson} track="js" />);
expect(js).toContain("const a = 1");
expect(js).not.toContain("val a = 1");
expect(js).toContain("<code>x</code>"); // inline code in the title
expect(js).toContain('aria-label="Track"');
expect(js).toContain("npm test");
const scala = html(<LessonView lesson={lesson} track="scala" />);
expect(scala).toContain("val a = 1");
expect(scala).toContain("sbt test");
});
test("helpers: local command per track, note timestamps, code tree", () => {
expect(localCommandFor({ js: "a", scala: "b" }, "scala")).toBe("b");
expect(localCommandFor({ js: "a" }, "fsharp")).toBe("a");
expect(localCommandFor("x", null)).toBe("x");
expect(firstStamp("see [1:05] and [0:10]")).toBe(65);
expect(firstStamp("[1:02:03] x")).toBe(3723);
expect(firstStamp("no stamp")).toBeNull();
const t = buildTree([{ path: "a.txt", size: 1 }, { path: "src/b.ts", size: 2 }, { path: "src/x/c.ts", size: 3 }]);
expect(t.files.map((f) => f.name)).toEqual(["a.txt"]);
expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]);
expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts");
});
test("the exercise editor: no Tab capture, Enter keeps the indentation", async () => {
const { newlineWithIndent } = await import("../src/web/lesson/Exercise");
expect(newlineWithIndent("function f() {\n return 1", 26)).toBe("\n ");
expect(newlineWithIndent("x", 1)).toBe("\n");
expect(newlineWithIndent("\t\tx", 3)).toBe("\n\t\t");
const src = await Bun.file(new URL("../src/web/lesson/Exercise.tsx", import.meta.url)).text();
expect(src).not.toMatch(/e\.key === "Tab"/);
});
@@ -0,0 +1,47 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
forEachDb("lesson payloads for every sample lesson type", async (db) => {
const t = await makeApp(db);
const get = async (id: string, cookie?: string) => (await t.call("GET", `/api/courses/sample/lessons/${id}`, { cookie })).json();
const intro = await get("m1-intro");
expect(intro.markdown).toContain("Welcome to the sample course");
expect(intro.prev).toBeNull();
expect(intro.next).toEqual({ id: "m1-video", title: "Watch the clip" });
expect(intro.code).toEqual({ moduleId: "m1", dir: "code/S01 - First steps", command: "sh hello.sh" });
expect(intro.lessonFiles["lessons/01-intro.md"]).toBe("m1-intro");
const video = await get("m1-video");
expect(video.video).toEqual({ url: "/api/media/sample/media/clip.mp4", file: "media/clip.mp4", duration: 2 });
expect(video.transcript).toContain("test pattern");
expect(video.decks).toEqual([{ id: "d1", title: "deck", url: "/api/media/sample/media/deck.pdf", attachTo: "m1-video" }]);
const ex = await get("m2-ex-add");
expect(ex.markdown?.startsWith("### Exercise 2.1")).toBe(true);
expect(ex.markdown).not.toContain("Hints");
expect(ex.hints.map((h: { label: string }) => h.label)).toEqual(["nudge", "near-solution"]);
expect(ex.browser.language).toBe("js");
expect(ex.browser.starter).toContain("a - b");
expect(ex.browser.tests).toContain("export const tests");
expect(ex.browser.solution).toBeNull(); // not an author
expect(ex.localCommand).toBe("node --test");
const owner = await t.signUp("[email protected]");
expect((await get("m2-ex-add", owner)).browser.solution).toContain("a + b");
expect((await t.call("GET", "/api/courses/sample/lessons/nope")).status).toBe(404);
}, 30_000);
forEachDb("module decks and attachments with non-ASCII paths", async (db) => {
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
const owner = await t.signUp("[email protected]");
const l = await (await t.call("GET", "/api/courses/gated/lessons/g2a", { cookie: owner })).json();
expect(l.locked).toBe(false); // owners bypass gating
expect(l.decks[0].url).toBe("/api/media/gated/%C3%86ble%20slides/deck%20one.pdf");
const first = await (await t.call("GET", "/api/courses/gated/lessons/g1a", { cookie: owner })).json();
expect(first.attachments).toEqual([{ title: "Cheat sheet", file: "Æble slides/cheat sheet.pdf", url: "/api/media/gated/%C3%86ble%20slides/cheat%20sheet.pdf", size: 14 }]);
const media = await t.call("GET", first.attachments[0].url, { cookie: owner });
expect(media.status).toBe(200);
}, 30_000);
@@ -0,0 +1,127 @@
import { expect, test } from "bun:test";
import { renderHintsHtml, renderLessonHtml, resolveHref, sanitizeHtml } from "../src/web/markdown";
const tracks = [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }, { id: "fsharp", label: "F#" }];
const opts = { courseId: "pure-snake", baseFile: "course/01-purity.md", tracks, lessonFiles: { "course/02-data.md": "m02-text" } };
const variants = `Intro
**JavaScript**
\`\`\`js
export const range = (n) => ...
\`\`\`
**Scala**
\`\`\`scala
def range(n: Int): List[Int]
\`\`\`
**F#**
\`\`\`fsharp
let range (n: int) : int list
\`\`\`
Tests: after.
`;
test("track switcher keeps only the chosen variant and its label", () => {
const scala = renderLessonHtml(variants, { ...opts, track: "scala" });
expect(scala).toContain("def range");
expect(scala).not.toContain("export const range");
expect(scala).not.toContain("let range");
expect(scala).toContain("<strong>Scala</strong>");
expect(scala).not.toContain("<strong>JavaScript</strong>");
expect(scala).toContain("Tests: after.");
const fs = renderLessonHtml(variants, { ...opts, track: "fsharp" });
expect(fs).toContain("let range");
expect(fs).not.toContain("def range");
});
test("a lone code block in one track stays visible for other tracks", () => {
const md = "Text\n\n```js\nexport const pipe = 1\n```\n\nMore";
expect(renderLessonHtml(md, { ...opts, track: "scala" })).toContain("export const pipe");
});
test("a variant group without the chosen track keeps every variant", () => {
const md = "```js\nA\n```\n\n```scala\nB\n```\n";
const html = renderLessonHtml(md, { ...opts, track: "fsharp" });
expect(html).toContain("A");
expect(html).toContain("B");
});
test("headings get GitHub ids and the first heading can be dropped", () => {
const html = renderLessonHtml("# Module 1\n\n## Why this module\n\n### Exercise 1.1 — `range`\n\n## Why this module\n", { ...opts, stripFirstHeading: true });
expect(html).not.toContain("Module 1");
expect(html).toContain('<h2 id="why-this-module">');
expect(html).toContain('<h3 id="exercise-11--range">');
expect(html).toContain('<h2 id="why-this-module-1">');
});
test("details become folds; consecutive hints become ordered tiers", () => {
const md = `<details><summary>Think about it first, then open the explanation</summary>
All segments move **at once**.
</details>
## Hints
<details><summary>Exercise 1.1 (range) — nudge</summary>
N1
</details>
<details><summary>Exercise 1.1 (range) — stronger hint</summary>
S1
</details>
<details><summary>Exercise 1.2 (dropLast) — nudge</summary>
N2
</details>
`;
const html = renderLessonHtml(md, opts);
expect(html).toContain('<details class="fold"><summary>');
expect(html).toContain("<strong>at once</strong>");
expect((html.match(/data-tier="1" data-tiers="2"/g) ?? []).length).toBe(1);
expect(html).toContain('data-tier="2" data-tiers="2"');
expect(html).toContain("1 of 2");
// the 1.2 hint starts a new group of its own
expect(html).toMatch(/<details class="fold" data-hint="1"><summary>[\s\S]*Exercise 1\.2 \(dropLast\)/);
expect((html.match(/<\/div><\/details>/g) ?? []).length).toBe(4);
});
test("systemic-design hint numbering forms one group", () => {
const md = "<details><summary>Hint 1 — nudge</summary>\n\na\n\n</details>\n<details><summary>Hint 2 — concept</summary>\n\nb\n\n</details>\n<details><summary>Hint 3 — pseudo-code</summary>\n\nc\n\n</details>\n";
const html = renderLessonHtml(md, opts);
expect(html).toContain('data-tier="3" data-tiers="3"');
});
test("raw HTML is reduced to safe tags", () => {
expect(sanitizeHtml('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;<kbd>x</kbd><img src="x" /><details open></details>');
const html = renderLessonHtml('<div onclick="x()">hi</div>\n\n[x](javascript:alert(1)) <span style="color:red">s</span>', opts);
expect(html).toContain("<div>hi</div>");
expect(html).not.toContain("onclick");
expect(html).not.toContain("javascript:");
expect(html).not.toContain("style=");
});
test("links and images resolve inside the course", () => {
expect(resolveHref("02-data.md#state", opts, "link")).toEqual({ url: "/c/pure-snake/m02-text#state", external: false });
expect(resolveHref("../fixtures/board 1.json", opts, "link")).toEqual({ url: "/api/media/pure-snake/fixtures/board%201.json", external: false });
expect(resolveHref("../../etc/passwd", opts, "link")).toBeNull();
expect(resolveHref("https://example.com", opts, "link")).toEqual({ url: "https://example.com", external: true });
expect(resolveHref("img/a.png", opts, "image")).toEqual({ url: "/api/media/pure-snake/course/img/a.png", external: false });
const html = renderLessonHtml("![board](img/board.png) and [ext](https://x.org)", opts);
expect(html).toContain('<img src="/api/media/pure-snake/course/img/board.png" alt="board" loading="lazy" />');
expect(html).toContain('target="_blank" rel="noopener noreferrer"');
});
test("exercise hints render as one tier group", () => {
const html = renderHintsHtml([{ label: "nudge", body: "Which **operator**?" }, { label: "near-solution", body: "`a + b`" }], { courseId: "sample", baseFile: null });
expect(html).toContain("Nudge");
expect(html).toContain('data-tier="2" data-tiers="2"');
expect(html).toContain("<strong>operator</strong>");
});
@@ -0,0 +1,13 @@
import { expect } from "bun:test";
import { runMigrations } from "../src/server/migrate";
import { forEachDb } from "./helpers";
forEachDb("learner migrations create the auth tables and are idempotent", async (db) => {
for (const t of ["auth_user", "auth_session", "auth_account", "auth_verification", "auth_sso_provider", "learner_meta", "learner_group_claims"]) {
const rows = await db.sql.unsafe(`SELECT COUNT(*) AS n FROM ${t}`);
expect(Number(rows[0].n)).toBe(0);
}
expect(await runMigrations(db)).toEqual([]);
const names = (await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name);
expect(names).toContain("0001_learner_auth.sql");
});
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createBrowserRunner } from "../src/web/runner/runner";
import { transpileTs } from "../src/server/transpile";
import { FIXTURES } from "./app";
const ex = (f: string) => readFileSync(join(FIXTURES, "sample/exercises/add", f), "utf8");
const js = createBrowserRunner("js");
test("the sample starter fails with expected and actual values", async () => {
const r = await js.run(ex("starter.js"), ex("tests.js"), 5000);
expect(r).toEqual([
{ name: "adds 2 and 3", passed: false, message: "Expected values to be strictly equal", expected: "5", actual: "-1" },
{ name: "adds -1 and 1", passed: false, message: "Expected values to be strictly equal", expected: "0", actual: "-2" },
]);
});
test("the sample exercise passes after editing", async () => {
const edited = ex("starter.js").replace("a - b", "a + b");
expect((await js.run(edited, ex("tests.js"), 5000)).every((t) => t.passed)).toBe(true);
});
test("syntax errors and missing exports are reported per test", async () => {
const r = await js.run("export function add(a, b) { return a +", ex("tests.js"), 5000);
expect(r.length).toBe(2);
expect(r[0]!.passed).toBe(false);
expect(r[0]!.message).toContain("did not load");
const r2 = await js.run("export const nope = 1", ex("tests.js"), 5000);
expect(r2[0]!.message).toContain("TypeError");
});
test("an endless loop is killed by the timeout", async () => {
const t0 = Date.now();
const r = await js.run("export function add() { while (true) {} }", ex("tests.js"), 400);
expect(Date.now() - t0).toBeLessThan(3000);
expect(r.map((x) => x.passed)).toEqual([false, false]);
expect(r[0]!.message).toContain("Timed out");
});
test("network access is blocked inside the worker", async () => {
const tests = `export const tests = [{ name: "net", run: async (m, assert) => assert.equal(await m.probe(), "blocked") }];`;
const code = `export async function probe() { try { await fetch("http://127.0.0.1:1/"); return "open"; } catch (e) { return String(e.message).includes("turned off") ? "blocked" : "other " + e.message; } }`;
expect((await js.run(code, tests, 3000))[0]!.passed).toBe(true);
});
test("deepEqual compares structures", async () => {
const tests = `export const tests = [
{ name: "same", run: (m, a) => a.deepEqual(m.v(), { x: [1, 2], y: { z: null } }) },
{ name: "diff", run: (m, a) => a.deepEqual(m.v(), { x: [1, 3] }) },
];`;
const r = await js.run("export const v = () => ({ x: [1, 2], y: { z: null } })", tests, 3000);
expect(r[0]!.passed).toBe(true);
expect(r[1]).toMatchObject({ passed: false, expected: "{x: [1, 3]}", actual: "{x: [1, 2], y: {z: null}}" });
});
test("TypeScript exercises are transpiled first", async () => {
const runner = createBrowserRunner("ts", { transpile: async (c) => transpileTs(c) });
const tests = `import type { X } from "./x"; export const tests = [{ name: "typed", run: (m: any, a: any) => a.equal(m.add(1, 2), 3) }];`;
const r = await runner.run("export function add(a: number, b: number): number { return a + b; }", tests, 3000);
expect(r).toEqual([{ name: "typed", passed: true }]);
});
@@ -0,0 +1,65 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
forEachDb("progress: mark done, positions never undo done, gating blocks writes", async (db) => {
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
await t.signUp("[email protected]");
const me = await t.signUp("[email protected]");
const put = (course: string, lesson: string, body: unknown, cookie = me) => t.call("PUT", `/api/courses/${course}/progress/${lesson}`, { cookie, body });
expect((await put("sample", "m1-intro", { state: "done" }, "")).status).toBe(401);
expect((await put("sample", "m1-intro", { state: "finished" })).status).toBe(400);
expect((await (await put("sample", "m1-video", { state: "in-progress", positionSeconds: 1.5 })).json()).positionSeconds).toBe(1.5);
await put("sample", "m1-video", { state: "done" });
const after = await (await put("sample", "m1-video", { state: "in-progress", positionSeconds: 0.5 })).json();
expect(after).toMatchObject({ state: "done", positionSeconds: 0.5 });
expect((await put("sample", "nope", { state: "done" })).status).toBe(404);
expect((await put("gated", "g2a", { state: "done" })).status).toBe(403);
await put("gated", "g1a", { state: "done" });
await put("gated", "g1b", { state: "done" });
expect((await put("gated", "g2a", { state: "done" })).status).toBe(200);
const all = await (await t.call("GET", "/api/courses/gated/progress", { cookie: me })).json();
expect(Object.keys(all).sort()).toEqual(["g1a", "g1b", "g2a"]);
}, 30_000);
forEachDb("notes persist per lesson with slide and video anchors", async (db) => {
const t = await makeApp(db);
const me = await t.signUp("[email protected]");
const other = await t.signUp("[email protected]");
const post = (b: unknown, cookie = me) => t.call("POST", "/api/courses/sample/notes", { cookie, body: b });
expect((await post({ lessonId: "m1-video", anchorKind: "slide", anchorValue: 2, body: "x" })).status).toBe(400); // no deck id
expect((await post({ lessonId: "m1-video", anchorKind: "video", body: "x" })).status).toBe(400);
const s10 = await (await post({ lessonId: "m1-video", anchorKind: "slide", anchorValue: 10, deckId: "d1", body: "slide ten" })).json();
await post({ lessonId: "m1-video", anchorKind: "slide", anchorValue: 2, deckId: "d1", body: "slide two" });
await post({ lessonId: "m1-video", anchorKind: "video", anchorValue: 1.25, body: "at 1.25s" });
await post({ lessonId: "m1-intro", body: "general" });
const list = await (await t.call("GET", "/api/courses/sample/notes?lesson=m1-video", { cookie: me })).json();
expect(list.map((n: { body: string }) => n.body)).toEqual(["slide two", "slide ten", "at 1.25s"]);
expect(list[0]).toMatchObject({ anchorKind: "slide", anchorValue: "2", deckId: "d1" });
expect(list[0].userId).toBeUndefined();
expect((await (await t.call("GET", "/api/courses/sample/notes", { cookie: me })).json()).length).toBe(4);
expect(await (await t.call("GET", "/api/courses/sample/notes", { cookie: other })).json()).toEqual([]);
expect((await t.call("PATCH", `/api/notes/${s10.id}`, { cookie: other, body: { body: "hijack" } })).status).toBe(404);
expect((await (await t.call("PATCH", `/api/notes/${s10.id}`, { cookie: me, body: { body: "edited" } })).json()).body).toBe("edited");
expect((await t.call("DELETE", `/api/notes/${s10.id}`, { cookie: other })).status).toBe(404);
expect((await t.call("DELETE", `/api/notes/${s10.id}`, { cookie: me })).status).toBe(200);
expect((await (await t.call("GET", "/api/courses/sample/notes?lesson=m1-video", { cookie: me })).json()).length).toBe(2);
}, 30_000);
forEachDb("exercise attempts save code and a pass marks the lesson done", async (db) => {
const t = await makeApp(db);
const me = await t.signUp("[email protected]");
const url = "/api/courses/sample/lessons/m2-ex-add/attempt";
expect(await (await t.call("GET", `${url}?track=js`, { cookie: me })).json()).toBeNull();
const r1 = await (await t.call("PUT", url, { cookie: me, body: { track: "js", code: "draft", passed: false, result: [{ name: "a", passed: false }] } })).json();
expect(r1).toEqual({ ok: true, passed: false, state: "in-progress" });
const r2 = await (await t.call("PUT", url, { cookie: me, body: { track: "js", code: "export const add=(a,b)=>a+b", passed: true, result: [] } })).json();
expect(r2.state).toBe("done");
const got = await (await t.call("GET", `${url}?track=js`, { cookie: me })).json();
expect(got).toMatchObject({ code: "export const add=(a,b)=>a+b", passed: true });
expect((await t.call("PUT", "/api/courses/sample/lessons/m1-intro/attempt", { cookie: me, body: { code: "x" } })).status).toBe(400);
}, 30_000);
@@ -0,0 +1,27 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
forEachDb("one visibility rule: the global author role alone does not open a restricted course (I1)", async (db) => {
const dir = await extraCourses();
const { call, signUp } = await makeApp(db, [FIXTURES, dir]);
const owner = await signUp("[email protected]");
const author = await signUp("[email protected]");
const coAuthor = await signUp("[email protected]");
const users = (await (await call("GET", "/api/admin/users", { cookie: owner })).json()) as { id: string; email: string }[];
const id = (e: string) => users.find((u) => u.email === e)!.id;
expect((await call("PUT", `/api/admin/users/${id("[email protected]")}/roles`, { cookie: owner, body: { roles: ["author"] } })).status).toBe(200);
expect((await call("PUT", "/api/admin/courses/secret/access", { cookie: owner, body: { authors: [id("[email protected]")] } })).status).toBe(200);
const paths = ["/api/courses/secret", "/api/courses/secret/lessons/s1", "/api/media/secret/a.md"];
for (const p of paths) {
expect([p, (await call("GET", p, { cookie: author })).status]).toEqual([p, 404]);
expect([p, (await call("GET", p, { cookie: coAuthor })).status]).toEqual([p, 200]);
}
const lib = async (c: string) => ((await (await call("GET", "/api/library", { cookie: c })).json()) as { courses: { id: string }[] }).courses.map((x) => x.id);
expect(await lib(author)).not.toContain("secret");
expect(await lib(coAuthor)).toContain("secret");
const search = (await (await call("GET", "/api/library/search?q=hidden", { cookie: author })).json()) as unknown[];
expect(search).toEqual([]);
});
@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { learnerWeb } from "../src/web";
import { filterCourses, LibraryView } from "../src/web/library/LibraryPage";
import { CourseFrame, moduleNumber } from "../src/web/shell/CourseFrame";
import { nextLesson } from "../src/web/course/pages";
import { safeNext } from "../src/web/account/AccountPage";
import type { CourseCard, CoursePayload, LibraryPayload } from "../src/shared/types";
const card = (id: string, over: Partial<CourseCard> = {}): CourseCard => ({
id, title: id.toUpperCase(), pitch: `${id} pitch`, authors: [], coverUrl: null, accent: null, formats: ["text"], lessonCount: 3,
minutes: 30, collections: [], tracks: [], source: "authored", addedAt: "2026-10-01T00:00:00Z", status: "ok", progress: 0, doneCount: 0, started: false, ...over,
});
const lib: LibraryPayload = {
signedIn: true,
featured: card("snake", { formats: ["text", "exercises"], minutes: 900 }),
continue: [{ courseId: "snake", lessonId: "m3", lessonTitle: "Pure game rules", moduleTitle: "Pure game rules", moduleIndex: 3, moduleDone: 1, moduleTotal: 5, progress: 0.2 }],
newest: ["docker", "snake"],
collections: [{ id: "devops", title: "DevOps", courseIds: ["docker"] }],
courses: [card("docker", { formats: ["video", "slides"], minutes: 300, started: true, progress: 0.5 }), card("snake", { formats: ["text", "exercises"], minutes: 900 })],
};
test("the learner web feature registers its routes", () => {
expect(learnerWeb.routes.map((r) => r.path)).toEqual(["/", "/c/:courseId", "/c/:courseId/notes", "/c/:courseId/:lessonId", "/account", "/account/admin"]);
});
test("every route module loads and has a default component", async () => {
for (const r of learnerWeb.routes) expect(typeof (await r.element()).default).toBe("function");
});
test("library: featured course, continue row, browsable rows and the course list", () => {
const out = renderToString(<MemoryRouter><LibraryView lib={lib} /></MemoryRouter>);
expect(out).toContain("Featured");
expect(out).toContain("SNAKE");
expect(out).toContain("Next up: <!-- -->Pure game rules");
expect(out).toContain("New in the library");
expect(out).toContain("DevOps");
expect(out).toContain("All courses");
expect(out).toContain('href="/c/snake/m3"');
expect(out).not.toMatch(/price|rating|bestseller/i);
});
test("library: search and filters switch to a results list", () => {
const out = renderToString(<MemoryRouter><LibraryView lib={lib} initialQuery="dock" /></MemoryRouter>);
expect(out).toContain("Courses matching “dock”");
expect(out).not.toContain("New in the library");
});
test("library filters by format, length and in-progress", () => {
const cs = lib.courses;
expect(filterCourses(cs, { format: "video", length: "any", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
expect(filterCourses(cs, { format: "all", length: "long", inProgress: false }).map((c) => c.id)).toEqual(["snake"]);
expect(filterCourses(cs, { format: "all", length: "medium", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
expect(filterCourses(cs, { format: "all", length: "any", inProgress: true }).map((c) => c.id)).toEqual(["docker"]);
});
const course: CoursePayload = {
id: "sd", title: "Systemic Design", pitch: null, authors: [], accent: "#86c7a6", coverUrl: null,
tracks: [{ id: "rust", label: "Rust" }, { id: "ts", label: "TypeScript" }], gating: "sequential", source: "authored", status: "ok",
modules: [
{ id: "m00", title: "Intro", index: 0, locked: false, hasCode: false, lessons: [{ id: "a", title: "A", type: "text", minutes: 5, hasDeck: false, browser: false }] },
{ id: "m01", title: "Next", index: 1, locked: true, hasCode: false, lessons: [{ id: "b", title: "Exercise 1.1 — `move`", type: "exercise", minutes: 15, hasDeck: false, browser: false }] },
],
progress: { a: { state: "done", positionSeconds: null, updatedAt: "" } }, canEdit: false, signedIn: true, diagnostics: [],
};
test("course frame: outline with progress, locks, current lesson and course accent", () => {
const out = renderToString(
<MemoryRouter initialEntries={["/c/sd/a"]}>
<CourseFrame course={course} currentLessonId="a" look="reading" lookMode="toggle"><p>body</p></CourseFrame>
</MemoryRouter>,
);
expect(out).toContain("--accent:#86c7a6");
expect(out).toContain('aria-current="page"');
expect(out).toContain("lr-les done");
expect(out).toContain("(locked)");
expect(out).toContain('aria-label="Course progress"');
expect(out).toContain("Your notes");
});
test("module numbers count from 0 when the first module id ends in 0", () => {
expect(moduleNumber(course, 1)).toBe(1);
expect(moduleNumber({ modules: [{ id: "m1" }] }, 0)).toBe(1);
});
test("next lesson skips done lessons and stops at a locked module", () => {
expect(nextLesson(course)).toEqual(course.modules[0]!.lessons[0]!);
const open = { ...course, modules: course.modules.map((m) => ({ ...m, locked: false })) };
expect(nextLesson(open)!.id).toBe("b");
});
test("sign-in only returns to same-site paths", () => {
expect(safeNext("/c/x")).toBe("/c/x");
expect(safeNext("//evil.example")).toBe("/");
expect(safeNext("https://evil.example")).toBe("/");
expect(safeNext(null)).toBe("/");
});
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from "bun:test";
import { renderHintsHtml, renderLessonHtml, sanitizeHtml } from "../src/web/markdown";
const opts = { courseId: "c", baseFile: "course/l.md", tracks: [], lessonFiles: {} };
const decode = (s: string) => s.replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&colon;/gi, ":").replace(/&tab;/gi, "\t").replace(/&newline;/gi, "\n").replace(/&amp;/g, "&");
/** Fails when real tags in `html` carry event handlers, style, a dangerous URL, or are script-capable elements. */
function expectSafe(html: string) {
const tags = html.match(/<[a-zA-Z][^>]*>/g) ?? [];
for (const t of tags) {
expect(t).not.toMatch(/\son[a-z]+\s*=/i);
expect(t).not.toMatch(/\sstyle\s*=/i);
expect(t).not.toMatch(/^<(script|style|svg|math|iframe|object|embed|form|input|button|textarea|select|link|meta|base|frame|frameset)\b/i);
for (const m of t.matchAll(/\s(?:href|src|action|formaction|xlink:href)\s*=\s*"([^"]*)"/gi)) {
const url = decode(m[1]!).replace(/[\u0000- \u007f-\u009f]/g, "").toLowerCase();
expect(url).not.toMatch(/^(javascript|vbscript|data|file):/);
}
}
// An unterminated tag must never swallow following markup into its attributes.
expect(html).not.toMatch(/<img[^>]*<p/i);
}
test("the reviewer's exploit: an unclosed tag inside <details> or <div>", () => {
for (const wrapper of ["details", "div"]) {
const md = `<${wrapper}>\n<summary>Think first</summary>\n<img src=x onerror=alert(1)\n\nNext paragraph\n`;
const html = renderLessonHtml(md, opts);
expectSafe(html);
expect(html).not.toContain("onerror=alert");
expect(html).toContain("Next paragraph");
}
});
test("unclosed tags at the end of a token and across blocks", () => {
for (const md of [
"<img src=x onerror=alert(1)",
"text <img src=x onerror=alert(1)\n\n# Heading\n\n<b>bold",
"<details><summary>a</summary>\n\n<a href=\"x\" onclick=\"alert(1)\"\n\nmore\n\n</details>",
"<span title='\n\n'onmouseover=alert(1) x='>hover</span>",
"<div\nonmouseover=alert(1)>x</div>",
]) expectSafe(renderLessonHtml(md, opts));
});
test("javascript:, data: and vbscript: URLs in any case or encoding are removed", () => {
const payloads = [
"javascript:alert(1)", "JaVaScRiPt:alert(1)", " javascript:alert(1)", "java\tscript:alert(1)", "java&#x09;script:alert(1)",
"jav&#x61;script:alert(1)", "&#106;avascript:alert(1)", "&#x6A;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3A;alert(1)",
"javascript&colon;alert(1)", "vbscript:msgbox(1)", "VBScript:msgbox(1)", "data:text/html,<script>alert(1)</script>",
"DATA:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", "data:image/svg+xml,<svg onload=alert(1)>",
];
for (const p of payloads) {
expectSafe(renderLessonHtml(`[link](${p.replace(/ /g, "%20")}) and <a href="${p}">raw</a> and <img src="${p}"> and ![i](${p.replace(/ /g, "%20")})`, opts));
expectSafe(sanitizeHtml(`<a href="${p}">x</a><img src="${p}">`));
}
});
test("svg, math, iframe, object, embed, form, script and style never become elements", () => {
const md = [
"<svg onload=alert(1)><circle /></svg>", "<svg><script>alert(1)</script></svg>", "<math><mi xlink:href=\"javascript:alert(1)\">x</mi></math>",
"<iframe src=\"javascript:alert(1)\"></iframe>", "<object data=\"x.swf\"></object>", "<embed src=\"x\">",
"<form action=\"javascript:alert(1)\"><button formaction=\"javascript:alert(1)\">go</button></form>",
"<script>alert(1)</script>", "<style>body{display:none}</style>", "<SCRIPT SRC=//x.example/x.js></SCRIPT>",
"<a href=\"https://ok.example\" style=\"position:fixed\" target=\"_self\">ok</a>",
].join("\n\n");
const html = renderLessonHtml(md, opts);
expectSafe(html);
expect(html).toContain('<a href="https://ok.example" target="_blank" rel="noopener noreferrer">ok</a>');
});
test("<details><summary onclick> keeps the fold but drops the handler", () => {
const html = renderLessonHtml('<details><summary onclick="alert(1)">Hint 1 — nudge</summary>\n\ntext\n\n</details>', opts);
expectSafe(html);
expect(html).toContain('<details class="fold" data-hint="1"');
expect(html).not.toContain("onclick");
const hints = renderHintsHtml([{ label: "nudge <img src=x onerror=alert(1)>", body: "<img src=x onerror=alert(1)" }], opts);
expectSafe(hints);
});
test("generic types in prose stay visible as text", () => {
const html = renderLessonHtml("A value of type IO<A> or Stream<Action>.", opts);
expect(html).toContain("IO&lt;A&gt;");
expect(html).toContain("Stream&lt;Action&gt;");
});
@@ -0,0 +1,4 @@
// Browser entry "@stc/feature-learner/web". The repo maps "@stc/*" to "packages/*/src", so this subpath
// resolves here (tsc and Bun alike). It re-exports the web half only: the package index also carries the
// server (Better Auth, SSO), which cannot be bundled for the browser.
export * from "../../src/web";