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

Adds aclRepo to @stc/db for canView.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:04:46 +01:00
co-authored by Claude Opus 5.5
parent ea18f18b4f
commit 7f33169487
12 changed files with 533 additions and 0 deletions
+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 });
}
+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from "bun:test";
import { cpSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { canView, createApp } from "../src";
const SAMPLE = resolve(import.meta.dir, "../../../fixtures/courses/sample");
async function setup(user: AuthUser | null = null) {
const dir = mkdtempSync(join(tmpdir(), "stc-app-"));
cpSync(SAMPLE, join(dir, "pub"), { recursive: true });
const manifest = await Bun.file(join(SAMPLE, "stcourse.yaml")).text();
mkdirSync(join(dir, "priv"));
cpSync(SAMPLE, join(dir, "priv"), { recursive: true });
writeFileSync(join(dir, "priv/stcourse.yaml"), manifest.replace("id: sample", "id: secret").replace("visibility: public", "visibility: restricted"));
mkdirSync(join(dir, "broken"));
writeFileSync(join(dir, "broken/stcourse.yaml"), "schema: 1\nid: broken\ntitle: Broken\nroot: /does/not/exist\nmodules: []\n");
symlinkSync("/etc", join(dir, "pub/escape"));
const db = await connect("sqlite://:memory:");
await migrate(db);
const courses = await createCourseRepository([dir]);
const ctx = { db, courses, auth: { userFromRequest: async () => user } } as unknown as ServerContext;
return { ctx, db, dir };
}
const feat = (name: string): Feature => ({ name, routes: { "/api/x": { GET: () => new Response(name) } } });
const user = (over: Partial<AuthUser> = {}): AuthUser => ({ id: "u1", email: "u@x", name: "U", roles: ["learner"], groups: [], ...over });
test("health ok, and an error course does not break it", async () => {
const { ctx, db } = await setup();
expect(ctx.courses.get("broken")?.status).toBe("error");
const app = createApp(ctx, []);
const r = await app.fetch(new Request("http://x/api/health"));
expect(r.status).toBe(200);
expect(await r.json()).toEqual({ ok: true, courses: ctx.courses.list().length, db: "sqlite" });
expect(ctx.courses.list().length).toBe(3);
await db.close();
});
test("media serves a file with Range, and a missing/broken course is 404", async () => {
const { ctx, db } = await setup();
const app = createApp(ctx, []);
const r = await app.fetch(new Request("http://x/api/media/sample/media/clip.mp4", { headers: { Range: "bytes=0-9" } }));
expect(r.status).toBe(206);
expect((await app.fetch(new Request("http://x/api/media/broken/anything"))).status).toBe(404);
expect((await app.fetch(new Request("http://x/api/media/nope/a"))).status).toBe(404);
await db.close();
});
test("traversal, absolute and symlink escapes are 404", async () => {
const { ctx, db } = await setup();
const app = createApp(ctx, []);
for (const p of ["..%2F..%2Fetc%2Fpasswd", "%2Fetc%2Fpasswd", "escape/passwd", "../priv/stcourse.yaml", "%E0%A4%A"]) {
const r = await app.fetch(new Request(`http://x/api/media/sample/${p}`));
expect(r.status).toBe(404);
}
await db.close();
});
test("restricted course: 404 anonymous, visible to owner, author, ACL user and group", async () => {
const url = "http://x/api/media/secret/media/clip.mp4";
const run = async (u: AuthUser | null, seed?: (db: Awaited<ReturnType<typeof setup>>["db"]) => Promise<unknown>) => {
const { ctx, db } = await setup(u);
await seed?.(db);
const s = (await createApp(ctx, []).fetch(new Request(url))).status;
await db.close();
return s;
};
expect(await run(null)).toBe(404);
expect(await run(user())).toBe(404);
expect(await run(user({ roles: ["owner"] }))).toBe(200);
expect(await run(user(), (db) => db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('secret', 'u1')`)).toBe(200);
expect(await run(user(), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'user', 'u1')`)).toBe(200);
expect(await run(user({ groups: ["g"] }), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'group', 'g')`)).toBe(200);
expect(await run(user({ groups: ["h"] }), (db) => db.sql`INSERT INTO course_acl VALUES ('secret', 'group', 'g')`)).toBe(404);
});
test("duplicate method+path across features throws", async () => {
const { ctx, db } = await setup();
expect(() => createApp(ctx, [feat("a"), feat("b")])).toThrow(/Duplicate route GET \/api\/x/);
await db.close();
});
test("feature routes receive RequestContext with the user", async () => {
const u = user();
const { ctx, db } = await setup(u);
const f: Feature = { name: "a", routes: { "/api/who/:id": { GET: (_r, rc) => Response.json({ u: rc.user?.id, id: rc.params.id }) } } };
const app = createApp(ctx, [f]);
expect(await (await app.fetch(new Request("http://x/api/who/42"))).json()).toEqual({ u: "u1", id: "42" });
await db.close();
});
test("canView matrix", async () => {
const { ctx, db } = await setup();
const pub = ctx.courses.get("sample")!, priv = ctx.courses.get("secret")!;
expect(canView(null, pub, [])).toBe(true);
expect(canView(null, priv, [])).toBe(false);
await db.close();
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, test, spyOn } from "bun:test";
import { loadConfig } from "../src";
test("defaults", () => {
const warn = spyOn(console, "warn").mockImplementation(() => {});
const c = loadConfig({});
expect(c.port).toBe(3000);
expect(c.databaseUrl).toBe("sqlite://./data/stcourses.db");
expect(c.dataDir).toBe("./data");
expect(c.courseDirs).toEqual(["./overlays", "./courses"]);
expect(c.publicUrl).toBe("http://localhost:3000");
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
test("colon splitting and overrides", () => {
const c = loadConfig({ STC_PORT: "8080", STC_COURSE_DIRS: "/a b:/c", STC_SECRET: "s", STC_PUBLIC_URL: "https://x.test" });
expect(c.port).toBe(8080);
expect(c.courseDirs).toEqual(["/a b", "/c"]);
expect(c.secret).toBe("s");
expect(c.publicUrl).toBe("https://x.test");
});
test("production without a secret throws", () => {
expect(() => loadConfig({ NODE_ENV: "production" })).toThrow(/STC_SECRET/);
expect(loadConfig({ NODE_ENV: "production", STC_SECRET: "x" }).secret).toBe("x");
});
+61
View File
@@ -0,0 +1,61 @@
import { expect } from "bun:test";
import { forEachDb } from "../../db/test/helpers";
import { jobsRepo } from "@stc/db";
import { createJobQueue } from "../src";
const until = async (f: () => Promise<boolean>) => {
for (let i = 0; i < 200; i++) { if (await f()) return; await Bun.sleep(10); }
throw new Error("timeout");
};
forEachDb("job runs to done with result and persisted progress", async (db) => {
const q = createJobQueue(db, {
ok: async (p, jc) => { await jc.reportProgress(0.5, "half"); return { echoed: p }; },
}, { pollMs: 10 });
q.start();
try {
const id = await q.enqueue("ok", { a: 1 });
await until(async () => (await q.get(id))?.status === "done");
const j = (await q.get(id))!;
expect(j.result).toEqual({ echoed: { a: 1 } });
expect(j.message).toBe("half");
expect(j.progress).toBe(1);
} finally { await q.stop(); }
});
forEachDb("throwing handler gives failed with message", async (db) => {
const q = createJobQueue(db, { bad: async () => { throw new Error("boom"); } }, { pollMs: 10 });
q.start();
try {
const id = await q.enqueue("bad", null);
await until(async () => (await q.get(id))?.status === "failed");
expect((await q.get(id))!.error).toBe("boom");
} finally { await q.stop(); }
});
forEachDb("progress updates persist while running", async (db) => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const q = createJobQueue(db, { slow: async (_p, jc) => { await jc.reportProgress(0.3, "working"); await gate; return 1; } }, { pollMs: 10 });
q.start();
try {
const id = await q.enqueue("slow", null);
await until(async () => (await q.get(id))?.progress === 0.3);
expect((await q.get(id))!.status).toBe("running");
expect((await q.get(id))!.message).toBe("working");
release();
await until(async () => (await q.get(id))?.status === "done");
} finally { release(); await q.stop(); }
});
forEachDb("stale running job is requeued at start", async (db) => {
const id = await jobsRepo(db).insert("ok", { z: 1 });
await jobsRepo(db).claimNext(["ok"]);
expect((await jobsRepo(db).get(id))!.status).toBe("running");
const q = createJobQueue(db, { ok: async () => "again" }, { pollMs: 10 });
q.start();
try {
await until(async () => (await q.get(id))?.status === "done");
expect((await q.get(id))!.result).toBe("again");
} finally { await q.stop(); }
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serveMedia } from "../src";
const dir = mkdtempSync(join(tmpdir(), "stc-media-"));
const file = join(dir, "clip.mp4");
const bytes = new Uint8Array(1000).map((_, i) => i % 251);
writeFileSync(file, bytes);
const req = (range?: string) => new Request("http://x/m", { headers: range ? { Range: range } : {} });
test("full file gives 200", async () => {
const r = await serveMedia(req(), file);
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toBe("video/mp4");
expect(r.headers.get("accept-ranges")).toBe("bytes");
expect(r.headers.get("content-length")).toBe("1000");
expect((await r.arrayBuffer()).byteLength).toBe(1000);
});
test("bytes=0-99 gives 206 with Content-Range", async () => {
const r = await serveMedia(req("bytes=0-99"), file);
expect(r.status).toBe(206);
expect(r.headers.get("content-range")).toBe("bytes 0-99/1000");
expect(r.headers.get("content-length")).toBe("100");
expect(new Uint8Array(await r.arrayBuffer())).toEqual(bytes.slice(0, 100));
});
test("open-ended, suffix and clamped ranges", async () => {
let r = await serveMedia(req("bytes=900-"), file);
expect(r.status).toBe(206);
expect(r.headers.get("content-range")).toBe("bytes 900-999/1000");
r = await serveMedia(req("bytes=-10"), file);
expect(r.headers.get("content-range")).toBe("bytes 990-999/1000");
expect(new Uint8Array(await r.arrayBuffer())).toEqual(bytes.slice(990));
r = await serveMedia(req("bytes=990-5000"), file);
expect(r.headers.get("content-range")).toBe("bytes 990-999/1000");
});
test("unsatisfiable range gives 416", async () => {
const r = await serveMedia(req("bytes=999999999-"), file);
expect(r.status).toBe(416);
expect(r.headers.get("content-range")).toBe("bytes */1000");
});
test("non-ASCII filename gets a valid filename*", async () => {
const f = join(dir, "Æble notes.pdf");
writeFileSync(f, "pdf");
const r = await serveMedia(req(), f, { download: true });
expect(r.headers.get("content-type")).toBe("application/pdf");
const cd = r.headers.get("content-disposition")!;
expect(cd).toContain("filename*=UTF-8''%C3%86ble%20notes.pdf");
expect(cd).toContain('filename="_ble notes.pdf"');
});
test("missing file is 404", async () => {
expect((await serveMedia(req(), join(dir, "nope.mp4"))).status).toBe(404);
});
+1
View File
@@ -5,3 +5,4 @@ export { notesRepo, type Note, type NoteInput } from "./repos/notes";
export { attemptsRepo, type Attempt } from "./repos/attempts";
export { libraryRepo, type Collection } from "./repos/library";
export { jobsRepo, type JobPatch } from "./repos/jobs";
export { aclRepo, type AclEntry } from "./repos/acl";
+16
View File
@@ -0,0 +1,16 @@
import type { Db } from "../connect";
export interface AclEntry { principal_type: "user" | "group"; principal_id: string }
export function aclRepo(db: Db) {
return {
async listForCourse(courseId: string): Promise<AclEntry[]> {
const rows = await db.sql`SELECT principal_type, principal_id FROM course_acl WHERE course_id = ${courseId} ORDER BY principal_type, principal_id`;
return rows.map((r: AclEntry) => ({ principal_type: r.principal_type, principal_id: r.principal_id }));
},
async isAuthor(courseId: string, userId: string): Promise<boolean> {
const rows = await db.sql`SELECT 1 AS x FROM course_authors WHERE course_id = ${courseId} AND user_id = ${userId}`;
return rows.length > 0;
},
};
}
+17
View File
@@ -0,0 +1,17 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { aclRepo } from "../src";
forEachDb("aclRepo lists ACL rows and detects authors", async (db) => {
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('c', 'user', 'u1'), ('c', 'group', 'g1'), ('d', 'user', 'u2')`;
await db.sql`INSERT INTO course_authors (course_id, user_id) VALUES ('c', 'a1')`;
const r = aclRepo(db);
expect(await r.listForCourse("c")).toEqual([
{ principal_type: "group", principal_id: "g1" },
{ principal_type: "user", principal_id: "u1" },
]);
expect(await r.listForCourse("none")).toEqual([]);
expect(await r.isAuthor("c", "a1")).toBe(true);
expect(await r.isAuthor("c", "u1")).toBe(false);
expect(await r.isAuthor("d", "a1")).toBe(false);
});