feat(core): config, job queue, range media serving, feature composition

Adds aclRepo to @stc/db for canView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:04:46 +01:00
1 parent ea18f18b4f
commit 7f33169487
12 files changed
+533

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
import type { LoadedCourse } from "@stc/course-format";
import { aclRepo } from "@stc/db";
import { serveMedia } from "./media";
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export type Routes = Record<string, Partial<Record<Method, (req: Request) => Response | Promise<Response>>>>;
export function canView(user: AuthUser | null, course: LoadedCourse, acl: { principal_type: string; principal_id: string }[], isAuthor = false): boolean {
const vis = course.manifest?.visibility ?? "restricted";
if (vis === "public") return true;
if (!user) return false;
if (vis === "signed-in") return true;
if (user.roles.includes("owner") || isAuthor) return true;
return acl.some((a) => (a.principal_type === "user" && a.principal_id === user.id) || (a.principal_type === "group" && user.groups.includes(a.principal_id)));
}
const json = (body: unknown, status = 200) => Response.json(body, { status });
export function createApp(ctx: ServerContext, features: Feature[]): { routes: Routes; fetch(req: Request): Promise<Response> } {
const acl = aclRepo(ctx.db);
const core: Feature = {
name: "core",
routes: {
"/api/health": { GET: () => json({ ok: true, courses: ctx.courses.list().length, db: ctx.db.dialect }) },
"/api/media/:courseId/*": {
GET: async (req, rc) => {
// Parse from the pathname so we do not depend on how the server fills params for "*".
const m = /^\/api\/media\/([^/]+)\/(.*)$/s.exec(rc.url.pathname);
if (!m) return new Response("Not found", { status: 404 });
let courseId: string, rel: string;
try { courseId = decodeURIComponent(m[1]!); rel = decodeURIComponent(m[2]!); } catch { return new Response("Not found", { status: 404 }); }
const course = ctx.courses.get(courseId);
if (!course) return new Response("Not found", { status: 404 });
const visible = canView(rc.user, course, await acl.listForCourse(course.id), rc.user ? await acl.isAuthor(course.id, rc.user.id) : false);
if (!visible) return new Response("Not found", { status: 404 });
const abs = ctx.courses.resolvePath(course.id, rel);
if (!abs) return new Response("Not found", { status: 404 });
return serveMedia(req, abs, { download: rc.url.searchParams.has("download") });
},
},
},
};
const wrap = (h: RouteHandler) => async (req: Request & { params?: Record<string, string> }): Promise<Response> => {
const url = new URL(req.url);
const rc: RequestContext = { user: await ctx.auth.userFromRequest(req), params: req.params ?? {}, url };
return h(req, rc);
};
const routes: Routes = {};
for (const f of [core, ...features]) {
for (const [path, methods] of Object.entries(f.routes)) {
const slot = (routes[path] ??= {});
for (const [m, h] of Object.entries(methods) as [Method, RouteHandler][]) {
if (slot[m]) throw new Error(`Duplicate route ${m} ${path} (feature "${f.name}")`);
slot[m] = wrap(h);
}
}
}
// Matcher for fetch(): same semantics as Bun routes (":name" segment, trailing "*" wildcard).
const compiled = Object.entries(routes).map(([path, methods]) => {
const parts = path.split("/");
return { parts, methods };
});
const match = (pathname: string) => {
const segs = pathname.split("/");
for (const { parts, methods } of compiled) {
const params: Record<string, string> = {};
let ok = true;
for (let i = 0; i < parts.length && ok; i++) {
const p = parts[i]!;
if (p === "*") { params["*"] = segs.slice(i).join("/"); if (i >= segs.length) params["*"] = ""; return { methods, params }; }
if (segs[i] === undefined) ok = false;
else if (p.startsWith(":")) params[p.slice(1)] = decodeURIComponent(segs[i]!);
else if (p !== segs[i]) ok = false;
}
if (ok && parts.length === segs.length) return { methods, params };
}
return null;
};
return {
routes,
async fetch(req) {
const url = new URL(req.url);
let m: ReturnType<typeof match>;
try { m = match(url.pathname); } catch { return new Response("Bad request", { status: 400 }); }
if (!m) return new Response("Not found", { status: 404 });
const h = m.methods[req.method as Method];
if (!h) return new Response("Method not allowed", { status: 405 });
const r = req as Request & { params?: Record<string, string> };
r.params = m.params;
return h(r);
},
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { AppConfig } from "@stc/contracts";
const DEV_SECRET = "stc-dev-secret-change-me";
export function loadConfig(env: Record<string, string | undefined> = process.env): AppConfig {
const port = env.STC_PORT !== undefined && env.STC_PORT !== "" ? Number(env.STC_PORT) : 3000;
if (!Number.isInteger(port) || port < 0) throw new Error(`Invalid STC_PORT: ${env.STC_PORT}`);
let secret = env.STC_SECRET;
if (!secret) {
if (env.NODE_ENV === "production") throw new Error("STC_SECRET is required when NODE_ENV=production");
console.warn("[stc] STC_SECRET is not set; using an insecure development default");
secret = DEV_SECRET;
}
return {
port,
databaseUrl: env.STC_DATABASE_URL || "sqlite://./data/stcourses.db",
dataDir: env.STC_DATA_DIR || "./data",
courseDirs: (env.STC_COURSE_DIRS || "./overlays:./courses").split(":").filter(Boolean),
secret,
publicUrl: env.STC_PUBLIC_URL || `http://localhost:${port}`,
};
}
+4
View File
@@ -0,0 +1,4 @@
export { loadConfig } from "./config";
export { createJobQueue } from "./jobs";
export { serveMedia, contentType, contentDisposition } from "./media";
export { createApp, canView, type Routes } from "./app";
+69
View File
@@ -0,0 +1,69 @@
import type { JobHandler, JobQueue } from "@stc/contracts";
import { jobsRepo, type Db } from "@stc/db";
export function createJobQueue(db: Db, handlers: Record<string, JobHandler>, opts: { pollMs?: number } = {}): JobQueue & { start(): void; stop(): Promise<void> } {
const repo = jobsRepo(db);
const pollMs = opts.pollMs ?? 500;
const kinds = Object.keys(handlers);
let running = false;
let loop: Promise<void> | null = null;
let wake: (() => void) | null = null;
let abort = new AbortController();
const runOne = async (): Promise<boolean> => {
const job = await repo.claimNext(kinds);
if (!job) return false;
const jc = {
signal: abort.signal,
reportProgress: (fraction: number, message?: string) =>
repo.update(job.id, message === undefined ? { progress: fraction } : { progress: fraction, message }),
};
try {
const result = await handlers[job.kind]!(job.payload, jc);
await repo.update(job.id, { status: "done", progress: 1, result: result ?? null });
} catch (e) {
await repo.update(job.id, { status: "failed", error: e instanceof Error ? e.message : String(e) });
}
return true;
};
const sleep = () => new Promise<void>((res) => {
const t = setTimeout(res, pollMs);
wake = () => { clearTimeout(t); res(); };
});
const run = async () => {
while (running) {
let did = false;
try { did = await runOne(); } catch (e) { console.error("[stc] job worker error", e); }
if (!did && running) await sleep();
}
};
return {
async enqueue(kind, payload) {
const id = await repo.insert(kind, payload);
wake?.();
return id;
},
get: (id) => repo.get(id),
list: (kind) => repo.list(kind),
start() {
if (running) return;
running = true;
abort = new AbortController();
loop = (async () => {
// jobs left running by a previous process go back to the queue
await db.sql`UPDATE jobs SET status = 'queued', updated_at = ${new Date().toISOString()} WHERE status = 'running'`;
await run();
})();
},
async stop() {
running = false;
abort.abort();
wake?.();
await loop;
loop = null;
},
};
}
+57
View File
@@ -0,0 +1,57 @@
const TYPES: Record<string, string> = {
mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
svg: "image/svg+xml", md: "text/markdown; charset=utf-8", txt: "text/plain; charset=utf-8", zip: "application/zip",
js: "text/javascript; charset=utf-8", ts: "text/plain; charset=utf-8",
};
export function contentType(path: string): string {
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
return TYPES[ext] ?? "application/octet-stream";
}
/** RFC 6266 / 5987 header value: ASCII fallback plus UTF-8 filename*. */
export function contentDisposition(name: string, kind: "inline" | "attachment" = "attachment"): string {
const fallback = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const star = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
return `${kind}; filename="${fallback}"; filename*=UTF-8''${star}`;
}
function parseRange(header: string, size: number): { start: number; end: number } | "invalid" | null {
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!m) return null; // malformed or multi-range: ignore, serve whole file
const [, a, b] = m;
if (a === "" && b === "") return null;
let start: number, end: number;
if (a === "") {
const n = Number(b);
if (n === 0) return "invalid";
start = Math.max(0, size - n); end = size - 1;
} else {
start = Number(a);
end = b === "" ? size - 1 : Math.min(Number(b), size - 1);
if (start >= size || start > end) return "invalid";
}
return { start, end };
}
export async function serveMedia(req: Request, absPath: string, opts: { download?: boolean } = {}): Promise<Response> {
const file = Bun.file(absPath);
if (!(await file.exists())) return new Response("Not found", { status: 404 });
const size = file.size;
const name = absPath.split("/").pop() ?? "file";
const headers: Record<string, string> = {
"Content-Type": contentType(absPath),
"Accept-Ranges": "bytes",
};
if (opts.download) headers["Content-Disposition"] = contentDisposition(name);
const range = req.headers.get("range");
const parsed = range ? parseRange(range, size) : null;
if (parsed === "invalid") return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes" } });
if (!parsed) {
headers["Content-Length"] = String(size);
return new Response(req.method === "HEAD" ? null : file, { status: 200, headers });
}
headers["Content-Range"] = `bytes ${parsed.start}-${parsed.end}/${size}`;
headers["Content-Length"] = String(parsed.end - parsed.start + 1);
return new Response(req.method === "HEAD" ? null : file.slice(parsed.start, parsed.end + 1), { status: 206, headers });
}