feat: personal API tokens
Tokens for scripts, made, listed and revoked on the account page. Each grants some of the person's own permissions at chosen places (a product of theirs, a catalog whose team they're on, or the hub for moderators); every check is the intersection with what they hold now. Tokens work only on routes that check a permission (session-only routes answer token_not_allowed), are shown once and stored as hashes, note their last use, and mark the audit entries of what they do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
58a3e70a63
commit
2b88a52bcb
17 files changed
+4358
-98
No files matched your search
+210
-84
@@ -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!) };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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.";
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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'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 <token></code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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's team. You don'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
);
|
||||||
Reference in new issue
Block a user