diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 13ab8ec..d335439 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,6 +8,7 @@ import { desc, eq, ilike, + isNull, inArray, ne, or, @@ -46,6 +47,7 @@ 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; @@ -140,6 +142,7 @@ 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); @@ -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.* * and hosted_room.* entries are about their target. */ const audited = ( - actorId: string | null, + actor: string | null | { id: string; token?: { name: string } }, action: string, target: string, detail: Record = {}, @@ -276,7 +279,10 @@ export function createApp({ else if (action.startsWith("product.")) t.product = 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. */ @@ -411,6 +417,31 @@ 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 = {}; + 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, @@ -477,17 +508,17 @@ export function createApp({ products.updateInfo(me.id, id, { rating: "general" }, reason, { creator: false, editLocked: true, rateLower: true }), ); 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); } } 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.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); } else if (c.kind === "catalog-deletion" && c.tags.catalog) { 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 }) => { + // 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). @@ -762,32 +807,47 @@ 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 }) => { + 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; if (!me) throw new Problem(401, "signed_out", "Sign in first."); return { me }; }, }, 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 (!person) throw new Problem(401, "signed_out", "Sign in first."); return { me: person }; }, }, 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 (!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. @@ -1032,7 +1092,7 @@ export function createApp({ await age.forgetAccount(l.imvuId); await rooms.forgetOwner(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; }, { signedIn: true, params: imvuIdParam }, @@ -1059,7 +1119,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.id, "proxy.signin", ""); + await audited(me, "proxy.signin", ""); set.status = 204; }, { 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)) 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.id, "hosted_room.create", String(r.room.n), { + await audited(me, "hosted_room.create", String(r.room.n), { name: r.room.name, rating: r.room.rating, privacy: r.room.privacy, @@ -1175,7 +1235,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.id, "hosted_room.edit", String(r.room.n), { + await audited(me, "hosted_room.edit", String(r.room.n), { fields: Object.keys(body), ...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}), }); @@ -1218,7 +1278,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.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; }, { signedIn: true, params: hostedParam }, @@ -1254,7 +1314,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.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; }, { @@ -1291,7 +1351,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.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) }; }, { @@ -1309,7 +1369,7 @@ export function createApp({ "/hosted-rooms/:n/image", async ({ me, params }) => { 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) }; }, { signedIn: true, params: hostedParam }, @@ -1389,7 +1449,7 @@ export function createApp({ "/catalogs", async ({ me, body, set }) => { 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; return { catalog: catalogView(r.catalog), key: r.key }; }, @@ -1432,11 +1492,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.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) }; }, { - signedIn: true, + scripted: true, params: catalogParam, body: t.Object({ name: t.Optional(t.String({ maxLength: 64 })), @@ -1455,20 +1515,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.id, "catalog.rotate_key", params.id); + await audited(me, "catalog.rotate_key", params.id); return { key }; }, - { signedIn: true, params: catalogParam }, + { scripted: 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.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) }; }, - { 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. .post( @@ -1480,20 +1540,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.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 }; }, - { 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( "/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.id, "catalog.reinstate", c.id, { name: c.name }); + await audited(me, "catalog.reinstate", c.id, { name: c.name }); return { catalog: catalogView(c) }; }, - { signedIn: true, params: catalogParam }, + { scripted: true, params: catalogParam }, ) // 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( "/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."); const p = await personByUsername(params.username); 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) }; }, { - signedIn: true, + scripted: 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") }), }, @@ -1539,11 +1599,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.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) }; }, { - signedIn: true, + scripted: 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") }), }, @@ -1555,7 +1615,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.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, person: me.id, }); @@ -1572,7 +1632,7 @@ export function createApp({ "/catalogs/:id/join", async ({ me, params, set }) => { 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" }); 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) }; }, { - signedIn: true, + scripted: true, params: catalogParam, query: t.Object({ 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)); const action = 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, person: p.id, }); @@ -1613,7 +1673,7 @@ export function createApp({ return { membership: body.status }; }, { - signedIn: true, + scripted: 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")]) }), }, @@ -1627,7 +1687,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.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" }); if (before === "requested") { const c = await catalogs.get(params.id); @@ -1636,7 +1696,7 @@ export function createApp({ set.status = 204; }, { - signedIn: true, + scripted: true, 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)); return { invites: (await catalogs.invites(params.id)).map(inviteView) }; }, - { signedIn: true, params: catalogParam }, + { scripted: 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.id, "catalog.invite_create", params.id, { + await audited(me, "catalog.invite_create", params.id, { maxUses: inv.maxUses, expiresAt: inv.expiresAt?.toISOString() ?? null, }); @@ -1661,7 +1721,7 @@ export function createApp({ return { invite: inviteView(inv) }; }, { - signedIn: true, + scripted: true, params: catalogParam, body: t.Object({ maxUses: t.Optional(t.Integer({ minimum: 1, maximum: 10_000 })), @@ -1674,11 +1734,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.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; }, { - signedIn: true, + scripted: true, 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)); return { products: await catalogs.hosted(params.id) }; }, - { signedIn: true, params: catalogParam }, + { scripted: true, params: catalogParam }, ) .delete( "/catalogs/:id/products/:product", @@ -1698,14 +1758,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.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, product: params.product, }); 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}$" }) }), query: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }), }, @@ -1757,8 +1817,13 @@ export function createApp({ // Products. .post( "/products/upload", - 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()) }) }, + 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()) }) }, ) .get( "/products", @@ -1831,14 +1896,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.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; } } } const done = await productCall(async () => products.updateInfo(me.id, params.id, input, reason, rights)); 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, person: creatorId, }); @@ -1851,7 +1916,7 @@ export function createApp({ }; }, { - signedIn: true, + scripted: true, params: productParam, body: t.Object({ name: t.Optional(t.String({ maxLength: 64 })), @@ -1874,7 +1939,7 @@ export function createApp({ const parent = (await products.latestParent(p!))?.parentInfo?.categories ?? []; return { choices: await products.categoryChoices(p!), parent }; }, - { signedIn: true, params: productParam }, + { scripted: true, params: productParam }, ) // A draft becomes published. .post( @@ -1883,10 +1948,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.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)) }; }, - { signedIn: true, params: productParam }, + { scripted: true, params: productParam }, ) // The info history: who changed what, and why. .get( @@ -1908,7 +1973,7 @@ export function createApp({ })), }; }, - { signedIn: true, params: productParam }, + { scripted: true, params: productParam }, ) .post( "/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 }) }), body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }), }, @@ -1945,7 +2010,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.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, person: creatorId, }); @@ -1953,7 +2018,7 @@ export function createApp({ return { product: productView(p, undefined, undefined, await products.latestParent(p)) }; }, { - signedIn: true, + scripted: true, params: productParam, body: t.Object({ fields: t.Array( @@ -1976,7 +2041,7 @@ export function createApp({ "/products/:id", async ({ me, params, set }) => { 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; }, { signedIn: true, params: productParam }, @@ -1986,7 +2051,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.id, "product.remove", params.id, { reason: body.reason.trim() }, { + await audited(me, "product.remove", params.id, { reason: body.reason.trim() }, { product: params.id, person: removed.creatorId, }); @@ -1995,6 +2060,66 @@ 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", @@ -2004,7 +2129,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 }; }, - { 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). .get( @@ -2014,7 +2139,7 @@ export function createApp({ const b = await blocks.get(params.sha256); return { block: b ? blockView(b) : null, products: await blocks.usedBy(params.sha256) }; }, - { signedIn: true, params: blockParam }, + { scripted: true, params: blockParam }, ) .put( "/blocks/:sha256", @@ -2023,14 +2148,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.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) { - 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); } 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( "/blocks/:sha256", @@ -2038,10 +2163,10 @@ export function createApp({ await need(me, "product:remove", hubScope); await productCall(() => blocks.unblock(params.sha256)); 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; }, - { 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). .post( @@ -2050,20 +2175,21 @@ 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.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); 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( "/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.id, "product.retract", params.id, { revision: params.n }); + await audited(me, "product.retract", params.id, { revision: params.n }); 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. .get( @@ -2188,7 +2314,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.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; }, { 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). - 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; 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; 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.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) }; }, - { 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( "/cases/:id/decide", @@ -2509,7 +2635,7 @@ export function createApp({ const c = await productCall(() => 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) }; }, { 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 }) => { 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.id, "person.approve", p.username ?? p.id); + if (!p.approved) await audited(me, "person.approve", p.username ?? p.id); return { person: personView(updated!) }; }, { moderator: true }, @@ -2651,7 +2777,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.id, "person.decline", p.username ?? p.id); + await audited(me, "person.decline", p.username ?? p.id); return { declined: 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."); const from = await settings.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 }; }, { @@ -2689,7 +2815,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.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!) }; }, { 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(); await db.delete(session).where(eq(session.userId, 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!) }; }, { moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) }, @@ -2713,7 +2839,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.id, "person.unban", p.username ?? p.id); + await audited(me, "person.unban", p.username ?? p.id); return { person: personView(updated!) }; }, { moderator: true }, @@ -2725,7 +2851,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.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!) }; }, { diff --git a/apps/api/src/permissions.ts b/apps/api/src/permissions.ts index f1e91ee..66fda33 100644 --- a/apps/api/src/permissions.ts +++ b/apps/api/src/permissions.ts @@ -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 } from "@t5de/db"; +import { and, eq, schema, type Database, type TokenGrant } from "@t5de/db"; 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). */ export const assignableCatalogRoles = ["catalog-operator"] as const; -/** A person, as the check needs them. */ -export type Who = { id: string; role: string }; +/** 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)), + ); +} /** Thrown when a permission is missing; the API answers 403 with what was missing. */ export class Forbidden extends Error { @@ -161,9 +171,8 @@ export class Permissions { /** A person's hub-wide permissions (their user.role). */ hub(who: Who | undefined): Set { - if (who?.role === "admin") return new Set(roles.admin.permissions); - if (who?.role === "moderator") return new Set(roles.moderator.permissions); - return new Set(); + const held = who?.role === "admin" ? roles.admin.permissions : who?.role === "moderator" ? roles.moderator.permissions : []; + return new Set(held.filter((p) => granted(who, p, hubScope))); } /** 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. */ async at(who: Who | undefined, scope: Scope): Promise> { const out = new Set(); - 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; } diff --git a/apps/api/src/tokens.ts b/apps/api/src/tokens.ts new file mode 100644 index 0000000..fd97ad7 --- /dev/null +++ b/apps/api/src/tokens.ts @@ -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(); + 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`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 { + 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; + } +} diff --git a/apps/api/test/tokens.test.ts b/apps/api/test/tokens.test.ts new file mode 100644 index 0000000..15a25e7 --- /dev/null +++ b/apps/api/test/tokens.test.ts @@ -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(); +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(["https://moved.example.test"]); + +let db: Database; +let close: () => Promise; +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 = {}) { + 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 { + 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: "takedowns@example.test", + 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 }, 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 }, 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); +}); diff --git a/apps/web/app/account/actions.ts b/apps/web/app/account/actions.ts index 224cb30..78aa0e9 100644 --- a/apps/web/app/account/actions.ts +++ b/apps/web/app/account/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; 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"; const done = () => revalidatePath("/account"); @@ -71,3 +71,34 @@ export async function startAgeCheck(_: ActionState, form: FormData): Promise { + 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 { + return attempt(async () => { + unwrap(await api.account.tokens({ id: field(form, "id") }).delete()); + done(); + return "Revoked."; + }); +} diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx index b93235f..419c274 100644 --- a/apps/web/app/account/page.tsx +++ b/apps/web/app/account/page.tsx @@ -11,13 +11,18 @@ 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] = 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 params = await searchParams; const welcome = params.welcome !== undefined; @@ -116,6 +121,8 @@ export default async function Account({ searchParams }: { searchParams: Promise< )} + + ); } diff --git a/apps/web/app/account/token-form.tsx b/apps/web/app/account/token-form.tsx new file mode 100644 index 0000000..11122a3 --- /dev/null +++ b/apps/web/app/account/token-form.tsx @@ -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([{ 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 ( +
+ +
+ + + {[7, 30, 90, maxDays].map((d) => ( + + {d === 365 ? "1 year" : `${d} days`} + + ))} + +
+ {rows.map((row, i) => { + const scope = byKey.get(row.scope)!; + return ( +
+
+ 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 ( + + {s.scope.type === "hub" ? "Everything on T5DE" : `${kindOf[s.scope.type]}: ${s.name}`} + + ); + })} + + {rows.length > 1 && ( + + )} +
+
+ {scope.permissions.map((p) => ( + + ))} +
+
+ ); + })} +
+ {unused && ( + + )} + +
+ {state?.error && {state.error}} + {state?.token && ( +
+

+ Your token {state.name}. Copy it now: it won't be shown again. +

+
+ {state.token} + +
+

+ Send it as Authorization: Bearer <token>. +

+
+ )} +
+ ); +} diff --git a/apps/web/app/account/tokens-card.tsx b/apps/web/app/account/tokens-card.tsx new file mode 100644 index 0000000..f68dcd5 --- /dev/null +++ b/apps/web/app/account/tokens-card.tsx @@ -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 ( + + + API Tokens + + For your own scripts. A token can do only what you give it, and never more than you can do yourself. + + + + {active.length > 0 && ( +
    + {active.map((t) => ( + + ))} +
+ )} + {data.scopes.length > 0 ? ( + + ) : ( +

+ Tokens act on what you manage: your products or a catalog's team. You don't manage anything yet. +

+ )} + {past.length > 0 && ( +
+ Revoked and expired +
    + {past.map((t) => ( + + ))} +
+
+ )} +
+
+ ); +} + +function TokenRow({ token: t }: { token: TokenEntry }) { + return ( +
  • +
    + {t.name} + {t.status !== "active" && {t.status}} + + {t.status === "active" && ( + + + Revoke + + + )} +
    +

    + {t.grants.map((g, i) => ( + + {i > 0 && ", "} + {g.permission} on {g.scope.type === "hub" ? "everything" : g.name} + + ))} +

    +

    + Made + {" · "} + {t.status === "revoked" && t.revokedAt ? ( + <> + revoked + + ) : ( + <> + {t.status === "expired" ? "expired" : "expires"} + + )} + {" · "} + {t.lastUsedAt ? ( + <> + last used + + ) : ( + "never used" + )} +

    +
  • + ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 2d2b1ca..ed06509 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -44,6 +44,9 @@ export type AuditEntry = ConsoleData["audit"][number]; export type CaseSummary = Data["cases"][number]; export type CasePage = Data["get"]>; export type NotificationEntry = Data["notifications"][number]; +export type TokenList = Data; +export type TokenEntry = TokenList["tokens"][number]; +export type TokenScope = TokenList["scopes"][number]; export type CatalogPage = Data["get"]>; export type CatalogMember = Data["members"]["get"]>["members"][number]; export type CatalogInvite = Data["invites"]["get"]>["invites"][number]; diff --git a/docs/api.md b/docs/api.md index 00fc7af..b2b63cd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,7 +7,9 @@ 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 `. +cookies. T5DE's proxy calls it directly with `Authorization: Bearer `. 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": "", "message": ""}` with a 4xx/5xx status; `message` can be shown as is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`, diff --git a/docs/console.md b/docs/console.md index 48eeac9..674cc67 100644 --- a/docs/console.md +++ b/docs/console.md @@ -182,6 +182,24 @@ Bearer t5de_pat_`; 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 ". +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) ``` @@ -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 | | `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 | +| `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 | | `POST /api/v1/console/view-as/:person` | (admins) start viewing as someone, read-only | diff --git a/docs/design.md b/docs/design.md index 4bdc2e6..a889a38 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 **done** (2026-11-05); API tokens and two-factor sign-in - next. + reasons, moderator notes or second approval. File blocks and API tokens **done** (2026-11-05); 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. diff --git a/packages/db/drizzle/0014_api_tokens.sql b/packages/db/drizzle/0014_api_tokens.sql new file mode 100644 index 0000000..af17a81 --- /dev/null +++ b/packages/db/drizzle/0014_api_tokens.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0014_snapshot.json b/packages/db/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..ed18493 --- /dev/null +++ b/packages/db/drizzle/meta/0014_snapshot.json @@ -0,0 +1,3500 @@ +{ + "id": "d9f8b65a-98c7-457f-afe7-bc8d097a3741", + "prevId": "f9562d67-64e9-4bf9-965c-b78989e08cfa", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_token": { + "name": "api_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grants": { + "name": "grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_token_hash": { + "name": "api_token_hash", + "columns": [ + { + "expression": "hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_token_user": { + "name": "api_token_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_token_user_id_user_id_fk": { + "name": "api_token_user_id_user_id_fk", + "tableFrom": "api_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_tags": { + "name": "audit_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.case_note": { + "name": "case_note", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "case_id": { + "name": "case_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "by_id": { + "name": "by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "case_note_case": { + "name": "case_note_case", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_note_case_id_moderation_case_id_fk": { + "name": "case_note_case_id_moderation_case_id_fk", + "tableFrom": "case_note", + "tableTo": "moderation_case", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_note_by_id_user_id_fk": { + "name": "case_note_by_id_user_id_fk", + "tableFrom": "case_note", + "tableTo": "user", + "columnsFrom": [ + "by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_rating": { + "name": "max_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + }, + "uploads": { + "name": "uploads", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anyone'" + }, + "mirrors": { + "name": "mirrors", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approve'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_change": { + "name": "catalog_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_change_catalog": { + "name": "catalog_change_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_change_catalog_id_catalog_id_fk": { + "name": "catalog_change_catalog_id_catalog_id_fk", + "tableFrom": "catalog_change", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_invite": { + "name": "catalog_invite", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uses": { + "name": "uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_invite_catalog": { + "name": "catalog_invite_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_invite_catalog_id_catalog_id_fk": { + "name": "catalog_invite_catalog_id_catalog_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_invite_created_by_user_id_fk": { + "name": "catalog_invite_created_by_user_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_member": { + "name": "catalog_member", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite": { + "name": "invite", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_member_user": { + "name": "catalog_member_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_member_catalog_id_catalog_id_fk": { + "name": "catalog_member_catalog_id_catalog_id_fk", + "tableFrom": "catalog_member", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_user_id_user_id_fk": { + "name": "catalog_member_user_id_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_decided_by_user_id_fk": { + "name": "catalog_member_decided_by_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "catalog_member_catalog_id_user_id_pk": { + "name": "catalog_member_catalog_id_user_id_pk", + "columns": [ + "catalog_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_block": { + "name": "file_block", + "schema": "", + "columns": { + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "by_id": { + "name": "by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "file_block_by_id_user_id_fk": { + "name": "file_block_by_id_user_id_fk", + "tableFrom": "file_block", + "tableTo": "user", + "columnsFrom": [ + "by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room": { + "name": "hosted_room", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "room_pid": { + "name": "room_pid", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scene": { + "name": "scene", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_hash": { + "name": "scene_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "moderators": { + "name": "moderators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_owner": { + "name": "hosted_room_owner", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hosted_room_created": { + "name": "hosted_room_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_owner_id_user_id_fk": { + "name": "hosted_room_owner_id_user_id_fk", + "tableFrom": "hosted_room", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_ban": { + "name": "hosted_room_ban", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "by_imvu_id": { + "name": "by_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_ban_n_hosted_room_n_fk": { + "name": "hosted_room_ban_n_hosted_room_n_fk", + "tableFrom": "hosted_room_ban", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_ban_n_imvu_id_pk": { + "name": "hosted_room_ban_n_imvu_id_pk", + "columns": [ + "n", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_favorite": { + "name": "hosted_room_favorite", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_favorite_user_id_user_id_fk": { + "name": "hosted_room_favorite_user_id_user_id_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_favorite_n_hosted_room_n_fk": { + "name": "hosted_room_favorite_n_hosted_room_n_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_favorite_user_id_n_pk": { + "name": "hosted_room_favorite_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_image": { + "name": "hosted_room_image", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_image_n_hosted_room_n_fk": { + "name": "hosted_room_image_n_hosted_room_n_fk", + "tableFrom": "hosted_room_image", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_rating": { + "name": "hosted_room_rating", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_rating_room": { + "name": "hosted_room_rating_room", + "columns": [ + { + "expression": "n", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_rating_user_id_user_id_fk": { + "name": "hosted_room_rating_user_id_user_id_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_rating_n_hosted_room_n_fk": { + "name": "hosted_room_rating_n_hosted_room_n_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_rating_user_id_n_pk": { + "name": "hosted_room_rating_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_visit": { + "name": "hosted_room_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_visit_at": { + "name": "hosted_room_visit_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_visit_user_id_user_id_fk": { + "name": "hosted_room_visit_user_id_user_id_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_visit_n_hosted_room_n_fk": { + "name": "hosted_room_visit_n_hosted_room_n_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_visit_user_id_n_pk": { + "name": "hosted_room_visit_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hub_setting": { + "name": "hub_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moderation_case": { + "name": "moderation_case", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "opened_by": { + "name": "opened_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "moderation_case_status": { + "name": "moderation_case_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "moderation_case_opened_by": { + "name": "moderation_case_opened_by", + "columns": [ + { + "expression": "opened_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "moderation_case_tags": { + "name": "moderation_case_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "moderation_case_opened_by_user_id_fk": { + "name": "moderation_case_opened_by_user_id_fk", + "tableFrom": "moderation_case", + "tableTo": "user", + "columnsFrom": [ + "opened_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "moderation_case_assignee_user_id_fk": { + "name": "moderation_case_assignee_user_id_fk", + "tableFrom": "moderation_case", + "tableTo": "user", + "columnsFrom": [ + "assignee" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "moderation_case_decided_by_user_id_fk": { + "name": "moderation_case_decided_by_user_id_fk", + "tableFrom": "moderation_case", + "tableTo": "user", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notification_user": { + "name": "notification_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "listed": { + "name": "listed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "category": { + "name": "category", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_tags": { + "name": "product_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_info_change": { + "name": "product_info_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "by_id": { + "name": "by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "revert_of": { + "name": "revert_of", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_info_change_product": { + "name": "product_info_change_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_info_change_product_id_product_id_fk": { + "name": "product_info_change_product_id_product_id_fk", + "tableFrom": "product_info_change", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_info_change_by_id_user_id_fk": { + "name": "product_info_change_by_id_user_id_fk", + "tableFrom": "product_info_change", + "tableTo": "user", + "columnsFrom": [ + "by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_info": { + "name": "parent_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_revision_files": { + "name": "product_revision_files", + "columns": [ + { + "expression": "files", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_assignment": { + "name": "role_assignment", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_assignment_scope": { + "name": "role_assignment_scope", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_assignment_user_id_user_id_fk": { + "name": "role_assignment_user_id_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignment_granted_by_user_id_fk": { + "name": "role_assignment_granted_by_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_assignment_user_id_role_scope_type_scope_id_pk": { + "name": "role_assignment_user_id_role_scope_type_scope_id_pk", + "columns": [ + "user_id", + "role", + "scope_type", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room_decoration": { + "name": "room_decoration", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "contents": { + "name": "contents", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_decoration_owner": { + "name": "room_decoration_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index a7f22f2..6d1300a 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1793917069600, "tag": "0013_file_blocks", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1793918993367, + "tag": "0014_api_tokens", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 100cc28..df8593d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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 } from "./schema"; +export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem, TokenGrant } from "./schema"; export * from "drizzle-orm"; const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 745a861..2cba421 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -667,3 +667,25 @@ 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().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)], +);