feat: personal API tokens
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

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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 14:58:58 -08:00
1 parent 58a3e70a63
commit 2b88a52bcb
17 files changed
+4358 -98

No files matched your search

+210 -84
View File
@@ -8,6 +8,7 @@ import {
desc, desc,
eq, eq,
ilike, ilike,
isNull,
inArray, inArray,
ne, ne,
or, or,
@@ -46,6 +47,7 @@ import { Settings } from "./settings";
import { Catalogs } from "./catalogs"; import { Catalogs } from "./catalogs";
import { appealable, caseKinds, Cases, type Case, type CaseKind } from "./cases"; import { appealable, caseKinds, Cases, type Case, type CaseKind } from "./cases";
import { Notifications } from "./notifications"; import { Notifications } from "./notifications";
import { maxGrants, maxTokenDays, tokenPrefix, Tokens } from "./tokens";
import { assignableCatalogRoles, Forbidden, hubScope, Permissions, roles as roleBundles, type Scope } from "./permissions"; import { assignableCatalogRoles, Forbidden, hubScope, Permissions, roles as roleBundles, type Scope } from "./permissions";
const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product, hosting } = schema; const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product, hosting } = schema;
@@ -140,6 +142,7 @@ export function createApp({
const signer = new Signer(db, apiURL ?? "http://localhost:4101"); const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer, imvu); const products = new Products(db, signer, imvu);
const perms = new Permissions(db); const perms = new Permissions(db);
const tokens = new Tokens(db, perms);
const catalogs = new Catalogs(db, catalogHealthy); const catalogs = new Catalogs(db, catalogHealthy);
const rooms = roomsService ?? new Rooms(db, imvu); const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db); const hosted = new HostedRooms(db);
@@ -264,7 +267,7 @@ export function createApp({
/** Writes an audit entry. Tags (who may see it, docs/console.md) default from the action: catalog.*, product.* /** Writes an audit entry. Tags (who may see it, docs/console.md) default from the action: catalog.*, product.*
* and hosted_room.* entries are about their target. */ * and hosted_room.* entries are about their target. */
const audited = ( const audited = (
actorId: string | null, actor: string | null | { id: string; token?: { name: string } },
action: string, action: string,
target: string, target: string,
detail: Record<string, unknown> = {}, detail: Record<string, unknown> = {},
@@ -276,7 +279,10 @@ export function createApp({
else if (action.startsWith("product.")) t.product = target; else if (action.startsWith("product.")) t.product = target;
else if (action.startsWith("hosted_room.")) t.room = target; else if (action.startsWith("hosted_room.")) t.room = target;
} }
return db.insert(audit).values({ actorId, action, target, detail, tags: t }); // Actions by API token are the person's, marked with the token's name.
const actorId = typeof actor === "object" && actor ? actor.id : actor;
const token = typeof actor === "object" && actor?.token ? actor.token.name : undefined;
return db.insert(audit).values({ actorId, action, target, detail: token ? { ...detail, token } : detail, tags: t });
}; };
/** Runs a check, turning a missing permission into a Problem. */ /** Runs a check, turning a missing permission into a Problem. */
@@ -411,6 +417,31 @@ export function createApp({
type ConsoleSection = "overview" | "products" | "catalogs" | "cases" | "people" | "blocks" | "audit" | "settings"; type ConsoleSection = "overview" | "products" | "catalogs" | "cases" | "people" | "blocks" | "audit" | "settings";
/** Tokens as their owner sees them (never the hash), with their scopes' names. */
const tokenViews = async (rows: (typeof schema.apiToken.$inferSelect)[]) => {
const ids = (type: string) => [
...new Set(rows.flatMap((r) => r.grants.flatMap((g) => (g.scope.type === type && "id" in g.scope ? [g.scope.id] : [])))),
];
const names: Record<string, string> = {};
const cs = ids("catalog");
const ps = ids("product");
if (cs.length) for (const c of await db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, cs))) names[c.id] = c.name;
if (ps.length)
for (const p of await db.select({ id: schema.product.id, name: schema.product.name }).from(schema.product).where(inArray(schema.product.id, ps)))
names[p.id] = p.name;
const now = Date.now();
return rows.map((r) => ({
id: r.id,
name: r.name,
grants: r.grants.map((g) => ({ ...g, name: "id" in g.scope ? (names[g.scope.id] ?? g.scope.id) : "T5DE" })),
status: r.revokedAt ? ("revoked" as const) : r.expiresAt.getTime() <= now ? ("expired" as const) : ("active" as const),
createdAt: r.createdAt.toISOString(),
expiresAt: r.expiresAt.toISOString(),
lastUsedAt: r.lastUsedAt?.toISOString() ?? null,
revokedAt: r.revokedAt?.toISOString() ?? null,
}));
};
const blockParam = t.Object({ sha256: t.String({ pattern: "^[0-9a-f]{64}$" }) }); const blockParam = t.Object({ sha256: t.String({ pattern: "^[0-9a-f]{64}$" }) });
const blockView = (b: { block: typeof schema.fileBlock.$inferSelect; by: string | null }) => ({ const blockView = (b: { block: typeof schema.fileBlock.$inferSelect; by: string | null }) => ({
sha256: b.block.sha256, sha256: b.block.sha256,
@@ -477,17 +508,17 @@ export function createApp({
products.updateInfo(me.id, id, { rating: "general" }, reason, { creator: false, editLocked: true, rateLower: true }), products.updateInfo(me.id, id, { rating: "general" }, reason, { creator: false, editLocked: true, rateLower: true }),
); );
if (done.entry) { if (done.entry) {
await audited(me.id, "product.edit", id, { fields: done.fields, reason, case: c.id }, { product: id, person: creatorId }); await audited(me, "product.edit", id, { fields: done.fields, reason, case: c.id }, { product: id, person: creatorId });
await productEdited(me, id, creatorId, "edit", done.fields, reason); await productEdited(me, id, creatorId, "edit", done.fields, reason);
} }
} else if (c.kind === "removal-request" && c.tags.product) { } else if (c.kind === "removal-request" && c.tags.product) {
const reason = resolution || c.reason; const reason = resolution || c.reason;
const removed = await productCall(() => products.removeByModeration(c.tags.product!, reason)); const removed = await productCall(() => products.removeByModeration(c.tags.product!, reason));
await audited(me.id, "product.remove", removed.id, { reason, case: c.id }, { product: removed.id, person: removed.creatorId }); await audited(me, "product.remove", removed.id, { reason, case: c.id }, { product: removed.id, person: removed.creatorId });
await notifications.add(removed.creatorId, "product_removed", { product: removed.id, name: removed.name, reason }, { product: removed.id }, me.id); await notifications.add(removed.creatorId, "product_removed", { product: removed.id, name: removed.name, reason }, { product: removed.id }, me.id);
} else if (c.kind === "catalog-deletion" && c.tags.catalog) { } else if (c.kind === "catalog-deletion" && c.tags.catalog) {
const deleted = await productCall(() => catalogs.markDeleted(c.tags.catalog!, resolution || c.reason)); const deleted = await productCall(() => catalogs.markDeleted(c.tags.catalog!, resolution || c.reason));
await audited(me.id, "catalog.delete", deleted.id, { name: deleted.name, case: c.id }); await audited(me, "catalog.delete", deleted.id, { name: deleted.name, case: c.id });
} }
}; };
@@ -754,6 +785,20 @@ export function createApp({
} }
}) })
.derive(async ({ request }) => { .derive(async ({ request }) => {
// An API token: only routes open to scripts (`scripted`) take it; everywhere else it's signed out.
const bearer = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1];
if (bearer?.startsWith(tokenPrefix)) {
const tok = await tokens.verify(bearer);
const owner = tok ? (await db.select().from(user).where(eq(user.id, tok.userId)))[0] : undefined;
const ok = owner && !owner.banned && owner.approved ? owner : undefined;
return {
person: undefined,
waiting: undefined,
sessionId: undefined,
tokenPerson: tok && ok ? Object.assign(ok, { token: { id: tok.id, name: tok.name, grants: tok.grants } }) : undefined,
badToken: !(tok && ok),
};
}
const s = await auth.api.getSession({ headers: request.headers }); const s = await auth.api.getSession({ headers: request.headers });
const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined; const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined;
// A person waiting for approval counts as signed out everywhere but their own account (/me). // A person waiting for approval counts as signed out everywhere but their own account (/me).
@@ -762,32 +807,47 @@ export function createApp({
person: usable?.approved ? usable : undefined, person: usable?.approved ? usable : undefined,
waiting: usable && !usable.approved ? usable : undefined, waiting: usable && !usable.approved ? usable : undefined,
sessionId: s?.session.id, sessionId: s?.session.id,
tokenPerson: undefined,
badToken: false,
}; };
}) })
.macro({ .macro({
/** Signed in, approved or still waiting. */ /** Signed in, approved or still waiting. */
account: { account: {
resolve: ({ person, waiting }) => { resolve: ({ person, waiting, tokenPerson, badToken }) => {
if (tokenPerson || badToken) throw new Problem(403, "token_not_allowed", "API tokens can't do this. Sign in on the website.");
const me = person ?? waiting; const me = person ?? waiting;
if (!me) throw new Problem(401, "signed_out", "Sign in first."); if (!me) throw new Problem(401, "signed_out", "Sign in first.");
return { me }; return { me };
}, },
}, },
signedIn: { signedIn: {
resolve: ({ person, waiting }) => { resolve: ({ person, waiting, tokenPerson, badToken }) => {
if (tokenPerson || badToken) throw new Problem(403, "token_not_allowed", "API tokens can't do this. Sign in on the website.");
if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval."); if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval.");
if (!person) throw new Problem(401, "signed_out", "Sign in first."); if (!person) throw new Problem(401, "signed_out", "Sign in first.");
return { me: person }; return { me: person };
}, },
}, },
moderator: { moderator: {
resolve: ({ person, waiting }) => { resolve: ({ person, waiting, tokenPerson, badToken }) => {
if (tokenPerson || badToken) throw new Problem(403, "token_not_allowed", "API tokens can't do this. Sign in on the website.");
if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval."); if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval.");
if (!person) throw new Problem(401, "signed_out", "Sign in first."); if (!person) throw new Problem(401, "signed_out", "Sign in first.");
if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this."); if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this.");
return { me: person }; return { me: person };
}, },
}, },
/** Signed in, or an API token (whose grants then limit every permission check). */
scripted: {
resolve: ({ person, waiting, tokenPerson, badToken }) => {
if (badToken) throw new Problem(401, "bad_token", "That token isn't valid: wrong, revoked or expired.");
if (tokenPerson) return { me: tokenPerson };
if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval.");
if (!person) throw new Problem(401, "signed_out", "Sign in first.");
return { me: person };
},
},
}) })
// Who's signed in, and the home page. // Who's signed in, and the home page.
@@ -1032,7 +1092,7 @@ export function createApp({
await age.forgetAccount(l.imvuId); await age.forgetAccount(l.imvuId);
await rooms.forgetOwner(l.imvuId); await rooms.forgetOwner(l.imvuId);
realtime.unlinked(me.id, l.imvuId); realtime.unlinked(me.id, l.imvuId);
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username }); await audited(me, "link.remove", String(l.imvuId), { imvu: l.username });
set.status = 204; set.status = 204;
}, },
{ signedIn: true, params: imvuIdParam }, { signedIn: true, params: imvuIdParam },
@@ -1059,7 +1119,7 @@ export function createApp({
} catch { } catch {
throw new Problem(404, "not_found", "That code isn't right, or it expired. Check the code T5DE shows."); throw new Problem(404, "not_found", "That code isn't right, or it expired. Check the code T5DE shows.");
} }
await audited(me.id, "proxy.signin", ""); await audited(me, "proxy.signin", "");
set.status = 204; set.status = 204;
}, },
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) }, { signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
@@ -1105,7 +1165,7 @@ export function createApp({
if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000)) if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while."); throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while.");
const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body)); const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body));
await audited(me.id, "hosted_room.create", String(r.room.n), { await audited(me, "hosted_room.create", String(r.room.n), {
name: r.room.name, name: r.room.name,
rating: r.room.rating, rating: r.room.rating,
privacy: r.room.privacy, privacy: r.room.privacy,
@@ -1175,7 +1235,7 @@ export function createApp({
: rest; : rest;
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes)); const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes));
realtime.hostedChanged(r.room.n, roomInfo(r)); realtime.hostedChanged(r.room.n, roomInfo(r));
await audited(me.id, "hosted_room.edit", String(r.room.n), { await audited(me, "hosted_room.edit", String(r.room.n), {
fields: Object.keys(body), fields: Object.keys(body),
...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}), ...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}),
}); });
@@ -1218,7 +1278,7 @@ export function createApp({
async ({ me, params, set }) => { async ({ me, params, set }) => {
const r = await hostedCall(() => hosted.close(me.id, isModerator(me), params.n)); const r = await hostedCall(() => hosted.close(me.id, isModerator(me), params.n));
realtime.hostedClosed(r.n); realtime.hostedClosed(r.n);
await audited(me.id, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) }); await audited(me, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) });
set.status = 204; set.status = 204;
}, },
{ signedIn: true, params: hostedParam }, { signedIn: true, params: hostedParam },
@@ -1254,7 +1314,7 @@ export function createApp({
async ({ me, params, set }) => { async ({ me, params, set }) => {
if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu)))) if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu))))
throw new Problem(404, "not_found", "That account isn't banned here."); throw new Problem(404, "not_found", "That account isn't banned here.");
await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu }); await audited(me, "hosted_room.unban", String(params.n), { imvu: params.imvu });
set.status = 204; set.status = 204;
}, },
{ {
@@ -1291,7 +1351,7 @@ export function createApp({
if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB."); if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB.");
const data = Buffer.from(await body.image.arrayBuffer()); const data = Buffer.from(await body.image.arrayBuffer());
const r = await hostedCall(() => hosted.setImage(me.id, params.n, data)); const r = await hostedCall(() => hosted.setImage(me.id, params.n, data));
await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length }); await audited(me, "hosted_room.image", String(params.n), { bytes: data.length });
return { room: await hostedViewFor(r, me) }; return { room: await hostedViewFor(r, me) };
}, },
{ {
@@ -1309,7 +1369,7 @@ export function createApp({
"/hosted-rooms/:n/image", "/hosted-rooms/:n/image",
async ({ me, params }) => { async ({ me, params }) => {
const r = await hostedCall(() => hosted.removeImage(me.id, params.n)); const r = await hostedCall(() => hosted.removeImage(me.id, params.n));
await audited(me.id, "hosted_room.image", String(params.n), { removed: true }); await audited(me, "hosted_room.image", String(params.n), { removed: true });
return { room: await hostedViewFor(r, me) }; return { room: await hostedViewFor(r, me) };
}, },
{ signedIn: true, params: hostedParam }, { signedIn: true, params: hostedParam },
@@ -1389,7 +1449,7 @@ export function createApp({
"/catalogs", "/catalogs",
async ({ me, body, set }) => { async ({ me, body, set }) => {
const r = await productCall(() => products.registerCatalog(me.id, body)); const r = await productCall(() => products.registerCatalog(me.id, body));
await audited(me.id, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url }); await audited(me, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url });
set.status = 201; set.status = 201;
return { catalog: catalogView(r.catalog), key: r.key }; return { catalog: catalogView(r.catalog), key: r.key };
}, },
@@ -1432,11 +1492,11 @@ export function createApp({
async ({ me, params, body }) => { async ({ me, params, body }) => {
await need(me, "catalog:edit", catalogScope(params.id)); await need(me, "catalog:edit", catalogScope(params.id));
const r = await productCall(() => catalogs.update(params.id, body)); const r = await productCall(() => catalogs.update(params.id, body));
if (Object.keys(r.changed).length) await audited(me.id, "catalog.edit", params.id, { changed: r.changed }); if (Object.keys(r.changed).length) await audited(me, "catalog.edit", params.id, { changed: r.changed });
return { catalog: catalogView(r.catalog) }; return { catalog: catalogView(r.catalog) };
}, },
{ {
signedIn: true, scripted: true,
params: catalogParam, params: catalogParam,
body: t.Object({ body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })), name: t.Optional(t.String({ maxLength: 64 })),
@@ -1455,20 +1515,20 @@ export function createApp({
async ({ me, params }) => { async ({ me, params }) => {
await need(me, "catalog:rotate-key", catalogScope(params.id)); await need(me, "catalog:rotate-key", catalogScope(params.id));
const key = await productCall(() => catalogs.rotateKey(params.id)); const key = await productCall(() => catalogs.rotateKey(params.id));
await audited(me.id, "catalog.rotate_key", params.id); await audited(me, "catalog.rotate_key", params.id);
return { key }; return { key };
}, },
{ signedIn: true, params: catalogParam }, { scripted: true, params: catalogParam },
) )
.post( .post(
"/catalogs/:id/suspend", "/catalogs/:id/suspend",
async ({ me, params, body }) => { async ({ me, params, body }) => {
await need(me, "catalog:suspend", catalogScope(params.id)); await need(me, "catalog:suspend", catalogScope(params.id));
const c = await productCall(() => catalogs.setSuspended(params.id, true, body.reason.trim())); const c = await productCall(() => catalogs.setSuspended(params.id, true, body.reason.trim()));
await audited(me.id, "catalog.suspend", c.id, { name: c.name, reason: body.reason.trim() }); await audited(me, "catalog.suspend", c.id, { name: c.name, reason: body.reason.trim() });
return { catalog: catalogView(c) }; return { catalog: catalogView(c) };
}, },
{ signedIn: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) }, { scripted: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
) )
// Deleting a catalog goes to moderators for now (docs/console.md): its owner opens a catalog-deletion case. // Deleting a catalog goes to moderators for now (docs/console.md): its owner opens a catalog-deletion case.
.post( .post(
@@ -1480,20 +1540,20 @@ export function createApp({
const open = await cases.findOpen("catalog-deletion", { catalog: c.id }); const open = await cases.findOpen("catalog-deletion", { catalog: c.id });
if (open) throw new Problem(409, "requested", "Its deletion was requested already.", { case: open.id }); if (open) throw new Problem(409, "requested", "Its deletion was requested already.", { case: open.id });
const kase = await cases.open("catalog-deletion", me.id, { catalog: c.id }, body.reason); const kase = await cases.open("catalog-deletion", me.id, { catalog: c.id }, body.reason);
await audited(me.id, "case.open", String(kase.id), { kind: kase.kind }, kase.tags); await audited(me, "case.open", String(kase.id), { kind: kase.kind }, kase.tags);
return { case: kase.id }; return { case: kase.id };
}, },
{ signedIn: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 2000 }) }) }, { scripted: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 2000 }) }) },
) )
.post( .post(
"/catalogs/:id/reinstate", "/catalogs/:id/reinstate",
async ({ me, params }) => { async ({ me, params }) => {
await need(me, "catalog:suspend", catalogScope(params.id)); await need(me, "catalog:suspend", catalogScope(params.id));
const c = await productCall(() => catalogs.setSuspended(params.id, false, "")); const c = await productCall(() => catalogs.setSuspended(params.id, false, ""));
await audited(me.id, "catalog.reinstate", c.id, { name: c.name }); await audited(me, "catalog.reinstate", c.id, { name: c.name });
return { catalog: catalogView(c) }; return { catalog: catalogView(c) };
}, },
{ signedIn: true, params: catalogParam }, { scripted: true, params: catalogParam },
) )
// A catalog's team: the owner, and the roles it gave. // A catalog's team: the owner, and the roles it gave.
@@ -1511,7 +1571,7 @@ export function createApp({
})), })),
}; };
}, },
{ signedIn: true, params: catalogParam }, { scripted: true, params: catalogParam },
) )
.put( .put(
"/catalogs/:id/team/:username", "/catalogs/:id/team/:username",
@@ -1522,11 +1582,11 @@ export function createApp({
throw new Problem(403, "forbidden", "You can only give roles whose permissions you have."); throw new Problem(403, "forbidden", "You can only give roles whose permissions you have.");
const p = await personByUsername(params.username); const p = await personByUsername(params.username);
await productCall(() => catalogs.addRole(params.id, p.id, body.role, me.id)); await productCall(() => catalogs.addRole(params.id, p.id, body.role, me.id));
await audited(me.id, "catalog.team_add", params.id, { person: p.username, role: body.role }, { catalog: params.id, person: p.id }); await audited(me, "catalog.team_add", params.id, { person: p.username, role: body.role }, { catalog: params.id, person: p.id });
return { team: await perms.team(params.id) }; return { team: await perms.team(params.id) };
}, },
{ {
signedIn: true, scripted: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
body: t.Object({ role: t.Literal("catalog-operator") }), body: t.Object({ role: t.Literal("catalog-operator") }),
}, },
@@ -1539,11 +1599,11 @@ export function createApp({
// Anyone may leave a team; taking others off needs team:manage. // Anyone may leave a team; taking others off needs team:manage.
if (p.id !== me.id) await need(me, "team:manage", scope); if (p.id !== me.id) await need(me, "team:manage", scope);
await productCall(() => catalogs.removeRole(params.id, p.id, query.role)); await productCall(() => catalogs.removeRole(params.id, p.id, query.role));
await audited(me.id, "catalog.team_remove", params.id, { person: p.username, role: query.role }, { catalog: params.id, person: p.id }); await audited(me, "catalog.team_remove", params.id, { person: p.username, role: query.role }, { catalog: params.id, person: p.id });
return { team: await perms.team(params.id) }; return { team: await perms.team(params.id) };
}, },
{ {
signedIn: true, scripted: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
query: t.Object({ role: t.Literal("catalog-operator") }), query: t.Object({ role: t.Literal("catalog-operator") }),
}, },
@@ -1555,7 +1615,7 @@ export function createApp({
async ({ me, params, body }) => { async ({ me, params, body }) => {
const invite = body?.invite || undefined; const invite = body?.invite || undefined;
const status = await productCall(() => catalogs.join(params.id, me.id, invite)); const status = await productCall(() => catalogs.join(params.id, me.id, invite));
await audited(me.id, status === "member" ? "catalog.join" : "catalog.request", params.id, invite ? { invite: true } : {}, { await audited(me, status === "member" ? "catalog.join" : "catalog.request", params.id, invite ? { invite: true } : {}, {
catalog: params.id, catalog: params.id,
person: me.id, person: me.id,
}); });
@@ -1572,7 +1632,7 @@ export function createApp({
"/catalogs/:id/join", "/catalogs/:id/join",
async ({ me, params, set }) => { async ({ me, params, set }) => {
await catalogs.leave(params.id, me.id); await catalogs.leave(params.id, me.id);
await audited(me.id, "catalog.leave", params.id, {}, { catalog: params.id, person: me.id }); await audited(me, "catalog.leave", params.id, {}, { catalog: params.id, person: me.id });
realtime.notify(me.id, { type: "library" }); realtime.notify(me.id, { type: "library" });
set.status = 204; set.status = 204;
}, },
@@ -1585,7 +1645,7 @@ export function createApp({
return { members: await catalogs.members(params.id, query.status), counts: await catalogs.memberCounts(params.id) }; return { members: await catalogs.members(params.id, query.status), counts: await catalogs.memberCounts(params.id) };
}, },
{ {
signedIn: true, scripted: true,
params: catalogParam, params: catalogParam,
query: t.Object({ query: t.Object({
status: t.Optional(t.Union([t.Literal("requested"), t.Literal("member"), t.Literal("banned")])), status: t.Optional(t.Union([t.Literal("requested"), t.Literal("member"), t.Literal("banned")])),
@@ -1601,7 +1661,7 @@ export function createApp({
await productCall(() => catalogs.setMember(params.id, p.id, body.status, me.id)); await productCall(() => catalogs.setMember(params.id, p.id, body.status, me.id));
const action = const action =
body.status === "banned" ? "catalog.member_ban" : before === "requested" ? "catalog.member_approve" : "catalog.member_add"; body.status === "banned" ? "catalog.member_ban" : before === "requested" ? "catalog.member_approve" : "catalog.member_add";
await audited(me.id, action, params.id, { person: p.username, ...(before ? { from: before } : {}) }, { await audited(me, action, params.id, { person: p.username, ...(before ? { from: before } : {}) }, {
catalog: params.id, catalog: params.id,
person: p.id, person: p.id,
}); });
@@ -1613,7 +1673,7 @@ export function createApp({
return { membership: body.status }; return { membership: body.status };
}, },
{ {
signedIn: true, scripted: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
body: t.Object({ status: t.Union([t.Literal("member"), t.Literal("banned")]) }), body: t.Object({ status: t.Union([t.Literal("member"), t.Literal("banned")]) }),
}, },
@@ -1627,7 +1687,7 @@ export function createApp({
await productCall(() => catalogs.removeMember(params.id, p.id)); await productCall(() => catalogs.removeMember(params.id, p.id));
const action = const action =
before === "requested" ? "catalog.member_decline" : before === "banned" ? "catalog.member_unban" : "catalog.member_remove"; before === "requested" ? "catalog.member_decline" : before === "banned" ? "catalog.member_unban" : "catalog.member_remove";
await audited(me.id, action, params.id, { person: p.username }, { catalog: params.id, person: p.id }); await audited(me, action, params.id, { person: p.username }, { catalog: params.id, person: p.id });
realtime.notify(p.id, { type: "library" }); realtime.notify(p.id, { type: "library" });
if (before === "requested") { if (before === "requested") {
const c = await catalogs.get(params.id); const c = await catalogs.get(params.id);
@@ -1636,7 +1696,7 @@ export function createApp({
set.status = 204; set.status = 204;
}, },
{ {
signedIn: true, scripted: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
}, },
) )
@@ -1646,14 +1706,14 @@ export function createApp({
await need(me, "member:invite", catalogScope(params.id)); await need(me, "member:invite", catalogScope(params.id));
return { invites: (await catalogs.invites(params.id)).map(inviteView) }; return { invites: (await catalogs.invites(params.id)).map(inviteView) };
}, },
{ signedIn: true, params: catalogParam }, { scripted: true, params: catalogParam },
) )
.post( .post(
"/catalogs/:id/invites", "/catalogs/:id/invites",
async ({ me, params, body, set }) => { async ({ me, params, body, set }) => {
await need(me, "member:invite", catalogScope(params.id)); await need(me, "member:invite", catalogScope(params.id));
const inv = await productCall(() => catalogs.createInvite(params.id, me.id, body)); const inv = await productCall(() => catalogs.createInvite(params.id, me.id, body));
await audited(me.id, "catalog.invite_create", params.id, { await audited(me, "catalog.invite_create", params.id, {
maxUses: inv.maxUses, maxUses: inv.maxUses,
expiresAt: inv.expiresAt?.toISOString() ?? null, expiresAt: inv.expiresAt?.toISOString() ?? null,
}); });
@@ -1661,7 +1721,7 @@ export function createApp({
return { invite: inviteView(inv) }; return { invite: inviteView(inv) };
}, },
{ {
signedIn: true, scripted: true,
params: catalogParam, params: catalogParam,
body: t.Object({ body: t.Object({
maxUses: t.Optional(t.Integer({ minimum: 1, maximum: 10_000 })), maxUses: t.Optional(t.Integer({ minimum: 1, maximum: 10_000 })),
@@ -1674,11 +1734,11 @@ export function createApp({
async ({ me, params, set }) => { async ({ me, params, set }) => {
await need(me, "member:invite", catalogScope(params.id)); await need(me, "member:invite", catalogScope(params.id));
await productCall(() => catalogs.revokeInvite(params.id, params.code)); await productCall(() => catalogs.revokeInvite(params.id, params.code));
await audited(me.id, "catalog.invite_revoke", params.id, { code: params.code.slice(0, 3) + "…" }); await audited(me, "catalog.invite_revoke", params.id, { code: params.code.slice(0, 3) + "…" });
set.status = 204; set.status = 204;
}, },
{ {
signedIn: true, scripted: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), code: t.String({ maxLength: 32 }) }), params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), code: t.String({ maxLength: 32 }) }),
}, },
) )
@@ -1690,7 +1750,7 @@ export function createApp({
await need(me, "audit:read", catalogScope(params.id)); await need(me, "audit:read", catalogScope(params.id));
return { products: await catalogs.hosted(params.id) }; return { products: await catalogs.hosted(params.id) };
}, },
{ signedIn: true, params: catalogParam }, { scripted: true, params: catalogParam },
) )
.delete( .delete(
"/catalogs/:id/products/:product", "/catalogs/:id/products/:product",
@@ -1698,14 +1758,14 @@ export function createApp({
await need(me, "hosting:delist", catalogScope(params.id)); await need(me, "hosting:delist", catalogScope(params.id));
const reason = query.reason.trim(); const reason = query.reason.trim();
const revisions = await productCall(() => catalogs.delist(params.id, params.product)); const revisions = await productCall(() => catalogs.delist(params.id, params.product));
await audited(me.id, "catalog.delist", params.id, { product: params.product, revisions, reason }, { await audited(me, "catalog.delist", params.id, { product: params.product, revisions, reason }, {
catalog: params.id, catalog: params.id,
product: params.product, product: params.product,
}); });
return { revisions }; return { revisions };
}, },
{ {
signedIn: true, scripted: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), product: t.String({ pattern: "^[a-z2-7]{10}$" }) }), params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), product: t.String({ pattern: "^[a-z2-7]{10}$" }) }),
query: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }), query: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }),
}, },
@@ -1757,8 +1817,13 @@ export function createApp({
// Products. // Products.
.post( .post(
"/products/upload", "/products/upload",
async ({ me, body }) => productCall(() => products.uploadToken(me.id, body.catalog, body.product)), async ({ me, body }) => {
{ signedIn: true, body: t.Object({ catalog: t.String(), product: t.Optional(t.String()) }) }, // A token uploads revisions of products it's granted; new products take the website (they start as drafts).
if (body.product) await need(me, "revision:upload", productScope(body.product));
else if ("token" in me) throw new Problem(403, "token_not_allowed", "API tokens upload new revisions of existing products only.");
return productCall(() => products.uploadToken(me.id, body.catalog, body.product));
},
{ scripted: true, body: t.Object({ catalog: t.String(), product: t.Optional(t.String()) }) },
) )
.get( .get(
"/products", "/products",
@@ -1831,14 +1896,14 @@ export function createApp({
if (open) review = open.id; if (open) review = open.id;
else { else {
const c = await cases.open("rating-review", me.id, { product: params.id, person: creatorId }, reason || "Lower the rating to general."); const c = await cases.open("rating-review", me.id, { product: params.id, person: creatorId }, reason || "Lower the rating to general.");
await audited(me.id, "case.open", String(c.id), { kind: c.kind }, c.tags); await audited(me, "case.open", String(c.id), { kind: c.kind }, c.tags);
review = c.id; review = c.id;
} }
} }
} }
const done = await productCall(async () => products.updateInfo(me.id, params.id, input, reason, rights)); const done = await productCall(async () => products.updateInfo(me.id, params.id, input, reason, rights));
if (done.entry) { if (done.entry) {
await audited(me.id, "product.edit", params.id, { fields: done.fields, ...(reason ? { reason } : {}) }, { await audited(me, "product.edit", params.id, { fields: done.fields, ...(reason ? { reason } : {}) }, {
product: params.id, product: params.id,
person: creatorId, person: creatorId,
}); });
@@ -1851,7 +1916,7 @@ export function createApp({
}; };
}, },
{ {
signedIn: true, scripted: true,
params: productParam, params: productParam,
body: t.Object({ body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })), name: t.Optional(t.String({ maxLength: 64 })),
@@ -1874,7 +1939,7 @@ export function createApp({
const parent = (await products.latestParent(p!))?.parentInfo?.categories ?? []; const parent = (await products.latestParent(p!))?.parentInfo?.categories ?? [];
return { choices: await products.categoryChoices(p!), parent }; return { choices: await products.categoryChoices(p!), parent };
}, },
{ signedIn: true, params: productParam }, { scripted: true, params: productParam },
) )
// A draft becomes published. // A draft becomes published.
.post( .post(
@@ -1883,10 +1948,10 @@ export function createApp({
const creatorId = await creatorOf(params.id); const creatorId = await creatorOf(params.id);
await need(me, "product:publish", productScope(params.id)); await need(me, "product:publish", productScope(params.id));
const p = await productCall(() => products.publish(me.id, params.id)); const p = await productCall(() => products.publish(me.id, params.id));
await audited(me.id, "product.publish", p.id, { name: p.name }, { product: p.id, person: creatorId }); await audited(me, "product.publish", p.id, { name: p.name }, { product: p.id, person: creatorId });
return { product: productView(p, undefined, undefined, await products.latestParent(p)) }; return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
}, },
{ signedIn: true, params: productParam }, { scripted: true, params: productParam },
) )
// The info history: who changed what, and why. // The info history: who changed what, and why.
.get( .get(
@@ -1908,7 +1973,7 @@ export function createApp({
})), })),
}; };
}, },
{ signedIn: true, params: productParam }, { scripted: true, params: productParam },
) )
.post( .post(
"/products/:id/history/:entry/revert", "/products/:id/history/:entry/revert",
@@ -1933,7 +1998,7 @@ export function createApp({
}; };
}, },
{ {
signedIn: true, scripted: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), entry: t.Numeric({ minimum: 1, multipleOf: 1 }) }), params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), entry: t.Numeric({ minimum: 1, multipleOf: 1 }) }),
body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }), body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }),
}, },
@@ -1945,7 +2010,7 @@ export function createApp({
const creatorId = await creatorOf(params.id); const creatorId = await creatorOf(params.id);
await need(me, "product:lock", productScope(params.id)); await need(me, "product:lock", productScope(params.id));
const p = await productCall(() => products.setLocks(me.id, params.id, body.fields, body.reason)); const p = await productCall(() => products.setLocks(me.id, params.id, body.fields, body.reason));
await audited(me.id, "product.lock", p.id, { fields: p.locked, reason: body.reason.trim() }, { await audited(me, "product.lock", p.id, { fields: p.locked, reason: body.reason.trim() }, {
product: p.id, product: p.id,
person: creatorId, person: creatorId,
}); });
@@ -1953,7 +2018,7 @@ export function createApp({
return { product: productView(p, undefined, undefined, await products.latestParent(p)) }; return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
}, },
{ {
signedIn: true, scripted: true,
params: productParam, params: productParam,
body: t.Object({ body: t.Object({
fields: t.Array( fields: t.Array(
@@ -1976,7 +2041,7 @@ export function createApp({
"/products/:id", "/products/:id",
async ({ me, params, set }) => { async ({ me, params, set }) => {
await productCall(() => products.delete(me.id, params.id)); await productCall(() => products.delete(me.id, params.id));
await audited(me.id, "product.delete", params.id); await audited(me, "product.delete", params.id);
set.status = 204; set.status = 204;
}, },
{ signedIn: true, params: productParam }, { signedIn: true, params: productParam },
@@ -1986,7 +2051,7 @@ export function createApp({
"/products/:id/remove", "/products/:id/remove",
async ({ me, params, body }) => { async ({ me, params, body }) => {
const removed = await productCall(() => products.removeByModeration(params.id, body.reason.trim())); const removed = await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() }, { await audited(me, "product.remove", params.id, { reason: body.reason.trim() }, {
product: params.id, product: params.id,
person: removed.creatorId, person: removed.creatorId,
}); });
@@ -1995,6 +2060,66 @@ export function createApp({
}, },
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) }, { moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
) )
// API tokens (docs/console.md): made, listed and revoked from a website session only.
.get(
"/account/tokens",
async ({ me }) => {
// What a token could be given: the person's permissions hub-wide, on their catalogs' teams and products.
const scopes: { scope: Scope; name: string; permissions: string[] }[] = [];
const hubHeld = [...perms.hub(me)];
if (hubHeld.length) scopes.push({ scope: hubScope, name: "T5DE", permissions: hubHeld });
const team = await perms.teamCatalogs(me.id);
if (team.length)
for (const c of await db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, team)).orderBy(catalog.name))
scopes.push({ scope: catalogScope(c.id), name: c.name, permissions: [...(await perms.at({ id: me.id, role: "user" }, catalogScope(c.id)))] });
const own = await db
.select({ id: schema.product.id, name: schema.product.name })
.from(schema.product)
.where(and(eq(schema.product.creatorId, me.id), isNull(schema.product.deletedAt), isNull(schema.product.removedAt)))
.orderBy(schema.product.name)
.limit(200);
for (const p of own)
scopes.push({ scope: productScope(p.id), name: p.name, permissions: [...(await perms.at({ id: me.id, role: "user" }, productScope(p.id)))] });
return { tokens: await tokenViews(await tokens.list(me.id)), scopes, maxDays: maxTokenDays };
},
{ signedIn: true },
)
.post(
"/account/tokens",
async ({ me, body, set }) => {
const made = await productCall(() => tokens.create(me, body.name, body.days, body.grants));
await audited(me, "token.create", made.row.id, { name: made.row.name, grants: made.row.grants }, { person: me.id });
set.status = 201;
return { token: made.token, info: (await tokenViews([made.row]))[0]! };
},
{
signedIn: true,
body: t.Object({
name: t.String({ maxLength: 200 }),
days: t.Integer({ minimum: 1, maximum: maxTokenDays }),
grants: t.Array(
t.Object({
permission: t.String({ maxLength: 64 }),
scope: t.Union([
t.Object({ type: t.Literal("hub") }),
t.Object({ type: t.Literal("catalog"), id: t.String({ maxLength: 32 }) }),
t.Object({ type: t.Literal("product"), id: t.String({ maxLength: 32 }) }),
]),
}),
{ maxItems: maxGrants },
),
}),
},
)
.delete(
"/account/tokens/:id",
async ({ me, params, set }) => {
const t = await productCall(() => tokens.revoke(me.id, params.id));
await audited(me, "token.revoke", t.id, { name: t.name }, { person: me.id });
set.status = 204;
},
{ signedIn: true, params: t.Object({ id: t.String({ maxLength: 32 }) }) },
)
// File blocks: moderation blocks a file network-wide (docs/products.md). // File blocks: moderation blocks a file network-wide (docs/products.md).
.get( .get(
"/blocks", "/blocks",
@@ -2004,7 +2129,7 @@ export function createApp({
const rows = await blocks.list({ before: query.before ? new Date(query.before) : undefined, limit }); const rows = await blocks.list({ before: query.before ? new Date(query.before) : undefined, limit });
return { blocks: rows.map(blockView), before: rows.length === limit ? rows.at(-1)!.block.createdAt.toISOString() : null }; return { blocks: rows.map(blockView), before: rows.length === limit ? rows.at(-1)!.block.createdAt.toISOString() : null };
}, },
{ signedIn: true, query: t.Object({ before: t.Optional(t.String({ format: "date-time" })) }) }, { scripted: true, query: t.Object({ before: t.Optional(t.String({ format: "date-time" })) }) },
) )
// A file's block, if any, and the products using it (what blocking it removes). // A file's block, if any, and the products using it (what blocking it removes).
.get( .get(
@@ -2014,7 +2139,7 @@ export function createApp({
const b = await blocks.get(params.sha256); const b = await blocks.get(params.sha256);
return { block: b ? blockView(b) : null, products: await blocks.usedBy(params.sha256) }; return { block: b ? blockView(b) : null, products: await blocks.usedBy(params.sha256) };
}, },
{ signedIn: true, params: blockParam }, { scripted: true, params: blockParam },
) )
.put( .put(
"/blocks/:sha256", "/blocks/:sha256",
@@ -2023,14 +2148,14 @@ export function createApp({
const reason = body.reason.trim(); const reason = body.reason.trim();
if (!reason || !isSha256(params.sha256)) throw new Problem(400, "invalid", "Give a reason."); if (!reason || !isSha256(params.sha256)) throw new Problem(400, "invalid", "Give a reason.");
const removed = await productCall(() => blocks.block(params.sha256, reason, me.id)); const removed = await productCall(() => blocks.block(params.sha256, reason, me.id));
await audited(me.id, "file.block", params.sha256, { reason, removed: removed.map((p) => p.id) }); await audited(me, "file.block", params.sha256, { reason, removed: removed.map((p) => p.id) });
for (const p of removed) { for (const p of removed) {
await audited(me.id, "product.remove", p.id, { reason, file: params.sha256 }, { product: p.id, person: p.creatorId }); await audited(me, "product.remove", p.id, { reason, file: params.sha256 }, { product: p.id, person: p.creatorId });
await notifications.add(p.creatorId, "product_removed", { product: p.id, name: p.name, reason }, { product: p.id }, me.id); await notifications.add(p.creatorId, "product_removed", { product: p.id, name: p.name, reason }, { product: p.id }, me.id);
} }
return { removed: removed.map((p) => ({ id: p.id, name: p.name })) }; return { removed: removed.map((p) => ({ id: p.id, name: p.name })) };
}, },
{ signedIn: true, params: blockParam, body: t.Object({ reason: t.String({ maxLength: 500 }) }) }, { scripted: true, params: blockParam, body: t.Object({ reason: t.String({ maxLength: 500 }) }) },
) )
.delete( .delete(
"/blocks/:sha256", "/blocks/:sha256",
@@ -2038,10 +2163,10 @@ export function createApp({
await need(me, "product:remove", hubScope); await need(me, "product:remove", hubScope);
await productCall(() => blocks.unblock(params.sha256)); await productCall(() => blocks.unblock(params.sha256));
const reason = body?.reason?.trim() ?? ""; const reason = body?.reason?.trim() ?? "";
await audited(me.id, "file.unblock", params.sha256, reason ? { reason } : {}); await audited(me, "file.unblock", params.sha256, reason ? { reason } : {});
set.status = 204; set.status = 204;
}, },
{ signedIn: true, params: blockParam, body: t.Optional(t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) })) }, { scripted: true, params: blockParam, body: t.Optional(t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) })) },
) )
// Moderation restores a removed product (an upheld appeal, a mistake). // Moderation restores a removed product (an upheld appeal, a mistake).
.post( .post(
@@ -2050,20 +2175,21 @@ export function createApp({
await need(me, "product:unremove", productScope(params.id)); await need(me, "product:unremove", productScope(params.id));
const p = await productCall(() => products.unremove(params.id)); const p = await productCall(() => products.unremove(params.id));
const reason = body.reason?.trim() ?? ""; const reason = body.reason?.trim() ?? "";
await audited(me.id, "product.unremove", p.id, reason ? { reason } : {}, { product: p.id, person: p.creatorId }); await audited(me, "product.unremove", p.id, reason ? { reason } : {}, { product: p.id, person: p.creatorId });
await notifications.add(p.creatorId, "product_restored", { product: p.id, name: p.name, reason }, { product: p.id }, me.id); await notifications.add(p.creatorId, "product_restored", { product: p.id, name: p.name, reason }, { product: p.id }, me.id);
return { ok: true }; return { ok: true };
}, },
{ signedIn: true, params: productParam, body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }) }, { scripted: true, params: productParam, body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }) },
) )
.post( .post(
"/products/:id/revisions/:n/retract", "/products/:id/revisions/:n/retract",
async ({ me, params }) => { async ({ me, params }) => {
await need(me, "revision:retract", productScope(params.id));
await productCall(() => products.retract(me.id, params.id, params.n)); await productCall(() => products.retract(me.id, params.id, params.n));
await audited(me.id, "product.retract", params.id, { revision: params.n }); await audited(me, "product.retract", params.id, { revision: params.n });
return { ok: true }; return { ok: true };
}, },
{ signedIn: true, params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), n: t.Numeric({ minimum: 1 }) }) }, { scripted: true, params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), n: t.Numeric({ minimum: 1 }) }) },
) )
// What the proxy loads: the revision this person uses, where to get it, and the hub's word on its files. // What the proxy loads: the revision this person uses, where to get it, and the hub's word on its files.
.get( .get(
@@ -2188,7 +2314,7 @@ export function createApp({
console.error(`age ${p.id}:`, e); console.error(`age ${p.id}:`, e);
throw new Problem(502, "provider_unavailable", "The age check didn't start. Try again in a moment."); throw new Problem(502, "provider_unavailable", "The age check didn't start. Try again in a moment.");
} }
await audited(me.id, "age.start", p.id, { ref: started.kind === "done" ? null : started.ref }); await audited(me, "age.start", p.id, { ref: started.kind === "done" ? null : started.ref });
return started.kind === "done" ? { kind: "done" as const } : started; return started.kind === "done" ? { kind: "done" as const } : started;
}, },
{ signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ returnURL: t.String({ maxLength: 500 }) }) }, { signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ returnURL: t.String({ maxLength: 500 }) }) },
@@ -2314,7 +2440,7 @@ export function createApp({
}); });
} }
// A report's entry isn't tagged with what it's about: who reported stays private (the reporter still sees it). // A report's entry isn't tagged with what it's about: who reported stays private (the reporter still sees it).
await audited(me.id, "case.open", String(c.id), { kind: c.kind }, c.kind === "report" ? {} : c.tags); await audited(me, "case.open", String(c.id), { kind: c.kind }, c.kind === "report" ? {} : c.tags);
set.status = 201; set.status = 201;
return { case: caseView(c, await cases.names([c]), me) }; return { case: caseView(c, await cases.names([c]), me) };
}, },
@@ -2385,10 +2511,10 @@ export function createApp({
const to = body.to ? await personByUsername(body.to) : null; const to = body.to ? await personByUsername(body.to) : null;
if (to && !perms.hub(to).has("case:resolve")) throw new Problem(400, "invalid", "Assign cases to moderators."); if (to && !perms.hub(to).has("case:resolve")) throw new Problem(400, "invalid", "Assign cases to moderators.");
const updated = await cases.assign(c, to?.id ?? null); const updated = await cases.assign(c, to?.id ?? null);
await audited(me.id, "case.assign", String(c.id), { to: to?.username ?? null }, c.kind === "report" ? {} : c.tags); await audited(me, "case.assign", String(c.id), { to: to?.username ?? null }, c.kind === "report" ? {} : c.tags);
return { case: caseView(updated, await cases.names([updated]), me) }; return { case: caseView(updated, await cases.names([updated]), me) };
}, },
{ signedIn: true, params: caseParam, body: t.Object({ to: t.Union([t.String({ maxLength: 64 }), t.Null()]) }) }, { scripted: true, params: caseParam, body: t.Object({ to: t.Union([t.String({ maxLength: 64 }), t.Null()]) }) },
) )
.post( .post(
"/cases/:id/decide", "/cases/:id/decide",
@@ -2509,7 +2635,7 @@ export function createApp({
const c = await productCall(() => const c = await productCall(() =>
products.setCatalogStatus(params.id, params.decision === "approve" ? "approved" : "rejected"), products.setCatalogStatus(params.id, params.decision === "approve" ? "approved" : "rejected"),
); );
await audited(me.id, `catalog.${params.decision}`, c.id, { name: c.name }); await audited(me, `catalog.${params.decision}`, c.id, { name: c.name });
return { catalog: catalogView(c) }; return { catalog: catalogView(c) };
}, },
{ moderator: true, params: t.Object({ id: t.String(), decision: t.Union([t.Literal("approve"), t.Literal("reject")]) }) }, { moderator: true, params: t.Object({ id: t.String(), decision: t.Union([t.Literal("approve"), t.Literal("reject")]) }) },
@@ -2640,7 +2766,7 @@ export function createApp({
async ({ me, params }) => { async ({ me, params }) => {
const p = await personById(params.id); const p = await personById(params.id);
const [updated] = await db.update(user).set({ approved: true }).where(eq(user.id, p.id)).returning(); const [updated] = await db.update(user).set({ approved: true }).where(eq(user.id, p.id)).returning();
if (!p.approved) await audited(me.id, "person.approve", p.username ?? p.id); if (!p.approved) await audited(me, "person.approve", p.username ?? p.id);
return { person: personView(updated!) }; return { person: personView(updated!) };
}, },
{ moderator: true }, { moderator: true },
@@ -2651,7 +2777,7 @@ export function createApp({
const p = await personById(params.id); const p = await personById(params.id);
if (p.approved) throw new Problem(400, "invalid", "Only accounts waiting for approval can be declined."); if (p.approved) throw new Problem(400, "invalid", "Only accounts waiting for approval can be declined.");
await db.delete(user).where(eq(user.id, p.id)); await db.delete(user).where(eq(user.id, p.id));
await audited(me.id, "person.decline", p.username ?? p.id); await audited(me, "person.decline", p.username ?? p.id);
return { declined: true }; return { declined: true };
}, },
{ moderator: true }, { moderator: true },
@@ -2663,7 +2789,7 @@ export function createApp({
if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change the hub's settings."); if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change the hub's settings.");
const from = await settings.registration(); const from = await settings.registration();
await settings.setRegistration(body.registration); await settings.setRegistration(body.registration);
if (from !== body.registration) await audited(me.id, "hub.registration", body.registration, { from }); if (from !== body.registration) await audited(me, "hub.registration", body.registration, { from });
return { registration: body.registration }; return { registration: body.registration };
}, },
{ {
@@ -2689,7 +2815,7 @@ export function createApp({
async ({ me, params, body }) => { async ({ me, params, body }) => {
const p = await personById(params.id); const p = await personById(params.id);
const [updated] = await db.update(user).set({ linkCap: body.linkCap }).where(eq(user.id, p.id)).returning(); const [updated] = await db.update(user).set({ linkCap: body.linkCap }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap }); await audited(me, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap });
return { person: personView(updated!) }; return { person: personView(updated!) };
}, },
{ moderator: true, body: t.Object({ linkCap: t.Integer({ minimum: 0, maximum: 1000 }) }) }, { moderator: true, body: t.Object({ linkCap: t.Integer({ minimum: 0, maximum: 1000 }) }) },
@@ -2703,7 +2829,7 @@ export function createApp({
const [updated] = await db.update(user).set({ banned: true, banReason: body.reason }).where(eq(user.id, p.id)).returning(); const [updated] = await db.update(user).set({ banned: true, banReason: body.reason }).where(eq(user.id, p.id)).returning();
await db.delete(session).where(eq(session.userId, p.id)); await db.delete(session).where(eq(session.userId, p.id));
realtime.disconnect(p.id); realtime.disconnect(p.id);
await audited(me.id, "person.ban", p.username ?? p.id, { reason: body.reason }); await audited(me, "person.ban", p.username ?? p.id, { reason: body.reason });
return { person: personView(updated!) }; return { person: personView(updated!) };
}, },
{ moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) }, { moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
@@ -2713,7 +2839,7 @@ export function createApp({
async ({ me, params }) => { async ({ me, params }) => {
const p = await personById(params.id); const p = await personById(params.id);
const [updated] = await db.update(user).set({ banned: false, banReason: null }).where(eq(user.id, p.id)).returning(); const [updated] = await db.update(user).set({ banned: false, banReason: null }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.unban", p.username ?? p.id); await audited(me, "person.unban", p.username ?? p.id);
return { person: personView(updated!) }; return { person: personView(updated!) };
}, },
{ moderator: true }, { moderator: true },
@@ -2725,7 +2851,7 @@ export function createApp({
const p = await personById(params.id); const p = await personById(params.id);
if (p.id === me.id) throw new Problem(400, "invalid", "Another admin has to change your role."); if (p.id === me.id) throw new Problem(400, "invalid", "Another admin has to change your role.");
const [updated] = await db.update(user).set({ role: body.role }).where(eq(user.id, p.id)).returning(); const [updated] = await db.update(user).set({ role: body.role }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.role", p.username ?? p.id, { from: p.role, to: body.role }); await audited(me, "person.role", p.username ?? p.id, { from: p.role, to: body.role });
return { person: personView(updated!) }; return { person: personView(updated!) };
}, },
{ {
+16 -7
View File
@@ -1,7 +1,7 @@
// Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of // Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of
// permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's // permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's
// creator), and other roles are role_assignment rows. // creator), and other roles are role_assignment rows.
import { and, eq, schema, type Database } from "@t5de/db"; import { and, eq, schema, type Database, type TokenGrant } from "@t5de/db";
const { catalog, product, roleAssignment, user } = schema; const { catalog, product, roleAssignment, user } = schema;
@@ -143,8 +143,18 @@ export type RoleId = keyof typeof roles;
/** Roles that can be given at a catalog (the owner is derived, never given). */ /** Roles that can be given at a catalog (the owner is derived, never given). */
export const assignableCatalogRoles = ["catalog-operator"] as const; export const assignableCatalogRoles = ["catalog-operator"] as const;
/** A person, as the check needs them. */ /** A person, as the check needs them. Signed in with an API token, only what it grants too (the intersection). */
export type Who = { id: string; role: string }; export type Who = { id: string; role: string; token?: { id: string; name: string; grants: TokenGrant[] } };
/** Whether a token (if any) grants a permission at a scope: granted there, or hub-wide. */
export function granted(who: Who | undefined, permission: Permission, scope: Scope): boolean {
if (!who?.token) return true;
return who.token.grants.some(
(g) =>
g.permission === permission &&
(g.scope.type === "hub" || (scope.type !== "hub" && g.scope.type === scope.type && g.scope.id === scope.id)),
);
}
/** Thrown when a permission is missing; the API answers 403 with what was missing. */ /** Thrown when a permission is missing; the API answers 403 with what was missing. */
export class Forbidden extends Error { export class Forbidden extends Error {
@@ -161,9 +171,8 @@ export class Permissions {
/** A person's hub-wide permissions (their user.role). */ /** A person's hub-wide permissions (their user.role). */
hub(who: Who | undefined): Set<Permission> { hub(who: Who | undefined): Set<Permission> {
if (who?.role === "admin") return new Set(roles.admin.permissions); const held = who?.role === "admin" ? roles.admin.permissions : who?.role === "moderator" ? roles.moderator.permissions : [];
if (who?.role === "moderator") return new Set(roles.moderator.permissions); return new Set(held.filter((p) => granted(who, p, hubScope)));
return new Set();
} }
/** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */ /** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */
@@ -192,7 +201,7 @@ export class Permissions {
/** Every permission a person holds at a scope. */ /** Every permission a person holds at a scope. */
async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> { async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> {
const out = new Set<Permission>(); const out = new Set<Permission>();
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) out.add(p); for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) if (granted(who, p, scope)) out.add(p);
return out; return out;
} }
+94
View File
@@ -0,0 +1,94 @@
// Personal API tokens (docs/console.md): for scripts. Each grants some of its owner's permissions at chosen scopes;
// what it may do is always the intersection with what the owner holds now. Only the hash is kept, and the token is
// shown once. Making, listing and revoking tokens takes a website session (a token can't make tokens).
import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, gt, isNull, schema, sql, type Database, type TokenGrant } from "@t5de/db";
import { ProductError } from "./products";
import { permissions, type Permission, type Permissions, type Scope } from "./permissions";
const { apiToken } = schema;
export const tokenPrefix = "t5de_pat_";
/** Active tokens a person may have at once. */
export const maxTokens = 20;
/** Grants a token may carry. */
export const maxGrants = 50;
/** The longest a token may last. */
export const maxTokenDays = 365;
const hashOf = (raw: string) => createHash("sha256").update(raw).digest("hex");
export type ApiToken = typeof apiToken.$inferSelect;
export class Tokens {
constructor(
private db: Database,
private perms: Permissions,
) {}
/** Makes a token for a person; every grant must be something they hold now. Returns the token (shown once). */
async create(who: { id: string; role: string }, name: string, days: number, grants: TokenGrant[]) {
const label = name.trim().replace(/\s+/g, " ");
if (!label || label.length > 64) throw new ProductError(400, "invalid", "Name it (at most 64 characters).");
if (!Number.isInteger(days) || days < 1 || days > maxTokenDays)
throw new ProductError(400, "invalid", `It may last 1 to ${maxTokenDays} days.`);
if (grants.length === 0 || grants.length > maxGrants)
throw new ProductError(400, "invalid", `Give it 1 to ${maxGrants} permissions.`);
const seen = new Set<string>();
const clean: TokenGrant[] = [];
for (const g of grants) {
if (!(permissions as readonly string[]).includes(g.permission))
throw new ProductError(400, "invalid", `No such permission: ${g.permission}.`);
const scope: Scope = g.scope.type === "hub" ? { type: "hub" } : { type: g.scope.type, id: g.scope.id };
if (!(await this.perms.can(who, g.permission as Permission, scope)))
throw new ProductError(400, "not_held", `You don't hold ${g.permission} there, so a token can't either.`);
const key = JSON.stringify([g.permission, scope]);
if (seen.has(key)) continue;
seen.add(key);
clean.push({ permission: g.permission, scope });
}
const [{ n } = { n: 0 }] = await this.db
.select({ n: sql<number>`count(*)::int` })
.from(apiToken)
.where(and(eq(apiToken.userId, who.id), isNull(apiToken.revokedAt), gt(apiToken.expiresAt, new Date())));
if (n >= maxTokens) throw new ProductError(409, "too_many", `You have ${maxTokens} tokens. Revoke one first.`);
const raw = tokenPrefix + randomBytes(32).toString("base64url");
const [made] = await this.db
.insert(apiToken)
.values({
id: randomBytes(8).toString("hex"),
userId: who.id,
name: label,
hash: hashOf(raw),
grants: clean,
expiresAt: new Date(Date.now() + days * 86_400_000),
})
.returning();
return { token: raw, row: made! };
}
/** A person's tokens, newest first (revoked and expired ones included, for the record). */
async list(personId: string) {
return this.db.select().from(apiToken).where(eq(apiToken.userId, personId)).orderBy(desc(apiToken.createdAt));
}
async revoke(personId: string, id: string) {
const [t] = await this.db
.update(apiToken)
.set({ revokedAt: new Date() })
.where(and(eq(apiToken.id, id), eq(apiToken.userId, personId), isNull(apiToken.revokedAt)))
.returning();
if (!t) throw new ProductError(404, "not_found", "No such token, or it's revoked already.");
return t;
}
/** The token a raw value names, if it's still good; notes its use (at most once a minute). */
async verify(raw: string): Promise<ApiToken | undefined> {
if (!raw.startsWith(tokenPrefix) || raw.length > 100) return undefined;
const [t] = await this.db.select().from(apiToken).where(eq(apiToken.hash, hashOf(raw)));
if (!t || t.revokedAt || t.expiresAt <= new Date()) return undefined;
if (!t.lastUsedAt || Date.now() - t.lastUsedAt.getTime() > 60_000)
await this.db.update(apiToken).set({ lastUsedAt: new Date() }).where(eq(apiToken.id, t.id));
return t;
}
}
+213
View File
@@ -0,0 +1,213 @@
// 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);
});
+32 -1
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api"; import { api, ApiError, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions"; import { attempt, field, type ActionState } from "@/lib/actions";
const done = () => revalidatePath("/account"); const done = () => revalidatePath("/account");
@@ -71,3 +71,34 @@ export async function startAgeCheck(_: ActionState, form: FormData): Promise<Act
if (url) redirect(url); if (url) redirect(url);
return state; return state;
} }
/** What making a token answers: the token itself, shown once. */
export type TokenState = { error?: string; token?: string; name?: string } | null;
/** Makes an API token. `grants` is JSON: [{permission, scope}]. */
export async function createToken(_: TokenState, form: FormData): Promise<TokenState> {
let grants: { permission: string; scope: { type: "hub" } | { type: "catalog" | "product"; id: string } }[];
try {
grants = JSON.parse(field(form, "grants") || "[]");
} catch {
return { error: "Choose what it may do." };
}
if (!Array.isArray(grants) || grants.length === 0) return { error: "Choose what it may do." };
try {
const made = unwrap(await api.account.tokens.post({ name: field(form, "name"), days: Number(field(form, "days")), grants }));
done();
return { token: made.token, name: made.info.name };
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
/** Revokes an API token. */
export async function revokeToken(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.account.tokens({ id: field(form, "id") }).delete());
done();
return "Revoked.";
});
}
+8 -1
View File
@@ -11,13 +11,18 @@ import { linkAccount, signOutComputers } from "./actions";
import { AgeCard } from "./age-card"; import { AgeCard } from "./age-card";
import { ChallengeCard } from "./challenge-card"; import { ChallengeCard } from "./challenge-card";
import { LinkCard } from "./link-card"; import { LinkCard } from "./link-card";
import { TokensCard } from "./tokens-card";
export const metadata: Metadata = { title: "Account" }; export const metadata: Metadata = { title: "Account" };
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) { export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
const me = await getMe(); const me = await getMe();
if (!me) redirect("/signin?next=/account"); if (!me) redirect("/signin?next=/account");
const [account, age] = await Promise.all([api.account.get().then(unwrap), api.age.get().then(unwrap)]); const [account, age, tokens] = await Promise.all([
api.account.get().then(unwrap),
api.age.get().then(unwrap),
api.account.tokens.get().then(unwrap),
]);
const { person, links, challenges, computers } = account; const { person, links, challenges, computers } = account;
const params = await searchParams; const params = await searchParams;
const welcome = params.welcome !== undefined; const welcome = params.welcome !== undefined;
@@ -116,6 +121,8 @@ export default async function Account({ searchParams }: { searchParams: Promise<
)} )}
</CardContent> </CardContent>
</Card> </Card>
<TokensCard data={tokens} />
</div> </div>
); );
} }
+116
View File
@@ -0,0 +1,116 @@
"use client";
// Making an API token: a name, how long it lasts, and what it may do where (some of the person's own permissions).
import { useActionState, useState } from "react";
import { ErrorText } from "@/components/forms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import type { TokenScope } from "@/lib/api";
import { createToken } from "./actions";
const keyOf = (s: TokenScope["scope"]) => (s.type === "hub" ? "hub" : `${s.type}:${s.id}`);
const kindOf = { hub: "Everything", catalog: "Catalog", product: "Product" } as const;
type Row = { scope: string; permissions: string[] };
export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays: number }) {
const [state, formAction, pending] = useActionState(createToken, null);
const [rows, setRows] = useState<Row[]>([{ scope: keyOf(scopes[0]!.scope), permissions: [] }]);
const [copied, setCopied] = useState(false);
const byKey = new Map(scopes.map((s) => [keyOf(s.scope), s]));
const grants = rows.flatMap((r) => r.permissions.map((permission) => ({ permission, scope: byKey.get(r.scope)!.scope })));
const set = (i: number, row: Row) => setRows(rows.map((r, j) => (j === i ? row : r)));
const unused = scopes.find((s) => !rows.some((r) => r.scope === keyOf(s.scope)));
return (
<form action={formAction} className="space-y-4">
<input type="hidden" name="grants" value={JSON.stringify(grants)} />
<div className="flex flex-wrap gap-2">
<Input name="name" required maxLength={64} placeholder="Name, e.g. Upload script" aria-label="Name" className="max-w-xs" />
<NativeSelect name="days" defaultValue="90" aria-label="Lasts" className="w-36">
{[7, 30, 90, maxDays].map((d) => (
<NativeSelectOption key={d} value={d}>
{d === 365 ? "1 year" : `${d} days`}
</NativeSelectOption>
))}
</NativeSelect>
</div>
{rows.map((row, i) => {
const scope = byKey.get(row.scope)!;
return (
<fieldset key={i} className="space-y-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
value={row.scope}
onChange={(e) => set(i, { scope: e.target.value, permissions: [] })}
aria-label="Where"
className="min-w-56"
>
{scopes.map((s) => {
const k = keyOf(s.scope);
if (k !== row.scope && rows.some((r) => r.scope === k)) return null;
return (
<NativeSelectOption key={k} value={k}>
{s.scope.type === "hub" ? "Everything on T5DE" : `${kindOf[s.scope.type]}: ${s.name}`}
</NativeSelectOption>
);
})}
</NativeSelect>
{rows.length > 1 && (
<Button type="button" variant="ghost" size="sm" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
Remove
</Button>
)}
</div>
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{scope.permissions.map((p) => (
<label key={p} className="flex items-center gap-2 font-mono text-xs">
<input
type="checkbox"
checked={row.permissions.includes(p)}
onChange={(e) =>
set(i, { ...row, permissions: e.target.checked ? [...row.permissions, p] : row.permissions.filter((x) => x !== p) })
}
/>
{p}
</label>
))}
</div>
</fieldset>
);
})}
<div className="flex flex-wrap gap-2">
{unused && (
<Button type="button" variant="outline" onClick={() => setRows([...rows, { scope: keyOf(unused.scope), permissions: [] }])}>
Add another place
</Button>
)}
<Button type="submit" disabled={pending || grants.length === 0}>
{pending ? "Making…" : "Make token"}
</Button>
</div>
{state?.error && <ErrorText>{state.error}</ErrorText>}
{state?.token && (
<div role="status" className="space-y-2 rounded-lg border p-3 text-sm">
<p>
Your token <strong>{state.name}</strong>. Copy it now: it won&apos;t be shown again.
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 rounded bg-muted px-2 py-1 font-mono text-xs break-all">{state.token}</code>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => navigator.clipboard.writeText(state.token!).then(() => setCopied(true))}
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<p className="text-muted-foreground">
Send it as <code className="font-mono text-xs">Authorization: Bearer &lt;token&gt;</code>.
</p>
</div>
)}
</form>
);
}
+97
View File
@@ -0,0 +1,97 @@
// API tokens on the account page: the person's tokens, and making one.
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { TokenEntry, TokenList } from "@/lib/api";
import { revokeToken } from "./actions";
import { TokenForm } from "./token-form";
export function TokensCard({ data }: { data: TokenList }) {
const active = data.tokens.filter((t) => t.status === "active");
const past = data.tokens.filter((t) => t.status !== "active").slice(0, 10);
return (
<Card>
<CardHeader>
<CardTitle>API Tokens</CardTitle>
<CardDescription>
For your own scripts. A token can do only what you give it, and never more than you can do yourself.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{active.length > 0 && (
<ul className="divide-y">
{active.map((t) => (
<TokenRow key={t.id} token={t} />
))}
</ul>
)}
{data.scopes.length > 0 ? (
<TokenForm scopes={data.scopes} maxDays={data.maxDays} />
) : (
<p className="text-sm text-muted-foreground">
Tokens act on what you manage: your products or a catalog&apos;s team. You don&apos;t manage anything yet.
</p>
)}
{past.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Revoked and expired</summary>
<ul className="mt-2 divide-y">
{past.map((t) => (
<TokenRow key={t.id} token={t} />
))}
</ul>
</details>
)}
</CardContent>
</Card>
);
}
function TokenRow({ token: t }: { token: TokenEntry }) {
return (
<li className="space-y-1 py-3 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{t.name}</span>
{t.status !== "active" && <Tag tone={t.status === "revoked" ? "danger" : "neutral"}>{t.status}</Tag>}
<span className="flex-1" />
{t.status === "active" && (
<ActionForm action={revokeToken} fields={{ id: t.id }}>
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…" confirm={`Revoke ${t.name}? Scripts using it stop working.`}>
Revoke
</SubmitButton>
</ActionForm>
)}
</div>
<p className="text-xs text-muted-foreground">
{t.grants.map((g, i) => (
<span key={i}>
{i > 0 && ", "}
<span className="font-mono">{g.permission}</span> on {g.scope.type === "hub" ? "everything" : g.name}
</span>
))}
</p>
<p className="text-xs text-muted-foreground">
Made <DateTime iso={t.createdAt} dateOnly />
{" · "}
{t.status === "revoked" && t.revokedAt ? (
<>
revoked <DateTime iso={t.revokedAt} dateOnly />
</>
) : (
<>
{t.status === "expired" ? "expired" : "expires"} <DateTime iso={t.expiresAt} dateOnly />
</>
)}
{" · "}
{t.lastUsedAt ? (
<>
last used <DateTime iso={t.lastUsedAt} />
</>
) : (
"never used"
)}
</p>
</li>
);
}
+3
View File
@@ -44,6 +44,9 @@ export type AuditEntry = ConsoleData["audit"][number];
export type CaseSummary = Data<V1["cases"]["get"]>["cases"][number]; export type CaseSummary = Data<V1["cases"]["get"]>["cases"][number];
export type CasePage = Data<ReturnType<V1["cases"]>["get"]>; export type CasePage = Data<ReturnType<V1["cases"]>["get"]>;
export type NotificationEntry = Data<V1["notifications"]["get"]>["notifications"][number]; export type NotificationEntry = Data<V1["notifications"]["get"]>["notifications"][number];
export type TokenList = Data<V1["account"]["tokens"]["get"]>;
export type TokenEntry = TokenList["tokens"][number];
export type TokenScope = TokenList["scopes"][number];
export type CatalogPage = Data<ReturnType<V1["catalogs"]>["get"]>; export type CatalogPage = Data<ReturnType<V1["catalogs"]>["get"]>;
export type CatalogMember = Data<ReturnType<V1["catalogs"]>["members"]["get"]>["members"][number]; export type CatalogMember = Data<ReturnType<V1["catalogs"]>["members"]["get"]>["members"][number];
export type CatalogInvite = Data<ReturnType<V1["catalogs"]>["invites"]["get"]>["invites"][number]; export type CatalogInvite = Data<ReturnType<V1["catalogs"]>["invites"]["get"]>["invites"][number];
+3 -1
View File
@@ -7,7 +7,9 @@ requests and type the website's client (Eden). This is the overview.
- `/api/v1/*`: the hub's own routes. - `/api/v1/*`: the hub's own routes.
The website reaches the API through its own origin (Next.js rewrites `/api` to it), so sessions are first-party The website reaches the API through its own origin (Next.js rewrites `/api` to it), so sessions are first-party
cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session token>`. cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session token>`. Scripts use personal API
tokens (`Authorization: Bearer t5de_pat_...`, docs/console.md): those work only on routes that check a permission
(the rest answer 403 `token_not_allowed`, and a wrong, revoked or expired token 401 `bad_token`).
Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as
is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`, is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`,
+19 -1
View File
@@ -182,6 +182,24 @@ Bearer t5de_pat_<random>`; only the hash is stored; shown once. A token never ha
check is the intersection, so losing a role takes it from tokens too. Listed and revocable in the account page; check is the intersection, so losing a role takes it from tokens too. Listed and revocable in the account page;
every use updates `last_used_at`; actions are audited as the person, marked "by token <name>". every use updates `last_used_at`; actions are audited as the person, marked "by token <name>".
Built (2026-11-05):
- Made, listed and revoked on the account page (a website session only: a token can't make tokens). Each grant is
a permission the person holds now at the hub (moderators and admins), a catalog whose team they're on, or a
product of theirs (400 `not_held` otherwise). At most 20 active tokens, 50 grants, 365 days.
- Usable on the routes that check a permission (product info, publish, locks, history; uploading new revisions of a
product, `revision:upload`, and retracting them; a catalog's settings, key, team, members, invites and hosted
products; blocks; assigning cases). Other routes refuse tokens (403 `token_not_allowed`), including making a new
product, reports and the console. On those routes every check is the intersection, so a moderator's token granted
`product:remove` hub-wide can block files but not suspend catalogs, and losing the role takes it from the token.
- The audit log marks actions by token (`detail.token`: its name); making and revoking one is audited
(`token.create`, `token.revoke`).
| | |
| --- | --- |
| `GET /api/v1/account/tokens` | `{tokens: [{id, name, grants: [{permission, scope, name}], status (active, expired, revoked), createdAt, expiresAt, lastUsedAt, revokedAt}], scopes: [{scope, name, permissions}], maxDays}`: `scopes` is what a token could be given |
| `POST /api/v1/account/tokens` | `{name, days, grants: [{permission, scope: {type: "hub"} or {type: "catalog" or "product", id}}]}` → 201 `{token, info}`; `token` is shown only here |
| `DELETE /api/v1/account/tokens/:id` | revokes it → 204 |
``` ```
api_token(id, person, name, hash, grants jsonb, expires_at, last_used_at, created_at, revoked_at) api_token(id, person, name, hash, grants jsonb, expires_at, last_used_at, created_at, revoked_at)
``` ```
@@ -224,7 +242,7 @@ for meaning.
| `POST /api/v1/cases/:id/decide` | `{decision: "resolve" or "decline", resolution}`: by those who may decide it (below); resolving carries out a rating review, removal request or deletion request; the opener is told | | `POST /api/v1/cases/:id/decide` | `{decision: "resolve" or "decline", resolution}`: by those who may decide it (below); resolving carries out a rating review, removal request or deletion request; the opener is told |
| `GET /api/v1/notifications?before=` | `{notifications: [{id, type, tags, data, at, read}], unread, before}`, newest first, 50 a page | | `GET /api/v1/notifications?before=` | `{notifications: [{id, type, tags, data, at, read}], unread, before}`, newest first, 50 a page |
| `POST /api/v1/notifications/read` | `{ids?}` (all if left out) → `{unread}` | | `POST /api/v1/notifications/read` | `{ids?}` (all if left out) → `{unread}` |
| `GET/POST /api/v1/account/tokens`, `DELETE /api/v1/account/tokens/:id` | API tokens | | `GET/POST /api/v1/account/tokens`, `DELETE /api/v1/account/tokens/:id` | API tokens (above) |
| `GET/PUT/DELETE /api/v1/roles/:scopeType/:scopeId/:person` | role assignments at a scope | | `GET/PUT/DELETE /api/v1/roles/:scopeType/:scopeId/:person` | role assignments at a scope |
| `POST /api/v1/console/view-as/:person` | (admins) start viewing as someone, read-only | | `POST /api/v1/console/view-as/:person` | (admins) start viewing as someone, read-only |
+2 -2
View File
@@ -478,8 +478,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md):
(to look into with the importer spike). (to look into with the importer spike).
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens; 3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
two-factor sign-in for sensitive permissions. Cases and notifications **done** (2026-11-05), without email, canned two-factor sign-in for sensitive permissions. Cases and notifications **done** (2026-11-05), without email, canned
reasons, moderator notes or second approval. File blocks **done** (2026-11-05); API tokens and two-factor sign-in reasons, moderator notes or second approval. File blocks and API tokens **done** (2026-11-05); two-factor
next. sign-in next.
4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike). 4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike).
5. Charts and stats; mirroring between catalogs. 5. Charts and stats; mirroring between catalogs.
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "api_token" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"hash" text NOT NULL,
"grants" jsonb NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"revoked_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "api_token" ADD CONSTRAINT "api_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "api_token_hash" ON "api_token" USING btree ("hash");--> statement-breakpoint
CREATE INDEX "api_token_user" ON "api_token" USING btree ("user_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1793917069600, "when": 1793917069600,
"tag": "0013_file_blocks", "tag": "0013_file_blocks",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1793918993367,
"tag": "0014_api_tokens",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
import * as schema from "./schema"; import * as schema from "./schema";
export { schema }; export { schema };
export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem } from "./schema"; export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem, TokenGrant } from "./schema";
export * from "drizzle-orm"; export * from "drizzle-orm";
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
+22
View File
@@ -667,3 +667,25 @@ export const notification = pgTable(
}, },
(t) => [index("notification_user").on(t.userId, t.id)], (t) => [index("notification_user").on(t.userId, t.id)],
); );
/** What an API token may do: a permission at a scope (`{type: "hub"}`, or a catalog or product by ID). */
export type TokenGrant = { permission: string; scope: { type: "hub" } | { type: "catalog" | "product"; id: string } };
// Personal API tokens for scripts (docs/console.md): a subset of the person's permissions; only the hash is kept.
export const apiToken = pgTable(
"api_token",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
hash: text("hash").notNull(), // sha256 of the token, hex
grants: jsonb("grants").$type<TokenGrant[]>().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: createdAt(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(t) => [uniqueIndex("api_token_hash").on(t.hash), index("api_token_user").on(t.userId)],
);