Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0693955145 |
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release.
|
||||
|
||||
## 0.3.0 (2026-11-05)
|
||||
|
||||
### Features
|
||||
|
||||
- Age verification with an ID document (Didit), and closed registration (#4)
|
||||
- Permissions, a scoped console, and catalog management (#7)
|
||||
- Product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
|
||||
- Cases (reports, reviews, requests, appeals) and notifications (#9)
|
||||
- File blocks (#10)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Permissions, console and imported IMVU products (#6)
|
||||
|
||||
|
||||
## 0.2.0 (2026-11-03)
|
||||
|
||||
### Features
|
||||
|
||||
+84
-210
@@ -8,7 +8,6 @@ import {
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
isNull,
|
||||
inArray,
|
||||
ne,
|
||||
or,
|
||||
@@ -47,7 +46,6 @@ import { Settings } from "./settings";
|
||||
import { Catalogs } from "./catalogs";
|
||||
import { appealable, caseKinds, Cases, type Case, type CaseKind } from "./cases";
|
||||
import { Notifications } from "./notifications";
|
||||
import { maxGrants, maxTokenDays, tokenPrefix, Tokens } from "./tokens";
|
||||
import { assignableCatalogRoles, Forbidden, hubScope, Permissions, roles as roleBundles, type Scope } from "./permissions";
|
||||
|
||||
const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product, hosting } = schema;
|
||||
@@ -142,7 +140,6 @@ export function createApp({
|
||||
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
|
||||
const products = new Products(db, signer, imvu);
|
||||
const perms = new Permissions(db);
|
||||
const tokens = new Tokens(db, perms);
|
||||
const catalogs = new Catalogs(db, catalogHealthy);
|
||||
const rooms = roomsService ?? new Rooms(db, imvu);
|
||||
const hosted = new HostedRooms(db);
|
||||
@@ -267,7 +264,7 @@ export function createApp({
|
||||
/** 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. */
|
||||
const audited = (
|
||||
actor: string | null | { id: string; token?: { name: string } },
|
||||
actorId: string | null,
|
||||
action: string,
|
||||
target: string,
|
||||
detail: Record<string, unknown> = {},
|
||||
@@ -279,10 +276,7 @@ export function createApp({
|
||||
else if (action.startsWith("product.")) t.product = target;
|
||||
else if (action.startsWith("hosted_room.")) t.room = target;
|
||||
}
|
||||
// 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 });
|
||||
return db.insert(audit).values({ actorId, action, target, detail, tags: t });
|
||||
};
|
||||
|
||||
/** Runs a check, turning a missing permission into a Problem. */
|
||||
@@ -417,31 +411,6 @@ export function createApp({
|
||||
|
||||
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 blockView = (b: { block: typeof schema.fileBlock.$inferSelect; by: string | null }) => ({
|
||||
sha256: b.block.sha256,
|
||||
@@ -508,17 +477,17 @@ export function createApp({
|
||||
products.updateInfo(me.id, id, { rating: "general" }, reason, { creator: false, editLocked: true, rateLower: true }),
|
||||
);
|
||||
if (done.entry) {
|
||||
await audited(me, "product.edit", id, { fields: done.fields, reason, case: c.id }, { product: id, person: creatorId });
|
||||
await audited(me.id, "product.edit", id, { fields: done.fields, reason, case: c.id }, { product: id, person: creatorId });
|
||||
await productEdited(me, id, creatorId, "edit", done.fields, reason);
|
||||
}
|
||||
} else if (c.kind === "removal-request" && c.tags.product) {
|
||||
const reason = resolution || c.reason;
|
||||
const removed = await productCall(() => products.removeByModeration(c.tags.product!, reason));
|
||||
await audited(me, "product.remove", removed.id, { reason, case: c.id }, { product: removed.id, person: removed.creatorId });
|
||||
await audited(me.id, "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);
|
||||
} else if (c.kind === "catalog-deletion" && c.tags.catalog) {
|
||||
const deleted = await productCall(() => catalogs.markDeleted(c.tags.catalog!, resolution || c.reason));
|
||||
await audited(me, "catalog.delete", deleted.id, { name: deleted.name, case: c.id });
|
||||
await audited(me.id, "catalog.delete", deleted.id, { name: deleted.name, case: c.id });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -785,20 +754,6 @@ export function createApp({
|
||||
}
|
||||
})
|
||||
.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 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).
|
||||
@@ -807,47 +762,32 @@ export function createApp({
|
||||
person: usable?.approved ? usable : undefined,
|
||||
waiting: usable && !usable.approved ? usable : undefined,
|
||||
sessionId: s?.session.id,
|
||||
tokenPerson: undefined,
|
||||
badToken: false,
|
||||
};
|
||||
})
|
||||
.macro({
|
||||
/** Signed in, approved or still waiting. */
|
||||
account: {
|
||||
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.");
|
||||
resolve: ({ person, waiting }) => {
|
||||
const me = person ?? waiting;
|
||||
if (!me) throw new Problem(401, "signed_out", "Sign in first.");
|
||||
return { me };
|
||||
},
|
||||
},
|
||||
signedIn: {
|
||||
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.");
|
||||
resolve: ({ person, waiting }) => {
|
||||
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 };
|
||||
},
|
||||
},
|
||||
moderator: {
|
||||
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.");
|
||||
resolve: ({ person, waiting }) => {
|
||||
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 (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this.");
|
||||
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.
|
||||
@@ -1092,7 +1032,7 @@ export function createApp({
|
||||
await age.forgetAccount(l.imvuId);
|
||||
await rooms.forgetOwner(l.imvuId);
|
||||
realtime.unlinked(me.id, l.imvuId);
|
||||
await audited(me, "link.remove", String(l.imvuId), { imvu: l.username });
|
||||
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: imvuIdParam },
|
||||
@@ -1119,7 +1059,7 @@ export function createApp({
|
||||
} catch {
|
||||
throw new Problem(404, "not_found", "That code isn't right, or it expired. Check the code T5DE shows.");
|
||||
}
|
||||
await audited(me, "proxy.signin", "");
|
||||
await audited(me.id, "proxy.signin", "");
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
|
||||
@@ -1165,7 +1105,7 @@ export function createApp({
|
||||
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.");
|
||||
const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body));
|
||||
await audited(me, "hosted_room.create", String(r.room.n), {
|
||||
await audited(me.id, "hosted_room.create", String(r.room.n), {
|
||||
name: r.room.name,
|
||||
rating: r.room.rating,
|
||||
privacy: r.room.privacy,
|
||||
@@ -1235,7 +1175,7 @@ export function createApp({
|
||||
: rest;
|
||||
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes));
|
||||
realtime.hostedChanged(r.room.n, roomInfo(r));
|
||||
await audited(me, "hosted_room.edit", String(r.room.n), {
|
||||
await audited(me.id, "hosted_room.edit", String(r.room.n), {
|
||||
fields: Object.keys(body),
|
||||
...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}),
|
||||
});
|
||||
@@ -1278,7 +1218,7 @@ export function createApp({
|
||||
async ({ me, params, set }) => {
|
||||
const r = await hostedCall(() => hosted.close(me.id, isModerator(me), params.n));
|
||||
realtime.hostedClosed(r.n);
|
||||
await audited(me, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) });
|
||||
await audited(me.id, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) });
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: hostedParam },
|
||||
@@ -1314,7 +1254,7 @@ export function createApp({
|
||||
async ({ me, params, set }) => {
|
||||
if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu))))
|
||||
throw new Problem(404, "not_found", "That account isn't banned here.");
|
||||
await audited(me, "hosted_room.unban", String(params.n), { imvu: params.imvu });
|
||||
await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu });
|
||||
set.status = 204;
|
||||
},
|
||||
{
|
||||
@@ -1351,7 +1291,7 @@ export function createApp({
|
||||
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 r = await hostedCall(() => hosted.setImage(me.id, params.n, data));
|
||||
await audited(me, "hosted_room.image", String(params.n), { bytes: data.length });
|
||||
await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length });
|
||||
return { room: await hostedViewFor(r, me) };
|
||||
},
|
||||
{
|
||||
@@ -1369,7 +1309,7 @@ export function createApp({
|
||||
"/hosted-rooms/:n/image",
|
||||
async ({ me, params }) => {
|
||||
const r = await hostedCall(() => hosted.removeImage(me.id, params.n));
|
||||
await audited(me, "hosted_room.image", String(params.n), { removed: true });
|
||||
await audited(me.id, "hosted_room.image", String(params.n), { removed: true });
|
||||
return { room: await hostedViewFor(r, me) };
|
||||
},
|
||||
{ signedIn: true, params: hostedParam },
|
||||
@@ -1449,7 +1389,7 @@ export function createApp({
|
||||
"/catalogs",
|
||||
async ({ me, body, set }) => {
|
||||
const r = await productCall(() => products.registerCatalog(me.id, body));
|
||||
await audited(me, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url });
|
||||
await audited(me.id, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url });
|
||||
set.status = 201;
|
||||
return { catalog: catalogView(r.catalog), key: r.key };
|
||||
},
|
||||
@@ -1492,11 +1432,11 @@ export function createApp({
|
||||
async ({ me, params, body }) => {
|
||||
await need(me, "catalog:edit", catalogScope(params.id));
|
||||
const r = await productCall(() => catalogs.update(params.id, body));
|
||||
if (Object.keys(r.changed).length) await audited(me, "catalog.edit", params.id, { changed: r.changed });
|
||||
if (Object.keys(r.changed).length) await audited(me.id, "catalog.edit", params.id, { changed: r.changed });
|
||||
return { catalog: catalogView(r.catalog) };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: catalogParam,
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String({ maxLength: 64 })),
|
||||
@@ -1515,20 +1455,20 @@ export function createApp({
|
||||
async ({ me, params }) => {
|
||||
await need(me, "catalog:rotate-key", catalogScope(params.id));
|
||||
const key = await productCall(() => catalogs.rotateKey(params.id));
|
||||
await audited(me, "catalog.rotate_key", params.id);
|
||||
await audited(me.id, "catalog.rotate_key", params.id);
|
||||
return { key };
|
||||
},
|
||||
{ scripted: true, params: catalogParam },
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.post(
|
||||
"/catalogs/:id/suspend",
|
||||
async ({ me, params, body }) => {
|
||||
await need(me, "catalog:suspend", catalogScope(params.id));
|
||||
const c = await productCall(() => catalogs.setSuspended(params.id, true, body.reason.trim()));
|
||||
await audited(me, "catalog.suspend", c.id, { name: c.name, reason: body.reason.trim() });
|
||||
await audited(me.id, "catalog.suspend", c.id, { name: c.name, reason: body.reason.trim() });
|
||||
return { catalog: catalogView(c) };
|
||||
},
|
||||
{ scripted: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
|
||||
{ signedIn: 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.
|
||||
.post(
|
||||
@@ -1540,20 +1480,20 @@ export function createApp({
|
||||
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 });
|
||||
const kase = await cases.open("catalog-deletion", me.id, { catalog: c.id }, body.reason);
|
||||
await audited(me, "case.open", String(kase.id), { kind: kase.kind }, kase.tags);
|
||||
await audited(me.id, "case.open", String(kase.id), { kind: kase.kind }, kase.tags);
|
||||
return { case: kase.id };
|
||||
},
|
||||
{ scripted: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 2000 }) }) },
|
||||
{ signedIn: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 2000 }) }) },
|
||||
)
|
||||
.post(
|
||||
"/catalogs/:id/reinstate",
|
||||
async ({ me, params }) => {
|
||||
await need(me, "catalog:suspend", catalogScope(params.id));
|
||||
const c = await productCall(() => catalogs.setSuspended(params.id, false, ""));
|
||||
await audited(me, "catalog.reinstate", c.id, { name: c.name });
|
||||
await audited(me.id, "catalog.reinstate", c.id, { name: c.name });
|
||||
return { catalog: catalogView(c) };
|
||||
},
|
||||
{ scripted: true, params: catalogParam },
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
|
||||
// A catalog's team: the owner, and the roles it gave.
|
||||
@@ -1571,7 +1511,7 @@ export function createApp({
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ scripted: true, params: catalogParam },
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.put(
|
||||
"/catalogs/:id/team/:username",
|
||||
@@ -1582,11 +1522,11 @@ export function createApp({
|
||||
throw new Problem(403, "forbidden", "You can only give roles whose permissions you have.");
|
||||
const p = await personByUsername(params.username);
|
||||
await productCall(() => catalogs.addRole(params.id, p.id, body.role, me.id));
|
||||
await audited(me, "catalog.team_add", params.id, { person: p.username, role: body.role }, { catalog: params.id, person: p.id });
|
||||
await audited(me.id, "catalog.team_add", params.id, { person: p.username, role: body.role }, { catalog: params.id, person: p.id });
|
||||
return { team: await perms.team(params.id) };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
|
||||
body: t.Object({ role: t.Literal("catalog-operator") }),
|
||||
},
|
||||
@@ -1599,11 +1539,11 @@ export function createApp({
|
||||
// Anyone may leave a team; taking others off needs team:manage.
|
||||
if (p.id !== me.id) await need(me, "team:manage", scope);
|
||||
await productCall(() => catalogs.removeRole(params.id, p.id, query.role));
|
||||
await audited(me, "catalog.team_remove", params.id, { person: p.username, role: query.role }, { catalog: params.id, person: p.id });
|
||||
await audited(me.id, "catalog.team_remove", params.id, { person: p.username, role: query.role }, { catalog: params.id, person: p.id });
|
||||
return { team: await perms.team(params.id) };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
|
||||
query: t.Object({ role: t.Literal("catalog-operator") }),
|
||||
},
|
||||
@@ -1615,7 +1555,7 @@ export function createApp({
|
||||
async ({ me, params, body }) => {
|
||||
const invite = body?.invite || undefined;
|
||||
const status = await productCall(() => catalogs.join(params.id, me.id, invite));
|
||||
await audited(me, status === "member" ? "catalog.join" : "catalog.request", params.id, invite ? { invite: true } : {}, {
|
||||
await audited(me.id, status === "member" ? "catalog.join" : "catalog.request", params.id, invite ? { invite: true } : {}, {
|
||||
catalog: params.id,
|
||||
person: me.id,
|
||||
});
|
||||
@@ -1632,7 +1572,7 @@ export function createApp({
|
||||
"/catalogs/:id/join",
|
||||
async ({ me, params, set }) => {
|
||||
await catalogs.leave(params.id, me.id);
|
||||
await audited(me, "catalog.leave", params.id, {}, { catalog: params.id, person: me.id });
|
||||
await audited(me.id, "catalog.leave", params.id, {}, { catalog: params.id, person: me.id });
|
||||
realtime.notify(me.id, { type: "library" });
|
||||
set.status = 204;
|
||||
},
|
||||
@@ -1645,7 +1585,7 @@ export function createApp({
|
||||
return { members: await catalogs.members(params.id, query.status), counts: await catalogs.memberCounts(params.id) };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: catalogParam,
|
||||
query: t.Object({
|
||||
status: t.Optional(t.Union([t.Literal("requested"), t.Literal("member"), t.Literal("banned")])),
|
||||
@@ -1661,7 +1601,7 @@ export function createApp({
|
||||
await productCall(() => catalogs.setMember(params.id, p.id, body.status, me.id));
|
||||
const action =
|
||||
body.status === "banned" ? "catalog.member_ban" : before === "requested" ? "catalog.member_approve" : "catalog.member_add";
|
||||
await audited(me, action, params.id, { person: p.username, ...(before ? { from: before } : {}) }, {
|
||||
await audited(me.id, action, params.id, { person: p.username, ...(before ? { from: before } : {}) }, {
|
||||
catalog: params.id,
|
||||
person: p.id,
|
||||
});
|
||||
@@ -1673,7 +1613,7 @@ export function createApp({
|
||||
return { membership: body.status };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
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")]) }),
|
||||
},
|
||||
@@ -1687,7 +1627,7 @@ export function createApp({
|
||||
await productCall(() => catalogs.removeMember(params.id, p.id));
|
||||
const action =
|
||||
before === "requested" ? "catalog.member_decline" : before === "banned" ? "catalog.member_unban" : "catalog.member_remove";
|
||||
await audited(me, action, params.id, { person: p.username }, { catalog: params.id, person: p.id });
|
||||
await audited(me.id, action, params.id, { person: p.username }, { catalog: params.id, person: p.id });
|
||||
realtime.notify(p.id, { type: "library" });
|
||||
if (before === "requested") {
|
||||
const c = await catalogs.get(params.id);
|
||||
@@ -1696,7 +1636,7 @@ export function createApp({
|
||||
set.status = 204;
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
|
||||
},
|
||||
)
|
||||
@@ -1706,14 +1646,14 @@ export function createApp({
|
||||
await need(me, "member:invite", catalogScope(params.id));
|
||||
return { invites: (await catalogs.invites(params.id)).map(inviteView) };
|
||||
},
|
||||
{ scripted: true, params: catalogParam },
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.post(
|
||||
"/catalogs/:id/invites",
|
||||
async ({ me, params, body, set }) => {
|
||||
await need(me, "member:invite", catalogScope(params.id));
|
||||
const inv = await productCall(() => catalogs.createInvite(params.id, me.id, body));
|
||||
await audited(me, "catalog.invite_create", params.id, {
|
||||
await audited(me.id, "catalog.invite_create", params.id, {
|
||||
maxUses: inv.maxUses,
|
||||
expiresAt: inv.expiresAt?.toISOString() ?? null,
|
||||
});
|
||||
@@ -1721,7 +1661,7 @@ export function createApp({
|
||||
return { invite: inviteView(inv) };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: catalogParam,
|
||||
body: t.Object({
|
||||
maxUses: t.Optional(t.Integer({ minimum: 1, maximum: 10_000 })),
|
||||
@@ -1734,11 +1674,11 @@ export function createApp({
|
||||
async ({ me, params, set }) => {
|
||||
await need(me, "member:invite", catalogScope(params.id));
|
||||
await productCall(() => catalogs.revokeInvite(params.id, params.code));
|
||||
await audited(me, "catalog.invite_revoke", params.id, { code: params.code.slice(0, 3) + "…" });
|
||||
await audited(me.id, "catalog.invite_revoke", params.id, { code: params.code.slice(0, 3) + "…" });
|
||||
set.status = 204;
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), code: t.String({ maxLength: 32 }) }),
|
||||
},
|
||||
)
|
||||
@@ -1750,7 +1690,7 @@ export function createApp({
|
||||
await need(me, "audit:read", catalogScope(params.id));
|
||||
return { products: await catalogs.hosted(params.id) };
|
||||
},
|
||||
{ scripted: true, params: catalogParam },
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.delete(
|
||||
"/catalogs/:id/products/:product",
|
||||
@@ -1758,14 +1698,14 @@ export function createApp({
|
||||
await need(me, "hosting:delist", catalogScope(params.id));
|
||||
const reason = query.reason.trim();
|
||||
const revisions = await productCall(() => catalogs.delist(params.id, params.product));
|
||||
await audited(me, "catalog.delist", params.id, { product: params.product, revisions, reason }, {
|
||||
await audited(me.id, "catalog.delist", params.id, { product: params.product, revisions, reason }, {
|
||||
catalog: params.id,
|
||||
product: params.product,
|
||||
});
|
||||
return { revisions };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
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 }) }),
|
||||
},
|
||||
@@ -1817,13 +1757,8 @@ export function createApp({
|
||||
// Products.
|
||||
.post(
|
||||
"/products/upload",
|
||||
async ({ me, body }) => {
|
||||
// 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()) }) },
|
||||
async ({ me, body }) => productCall(() => products.uploadToken(me.id, body.catalog, body.product)),
|
||||
{ signedIn: true, body: t.Object({ catalog: t.String(), product: t.Optional(t.String()) }) },
|
||||
)
|
||||
.get(
|
||||
"/products",
|
||||
@@ -1896,14 +1831,14 @@ export function createApp({
|
||||
if (open) review = open.id;
|
||||
else {
|
||||
const c = await cases.open("rating-review", me.id, { product: params.id, person: creatorId }, reason || "Lower the rating to general.");
|
||||
await audited(me, "case.open", String(c.id), { kind: c.kind }, c.tags);
|
||||
await audited(me.id, "case.open", String(c.id), { kind: c.kind }, c.tags);
|
||||
review = c.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
const done = await productCall(async () => products.updateInfo(me.id, params.id, input, reason, rights));
|
||||
if (done.entry) {
|
||||
await audited(me, "product.edit", params.id, { fields: done.fields, ...(reason ? { reason } : {}) }, {
|
||||
await audited(me.id, "product.edit", params.id, { fields: done.fields, ...(reason ? { reason } : {}) }, {
|
||||
product: params.id,
|
||||
person: creatorId,
|
||||
});
|
||||
@@ -1916,7 +1851,7 @@ export function createApp({
|
||||
};
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: productParam,
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String({ maxLength: 64 })),
|
||||
@@ -1939,7 +1874,7 @@ export function createApp({
|
||||
const parent = (await products.latestParent(p!))?.parentInfo?.categories ?? [];
|
||||
return { choices: await products.categoryChoices(p!), parent };
|
||||
},
|
||||
{ scripted: true, params: productParam },
|
||||
{ signedIn: true, params: productParam },
|
||||
)
|
||||
// A draft becomes published.
|
||||
.post(
|
||||
@@ -1948,10 +1883,10 @@ export function createApp({
|
||||
const creatorId = await creatorOf(params.id);
|
||||
await need(me, "product:publish", productScope(params.id));
|
||||
const p = await productCall(() => products.publish(me.id, params.id));
|
||||
await audited(me, "product.publish", p.id, { name: p.name }, { product: p.id, person: creatorId });
|
||||
await audited(me.id, "product.publish", p.id, { name: p.name }, { product: p.id, person: creatorId });
|
||||
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
|
||||
},
|
||||
{ scripted: true, params: productParam },
|
||||
{ signedIn: true, params: productParam },
|
||||
)
|
||||
// The info history: who changed what, and why.
|
||||
.get(
|
||||
@@ -1973,7 +1908,7 @@ export function createApp({
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ scripted: true, params: productParam },
|
||||
{ signedIn: true, params: productParam },
|
||||
)
|
||||
.post(
|
||||
"/products/:id/history/:entry/revert",
|
||||
@@ -1998,7 +1933,7 @@ export function createApp({
|
||||
};
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
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 })) }),
|
||||
},
|
||||
@@ -2010,7 +1945,7 @@ export function createApp({
|
||||
const creatorId = await creatorOf(params.id);
|
||||
await need(me, "product:lock", productScope(params.id));
|
||||
const p = await productCall(() => products.setLocks(me.id, params.id, body.fields, body.reason));
|
||||
await audited(me, "product.lock", p.id, { fields: p.locked, reason: body.reason.trim() }, {
|
||||
await audited(me.id, "product.lock", p.id, { fields: p.locked, reason: body.reason.trim() }, {
|
||||
product: p.id,
|
||||
person: creatorId,
|
||||
});
|
||||
@@ -2018,7 +1953,7 @@ export function createApp({
|
||||
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
|
||||
},
|
||||
{
|
||||
scripted: true,
|
||||
signedIn: true,
|
||||
params: productParam,
|
||||
body: t.Object({
|
||||
fields: t.Array(
|
||||
@@ -2041,7 +1976,7 @@ export function createApp({
|
||||
"/products/:id",
|
||||
async ({ me, params, set }) => {
|
||||
await productCall(() => products.delete(me.id, params.id));
|
||||
await audited(me, "product.delete", params.id);
|
||||
await audited(me.id, "product.delete", params.id);
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: productParam },
|
||||
@@ -2051,7 +1986,7 @@ export function createApp({
|
||||
"/products/:id/remove",
|
||||
async ({ me, params, body }) => {
|
||||
const removed = await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
|
||||
await audited(me, "product.remove", params.id, { reason: body.reason.trim() }, {
|
||||
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() }, {
|
||||
product: params.id,
|
||||
person: removed.creatorId,
|
||||
});
|
||||
@@ -2060,66 +1995,6 @@ export function createApp({
|
||||
},
|
||||
{ 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).
|
||||
.get(
|
||||
"/blocks",
|
||||
@@ -2129,7 +2004,7 @@ export function createApp({
|
||||
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 };
|
||||
},
|
||||
{ scripted: true, query: t.Object({ before: t.Optional(t.String({ format: "date-time" })) }) },
|
||||
{ signedIn: 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).
|
||||
.get(
|
||||
@@ -2139,7 +2014,7 @@ export function createApp({
|
||||
const b = await blocks.get(params.sha256);
|
||||
return { block: b ? blockView(b) : null, products: await blocks.usedBy(params.sha256) };
|
||||
},
|
||||
{ scripted: true, params: blockParam },
|
||||
{ signedIn: true, params: blockParam },
|
||||
)
|
||||
.put(
|
||||
"/blocks/:sha256",
|
||||
@@ -2148,14 +2023,14 @@ export function createApp({
|
||||
const reason = body.reason.trim();
|
||||
if (!reason || !isSha256(params.sha256)) throw new Problem(400, "invalid", "Give a reason.");
|
||||
const removed = await productCall(() => blocks.block(params.sha256, reason, me.id));
|
||||
await audited(me, "file.block", params.sha256, { reason, removed: removed.map((p) => p.id) });
|
||||
await audited(me.id, "file.block", params.sha256, { reason, removed: removed.map((p) => p.id) });
|
||||
for (const p of removed) {
|
||||
await audited(me, "product.remove", p.id, { reason, file: params.sha256 }, { product: p.id, person: p.creatorId });
|
||||
await audited(me.id, "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);
|
||||
}
|
||||
return { removed: removed.map((p) => ({ id: p.id, name: p.name })) };
|
||||
},
|
||||
{ scripted: true, params: blockParam, body: t.Object({ reason: t.String({ maxLength: 500 }) }) },
|
||||
{ signedIn: true, params: blockParam, body: t.Object({ reason: t.String({ maxLength: 500 }) }) },
|
||||
)
|
||||
.delete(
|
||||
"/blocks/:sha256",
|
||||
@@ -2163,10 +2038,10 @@ export function createApp({
|
||||
await need(me, "product:remove", hubScope);
|
||||
await productCall(() => blocks.unblock(params.sha256));
|
||||
const reason = body?.reason?.trim() ?? "";
|
||||
await audited(me, "file.unblock", params.sha256, reason ? { reason } : {});
|
||||
await audited(me.id, "file.unblock", params.sha256, reason ? { reason } : {});
|
||||
set.status = 204;
|
||||
},
|
||||
{ scripted: true, params: blockParam, body: t.Optional(t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) })) },
|
||||
{ signedIn: 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).
|
||||
.post(
|
||||
@@ -2175,21 +2050,20 @@ export function createApp({
|
||||
await need(me, "product:unremove", productScope(params.id));
|
||||
const p = await productCall(() => products.unremove(params.id));
|
||||
const reason = body.reason?.trim() ?? "";
|
||||
await audited(me, "product.unremove", p.id, reason ? { reason } : {}, { product: p.id, person: p.creatorId });
|
||||
await audited(me.id, "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);
|
||||
return { ok: true };
|
||||
},
|
||||
{ scripted: true, params: productParam, body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }) },
|
||||
{ signedIn: true, params: productParam, body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }) },
|
||||
)
|
||||
.post(
|
||||
"/products/:id/revisions/:n/retract",
|
||||
async ({ me, params }) => {
|
||||
await need(me, "revision:retract", productScope(params.id));
|
||||
await productCall(() => products.retract(me.id, params.id, params.n));
|
||||
await audited(me, "product.retract", params.id, { revision: params.n });
|
||||
await audited(me.id, "product.retract", params.id, { revision: params.n });
|
||||
return { ok: true };
|
||||
},
|
||||
{ scripted: true, params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), n: t.Numeric({ minimum: 1 }) }) },
|
||||
{ signedIn: 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.
|
||||
.get(
|
||||
@@ -2314,7 +2188,7 @@ export function createApp({
|
||||
console.error(`age ${p.id}:`, e);
|
||||
throw new Problem(502, "provider_unavailable", "The age check didn't start. Try again in a moment.");
|
||||
}
|
||||
await audited(me, "age.start", p.id, { ref: started.kind === "done" ? null : started.ref });
|
||||
await audited(me.id, "age.start", p.id, { ref: started.kind === "done" ? null : started.ref });
|
||||
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 }) }) },
|
||||
@@ -2440,7 +2314,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).
|
||||
await audited(me, "case.open", String(c.id), { kind: c.kind }, c.kind === "report" ? {} : c.tags);
|
||||
await audited(me.id, "case.open", String(c.id), { kind: c.kind }, c.kind === "report" ? {} : c.tags);
|
||||
set.status = 201;
|
||||
return { case: caseView(c, await cases.names([c]), me) };
|
||||
},
|
||||
@@ -2511,10 +2385,10 @@ export function createApp({
|
||||
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.");
|
||||
const updated = await cases.assign(c, to?.id ?? null);
|
||||
await audited(me, "case.assign", String(c.id), { to: to?.username ?? null }, c.kind === "report" ? {} : c.tags);
|
||||
await audited(me.id, "case.assign", String(c.id), { to: to?.username ?? null }, c.kind === "report" ? {} : c.tags);
|
||||
return { case: caseView(updated, await cases.names([updated]), me) };
|
||||
},
|
||||
{ scripted: true, params: caseParam, body: t.Object({ to: t.Union([t.String({ maxLength: 64 }), t.Null()]) }) },
|
||||
{ signedIn: true, params: caseParam, body: t.Object({ to: t.Union([t.String({ maxLength: 64 }), t.Null()]) }) },
|
||||
)
|
||||
.post(
|
||||
"/cases/:id/decide",
|
||||
@@ -2635,7 +2509,7 @@ export function createApp({
|
||||
const c = await productCall(() =>
|
||||
products.setCatalogStatus(params.id, params.decision === "approve" ? "approved" : "rejected"),
|
||||
);
|
||||
await audited(me, `catalog.${params.decision}`, c.id, { name: c.name });
|
||||
await audited(me.id, `catalog.${params.decision}`, c.id, { name: c.name });
|
||||
return { catalog: catalogView(c) };
|
||||
},
|
||||
{ moderator: true, params: t.Object({ id: t.String(), decision: t.Union([t.Literal("approve"), t.Literal("reject")]) }) },
|
||||
@@ -2766,7 +2640,7 @@ export function createApp({
|
||||
async ({ me, params }) => {
|
||||
const p = await personById(params.id);
|
||||
const [updated] = await db.update(user).set({ approved: true }).where(eq(user.id, p.id)).returning();
|
||||
if (!p.approved) await audited(me, "person.approve", p.username ?? p.id);
|
||||
if (!p.approved) await audited(me.id, "person.approve", p.username ?? p.id);
|
||||
return { person: personView(updated!) };
|
||||
},
|
||||
{ moderator: true },
|
||||
@@ -2777,7 +2651,7 @@ export function createApp({
|
||||
const p = await personById(params.id);
|
||||
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 audited(me, "person.decline", p.username ?? p.id);
|
||||
await audited(me.id, "person.decline", p.username ?? p.id);
|
||||
return { declined: true };
|
||||
},
|
||||
{ moderator: true },
|
||||
@@ -2789,7 +2663,7 @@ export function createApp({
|
||||
if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change the hub's settings.");
|
||||
const from = await settings.registration();
|
||||
await settings.setRegistration(body.registration);
|
||||
if (from !== body.registration) await audited(me, "hub.registration", body.registration, { from });
|
||||
if (from !== body.registration) await audited(me.id, "hub.registration", body.registration, { from });
|
||||
return { registration: body.registration };
|
||||
},
|
||||
{
|
||||
@@ -2815,7 +2689,7 @@ export function createApp({
|
||||
async ({ me, params, body }) => {
|
||||
const p = await personById(params.id);
|
||||
const [updated] = await db.update(user).set({ linkCap: body.linkCap }).where(eq(user.id, p.id)).returning();
|
||||
await audited(me, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap });
|
||||
await audited(me.id, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap });
|
||||
return { person: personView(updated!) };
|
||||
},
|
||||
{ moderator: true, body: t.Object({ linkCap: t.Integer({ minimum: 0, maximum: 1000 }) }) },
|
||||
@@ -2829,7 +2703,7 @@ export function createApp({
|
||||
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));
|
||||
realtime.disconnect(p.id);
|
||||
await audited(me, "person.ban", p.username ?? p.id, { reason: body.reason });
|
||||
await audited(me.id, "person.ban", p.username ?? p.id, { reason: body.reason });
|
||||
return { person: personView(updated!) };
|
||||
},
|
||||
{ moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
|
||||
@@ -2839,7 +2713,7 @@ export function createApp({
|
||||
async ({ me, params }) => {
|
||||
const p = await personById(params.id);
|
||||
const [updated] = await db.update(user).set({ banned: false, banReason: null }).where(eq(user.id, p.id)).returning();
|
||||
await audited(me, "person.unban", p.username ?? p.id);
|
||||
await audited(me.id, "person.unban", p.username ?? p.id);
|
||||
return { person: personView(updated!) };
|
||||
},
|
||||
{ moderator: true },
|
||||
@@ -2851,7 +2725,7 @@ export function createApp({
|
||||
const p = await personById(params.id);
|
||||
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();
|
||||
await audited(me, "person.role", p.username ?? p.id, { from: p.role, to: body.role });
|
||||
await audited(me.id, "person.role", p.username ?? p.id, { from: p.role, to: body.role });
|
||||
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 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.
|
||||
import { and, eq, schema, type Database, type TokenGrant } from "@t5de/db";
|
||||
import { and, eq, schema, type Database } from "@t5de/db";
|
||||
|
||||
const { catalog, product, roleAssignment, user } = schema;
|
||||
|
||||
@@ -143,18 +143,8 @@ export type RoleId = keyof typeof roles;
|
||||
/** Roles that can be given at a catalog (the owner is derived, never given). */
|
||||
export const assignableCatalogRoles = ["catalog-operator"] as const;
|
||||
|
||||
/** 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; 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)),
|
||||
);
|
||||
}
|
||||
/** A person, as the check needs them. */
|
||||
export type Who = { id: string; role: string };
|
||||
|
||||
/** Thrown when a permission is missing; the API answers 403 with what was missing. */
|
||||
export class Forbidden extends Error {
|
||||
@@ -171,8 +161,9 @@ export class Permissions {
|
||||
|
||||
/** A person's hub-wide permissions (their user.role). */
|
||||
hub(who: Who | undefined): Set<Permission> {
|
||||
const held = who?.role === "admin" ? roles.admin.permissions : who?.role === "moderator" ? roles.moderator.permissions : [];
|
||||
return new Set(held.filter((p) => granted(who, p, hubScope)));
|
||||
if (who?.role === "admin") return new Set(roles.admin.permissions);
|
||||
if (who?.role === "moderator") return new Set(roles.moderator.permissions);
|
||||
return new Set();
|
||||
}
|
||||
|
||||
/** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */
|
||||
@@ -201,7 +192,7 @@ export class Permissions {
|
||||
/** Every permission a person holds at a scope. */
|
||||
async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> {
|
||||
const out = new Set<Permission>();
|
||||
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) if (granted(who, p, scope)) out.add(p);
|
||||
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) out.add(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
// 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";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { api, ApiError, unwrap } from "@/lib/api";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
const done = () => revalidatePath("/account");
|
||||
@@ -71,34 +71,3 @@ export async function startAgeCheck(_: ActionState, form: FormData): Promise<Act
|
||||
if (url) redirect(url);
|
||||
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,18 +11,13 @@ import { linkAccount, signOutComputers } from "./actions";
|
||||
import { AgeCard } from "./age-card";
|
||||
import { ChallengeCard } from "./challenge-card";
|
||||
import { LinkCard } from "./link-card";
|
||||
import { TokensCard } from "./tokens-card";
|
||||
|
||||
export const metadata: Metadata = { title: "Account" };
|
||||
|
||||
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/account");
|
||||
const [account, age, tokens] = await Promise.all([
|
||||
api.account.get().then(unwrap),
|
||||
api.age.get().then(unwrap),
|
||||
api.account.tokens.get().then(unwrap),
|
||||
]);
|
||||
const [account, age] = await Promise.all([api.account.get().then(unwrap), api.age.get().then(unwrap)]);
|
||||
const { person, links, challenges, computers } = account;
|
||||
const params = await searchParams;
|
||||
const welcome = params.welcome !== undefined;
|
||||
@@ -121,8 +116,6 @@ export default async function Account({ searchParams }: { searchParams: Promise<
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TokensCard data={tokens} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
"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 { ChevronRightIcon } from "lucide-react";
|
||||
import { useActionState, useState } from "react";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
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[] };
|
||||
|
||||
// Permissions are `resource:action`; they're grouped by resource, in this order (new resources go last, by name).
|
||||
const groupNames: Record<string, string> = {
|
||||
product: "Products",
|
||||
revision: "Revisions",
|
||||
catalog: "Catalog",
|
||||
team: "Team",
|
||||
member: "Members",
|
||||
hosting: "Hosting",
|
||||
import: "Imports",
|
||||
case: "Cases",
|
||||
audit: "Audit Log",
|
||||
stats: "Stats",
|
||||
person: "People",
|
||||
room: "Rooms",
|
||||
settings: "Settings",
|
||||
};
|
||||
const order = Object.keys(groupNames);
|
||||
|
||||
/** A scope's permissions, grouped by resource: [group name, permissions]. */
|
||||
function groupsOf(permissions: string[]): [string, string[]][] {
|
||||
const by = new Map<string, string[]>();
|
||||
for (const p of permissions) {
|
||||
const resource = p.split(":")[0]!;
|
||||
by.set(resource, [...(by.get(resource) ?? []), p]);
|
||||
}
|
||||
const rank = (r: string) => (order.includes(r) ? order.indexOf(r) : order.length);
|
||||
return [...by.entries()]
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([r, list]) => [groupNames[r] ?? r.charAt(0).toUpperCase() + r.slice(1), list]);
|
||||
}
|
||||
|
||||
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 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="divide-y rounded-md border">
|
||||
{groupsOf(scope.permissions).map(([group, list]) => {
|
||||
const chosen = list.filter((p) => row.permissions.includes(p)).length;
|
||||
const toggleAll = (on: boolean) =>
|
||||
set(i, { ...row, permissions: on ? [...new Set([...row.permissions, ...list])] : row.permissions.filter((x) => !list.includes(x)) });
|
||||
return (
|
||||
<details key={group} className="group/perm">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm select-none [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground transition-transform group-open/perm:rotate-90" aria-hidden />
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`All ${group}`}
|
||||
checked={chosen === list.length}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = chosen > 0 && chosen < list.length;
|
||||
}}
|
||||
onChange={(e) => toggleAll(e.target.checked)}
|
||||
/>
|
||||
<span className="font-medium">{group}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chosen > 0 ? `${chosen} of ${list.length}` : list.length === 1 ? "1 permission" : `${list.length} permissions`}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="grid gap-x-4 gap-y-1 px-3 pt-1 pb-3 pl-10 sm:grid-cols-2">
|
||||
{list.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.slice(p.indexOf(":") + 1)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
<CopyButton text={state.token} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Send it as <code className="font-mono text-xs">Authorization: Bearer <token></code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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,9 +44,6 @@ export type AuditEntry = ConsoleData["audit"][number];
|
||||
export type CaseSummary = Data<V1["cases"]["get"]>["cases"][number];
|
||||
export type CasePage = Data<ReturnType<V1["cases"]>["get"]>;
|
||||
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 CatalogMember = Data<ReturnType<V1["catalogs"]>["members"]["get"]>["members"][number];
|
||||
export type CatalogInvite = Data<ReturnType<V1["catalogs"]>["invites"]["get"]>["invites"][number];
|
||||
|
||||
+1
-3
@@ -7,9 +7,7 @@ requests and type the website's client (Eden). This is the overview.
|
||||
- `/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
|
||||
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`).
|
||||
cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session token>`.
|
||||
|
||||
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`,
|
||||
|
||||
+1
-19
@@ -182,24 +182,6 @@ 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;
|
||||
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)
|
||||
```
|
||||
@@ -242,7 +224,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 |
|
||||
| `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}` |
|
||||
| `GET/POST /api/v1/account/tokens`, `DELETE /api/v1/account/tokens/:id` | API tokens (above) |
|
||||
| `GET/POST /api/v1/account/tokens`, `DELETE /api/v1/account/tokens/:id` | API tokens |
|
||||
| `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 |
|
||||
|
||||
|
||||
+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).
|
||||
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
|
||||
reasons, moderator notes or second approval. File blocks and API tokens **done** (2026-11-05); two-factor
|
||||
sign-in next.
|
||||
reasons, moderator notes or second approval. File blocks **done** (2026-11-05); API tokens and two-factor sign-in
|
||||
next.
|
||||
4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike).
|
||||
5. Charts and stats; mirroring between catalogs.
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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,13 +99,6 @@
|
||||
"when": 1793917069600,
|
||||
"tag": "0013_file_blocks",
|
||||
"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";
|
||||
|
||||
export { schema };
|
||||
export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem, TokenGrant } from "./schema";
|
||||
export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
@@ -667,25 +667,3 @@ export const notification = pgTable(
|
||||
},
|
||||
(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