Files
t5de-hub/apps/api/test/tokens.test.ts
T
dhkatzandClaude Opus 5.5 2b88a52bcb
Pull request / title (pull_request) Successful in 0s
CI / test (pull_request) Successful in 1m9s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: personal API tokens
Tokens for scripts, made, listed and revoked on the account page. Each grants some of the person's own permissions
at chosen places (a product of theirs, a catalog whose team they're on, or the hub for moderators); every check is
the intersection with what they hold now. Tokens work only on routes that check a permission (session-only routes
answer token_not_allowed), are shown once and stored as hashes, note their last use, and mark the audit entries of
what they do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:58:58 -08:00

214 lines
11 KiB
TypeScript

// API tokens (docs/console.md): made from a session, limited to their grants and to what their owner holds now,
// usable only on routes that check permissions, audited by name, revocable.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// Catalog addresses that answer /health (for address changes).
const healthy = new Set<string>(["https://moved.example.test"]);
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({
db,
auth,
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
apiURL,
webURL,
catalogHealthy: async (url) => healthy.has(url),
});
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; name: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
/** A person's catalog, registered and approved; its key's Authorization header. */
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
const reg = await call(owner, "POST", "/api/v1/catalogs", {
name,
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
contact: "[email protected]",
visibility: "public",
membership,
});
expect(reg.status).toBe(201);
const id = reg.json.catalog.id as string;
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
}
/** A product uploaded by a person to a catalog; its ID. */
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const r = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
/** A revision with these files, by a person, to a catalog: the answer. */
async function uploadFiles(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, files: unknown[]) {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
return call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files, rating: "general", parent: 0, name, notes: "" }, c.auth);
}
let admin: Person;
let mod: Person;
test("the first account is the admin; a moderator", async () => {
admin = await person("admin1");
mod = await person("warden");
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
});
const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
const at = (type: "product" | "catalog", id: string) => ({ type, id });
test("tokens: made from a session, limited to their grants, usable only where permissions are checked", async () => {
const owner = await person("shelfowner");
const maker = await person("maker");
const c = await catalogOf(owner, admin, "Token Shelf");
const hat = await upload(maker, c, "Hat", 1);
const scarf = await upload(maker, c, "Scarf", 2);
const theirs = await upload(owner, c, "Owner's Coat", 3);
// Only what the person holds now.
const make = (p: Person, grants: unknown[], name = "script", days = 30) => call(p, "POST", "/api/v1/account/tokens", { name, days, grants });
expect((await make(maker, [{ permission: "product:edit", scope: at("product", theirs) }])).json.error).toBe("not_held");
expect((await make(maker, [{ permission: "product:remove", scope: { type: "hub" } }])).json.error).toBe("not_held");
expect((await make(maker, [{ permission: "no:such", scope: at("product", hat) }])).status).toBe(400);
expect((await make(maker, [])).status).toBe(400);
expect((await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "x", 400)).status).toBe(400);
expect((await make(undefined as unknown as Person, [{ permission: "product:edit", scope: at("product", hat) }])).status).toBe(401);
const made = await make(maker, [
{ permission: "product:edit", scope: at("product", hat) },
{ permission: "revision:upload", scope: at("product", hat) },
{ permission: "product:edit", scope: at("product", hat) },
], "Hat updater");
expect(made.status).toBe(201);
const tok = made.json.token as string;
expect(tok).toMatch(/^t5de_pat_[A-Za-z0-9_-]{43}$/);
expect(made.json.info).toMatchObject({ name: "Hat updater", status: "active", lastUsedAt: null });
expect(made.json.info.grants).toEqual([
{ permission: "product:edit", scope: at("product", hat), name: "Hat" },
{ permission: "revision:upload", scope: at("product", hat), name: "Hat" },
]);
expect(JSON.stringify(made.json.info)).not.toContain(tok);
// What it grants, there.
const asToken = (method: string, path: string, body?: unknown) => call(undefined, method, path, body, bearer(tok));
expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "By script" })).status).toBe(200);
const other = await asToken("PATCH", `/api/v1/products/${scarf}`, { description: "Nope" });
expect(other.status).toBe(403);
expect(other.json.missing).toEqual({ permission: "product:edit", scope: { product: scarf } });
expect((await asToken("POST", `/api/v1/products/${hat}/publish`)).status).toBe(403);
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: hat })).status).toBe(200);
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: scarf })).status).toBe(403);
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id })).json.error).toBe("token_not_allowed");
// Nothing else: session-only routes refuse it, and it can't make tokens; elsewhere it's signed out.
expect((await asToken("GET", "/api/v1/me")).json.error).toBe("token_not_allowed");
expect((await asToken("POST", "/api/v1/account/tokens", { name: "more", days: 1, grants: [{ permission: "product:edit", scope: at("product", hat) }] })).json.error).toBe("token_not_allowed");
expect((await asToken("DELETE", `/api/v1/products/${hat}`)).json.error).toBe("token_not_allowed");
expect((await asToken("POST", "/api/v1/cases", { kind: "report", about: { product: theirs }, reason: "x" })).json.error).toBe("token_not_allowed");
// Audited as the person, by token; listed with its last use.
const entry = (await call(maker, "GET", `/api/v1/console/audit?action=product.edit&product=${hat}`)).json.entries[0];
expect(entry).toMatchObject({ actor: "maker", detail: { token: "Hat updater" } });
const listed = (await call(maker, "GET", "/api/v1/account/tokens")).json.tokens;
expect(listed).toHaveLength(1);
expect(listed[0].lastUsedAt).not.toBeNull();
expect((await call(owner, "GET", "/api/v1/account/tokens")).json.tokens).toEqual([]);
// Revoked or expired: refused.
expect((await call(owner, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(404);
expect((await call(maker, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(204);
expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "Again" })).json.error).toBe("bad_token");
expect((await call(maker, "GET", "/api/v1/account/tokens")).json.tokens[0].status).toBe("revoked");
expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer("t5de_pat_nope"))).json.error).toBe("bad_token");
const short = (await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "short", 1)).json;
await db.update(schema.apiToken).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(schema.apiToken.id, short.info.id));
expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer(short.token))).json.error).toBe("bad_token");
});
test("tokens never have more than their owner holds now", async () => {
const made = await call(mod, "POST", "/api/v1/account/tokens", { name: "blocks", days: 7, grants: [{ permission: "product:remove", scope: { type: "hub" } }] });
expect(made.status).toBe(201);
const tok = made.json.token as string;
expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(200);
// Its grants don't reach other permissions the moderator holds.
expect((await call(undefined, "GET", `/api/v1/catalogs/${(await call(mod, "GET", "/api/v1/console/catalogs")).json.catalogs[0].id}/team`, undefined, bearer(tok))).status).toBe(403);
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "user" })).status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(403);
});