feat: permissions, a scoped console, and catalog management (#7)
Release / release (push) Successful in 4s
CI / test (push) Successful in 1m8s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 14s

Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first).

**API**
- `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`.
- Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs.
- Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership.
- Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them.
- Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries).

**Website**
- Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes).
- Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs.

**Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser.

Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first).

**API**
- `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`.
- Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs.
- Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership.
- Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them.
- Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries).

**Website**
- Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes).
- Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs.

**Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser.

Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
This commit was merged in pull request #7.
This commit is contained in:
dhkatz authored and dhkatz committed 2026-11-05 13:13:42 +00:00
1 parent ed3a188b82
commit 2cbedd26fc
29 files changed
+5657 -181

No files matched your search

+580 -28
View File
@@ -15,6 +15,7 @@ import {
count, count,
schema, schema,
type Database, type Database,
type AuditTags,
type ParentInfo, type ParentInfo,
type RoomModerator, type RoomModerator,
} from "@t5de/db"; } from "@t5de/db";
@@ -25,7 +26,7 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime"; import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms"; import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys"; import { Signer } from "./keys";
import { ProductError, Products } from "./products"; import { availableTo, ProductError, Products } from "./products";
import { import {
chatId, chatId,
HostedError, HostedError,
@@ -41,8 +42,10 @@ import {
} from "./hosted"; } from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations"; import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
import { Settings } from "./settings"; import { Settings } from "./settings";
import { Catalogs } from "./catalogs";
import { assignableCatalogRoles, Forbidden, hubScope, Permissions, roles as roleBundles, type Scope } from "./permissions";
const { user, session, imvuLink, linkChallenge, audit } = schema; const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product } = schema;
export type Deps = { export type Deps = {
db: Database; db: Database;
@@ -55,6 +58,8 @@ export type Deps = {
apiURL?: string; apiURL?: string;
/** The website's address: providers send people back only to it. */ /** The website's address: providers send people back only to it. */
webURL?: string; webURL?: string;
/** Whether a catalog answers at an address (its /health), checked when a catalog's address changes. */
catalogHealthy?: (url: string) => Promise<boolean>;
/** The hub's settings (default: kept in the database, with no environment defaults). */ /** The hub's settings (default: kept in the database, with no environment defaults). */
settings?: Settings; settings?: Settings;
}; };
@@ -65,11 +70,19 @@ export class Problem extends Error {
readonly status: number, readonly status: number,
readonly slug: string, readonly slug: string,
message: string, message: string,
/** More for the website, e.g. the permission that was missing. */
readonly extra: Record<string, unknown> = {},
) { ) {
super(message); super(message);
} }
} }
/** A missing permission as the API answers it: 403 forbidden, naming the permission and its scope. */
const forbiddenProblem = (e: Forbidden) =>
new Problem(403, "forbidden", e.message, {
missing: { permission: e.permission, scope: e.scope.type === "hub" ? { hub: true } : { [e.scope.type]: e.scope.id } },
});
type Person = typeof user.$inferSelect; type Person = typeof user.$inferSelect;
type Link = typeof imvuLink.$inferSelect; type Link = typeof imvuLink.$inferSelect;
@@ -117,11 +130,14 @@ export function createApp({
apiURL, apiURL,
webURL, webURL,
settings: hubSettings, settings: hubSettings,
catalogHealthy,
}: Deps) { }: Deps) {
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const settings = hubSettings ?? new Settings(db); const settings = hubSettings ?? new Settings(db);
const signer = new Signer(db, apiURL ?? "http://localhost:4101"); const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer, imvu); const products = new Products(db, signer, imvu);
const perms = new Permissions(db);
const catalogs = new Catalogs(db, catalogHealthy);
const rooms = roomsService ?? new Rooms(db, imvu); const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db); const hosted = new HostedRooms(db);
const decorations = new Decorations(db); const decorations = new Decorations(db);
@@ -154,6 +170,13 @@ export function createApp({
hostedVisit: (userId, n, imvuId) => { hostedVisit: (userId, n, imvuId) => {
hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e)); hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e));
}, },
wearable: async (userId, ids) => {
const rows = await db
.select({ id: product.id })
.from(product)
.where(and(inArray(product.id, ids), availableTo(userId)));
return new Set(rows.map((r) => r.id));
},
}); });
const limiter = new Limiter(); const limiter = new Limiter();
@@ -220,8 +243,33 @@ export function createApp({
}; };
const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!; const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!;
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) => /** Writes an audit entry. Tags (who may see it, docs/console.md) default from the action: catalog.*, product.*
db.insert(audit).values({ actorId, action, target, detail }); * and hosted_room.* entries are about their target. */
const audited = (
actorId: string | null,
action: string,
target: string,
detail: Record<string, unknown> = {},
tags?: AuditTags,
) => {
const t: AuditTags = tags ?? {};
if (!tags && target) {
if (action.startsWith("catalog.")) t.catalog = target;
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 });
};
/** Runs a check, turning a missing permission into a Problem. */
const need = async (who: Person, permission: Parameters<Permissions["require"]>[1], scope: Scope) => {
try {
await perms.require(who, permission, scope);
} catch (e) {
if (e instanceof Forbidden) throw forbiddenProblem(e);
throw e;
}
};
/** Who's online that viewer may see: accounts showing their presence (moderators see all). */ /** Who's online that viewer may see: accounts showing their presence (moderators see all). */
const visibleOnline = async (viewer: Person | undefined) => { const visibleOnline = async (viewer: Person | undefined) => {
@@ -298,9 +346,124 @@ export function createApp({
visibility: c.visibility, visibility: c.visibility,
membership: c.membership, membership: c.membership,
status: c.status, status: c.status,
statusReason: c.statusReason,
maxRating: c.maxRating,
uploads: c.uploads,
mirrors: c.mirrors,
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
}); });
const catalogParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
const catalogScope = (id: string): Scope => ({ type: "catalog", id });
const inviteView = (i: typeof schema.catalogInvite.$inferSelect) => ({
code: i.code,
maxUses: i.maxUses,
uses: i.uses,
expiresAt: i.expiresAt?.toISOString() ?? null,
createdAt: i.createdAt.toISOString(),
url: `${(webURL ?? "").replace(/\/+$/, "")}/catalogs/${i.catalogId}?invite=${i.code}`,
});
type ConsoleSection = "overview" | "products" | "catalogs" | "people" | "audit" | "settings";
/** What a person controls: the catalogs they're on the team of, their products, their hosted rooms. */
const consoleScope = async (me: Person) => ({
catalogs: await perms.teamCatalogs(me.id),
products: await perms.ownProducts(me.id),
rooms: (await db.select({ n: hostedRoom.n }).from(hostedRoom).where(eq(hostedRoom.ownerId, me.id))).map((r) => String(r.n)),
});
type AuditQuery = {
catalog?: string;
product?: string;
room?: string;
person?: string;
actor?: string;
action?: string;
q?: string;
from?: string;
to?: string;
before?: number;
limit?: number;
};
/**
* Audit entries a person may see (docs/console.md): moderators all; others those they made, and those tagged
* with a catalog they're on the team of, a product of theirs or a hosted room of theirs. Newest first.
*/
const auditFor = async (me: Person, scope: Awaited<ReturnType<typeof consoleScope>>, f: AuditQuery) => {
const conds = [];
if (!isModerator(me)) {
const visible = [eq(audit.actorId, me.id)];
if (scope.catalogs.length) visible.push(inArray(sql`${audit.tags}->>'catalog'`, scope.catalogs));
if (scope.products.length) visible.push(inArray(sql`${audit.tags}->>'product'`, scope.products));
if (scope.rooms.length) visible.push(inArray(sql`${audit.tags}->>'room'`, scope.rooms));
conds.push(or(...visible)!);
}
for (const k of ["catalog", "product", "room"] as const) if (f[k]) conds.push(sql`${audit.tags}->>${k} = ${f[k]}`);
if (f.person) {
const [p] = await db.select({ id: user.id }).from(user).where(sql`lower(${user.username}) = lower(${f.person})`);
conds.push(p ? or(sql`${audit.tags}->>'person' = ${p.id}`, eq(audit.actorId, p.id))! : sql`false`);
}
if (f.actor) {
const [p] = await db.select({ id: user.id }).from(user).where(sql`lower(${user.username}) = lower(${f.actor})`);
conds.push(p ? eq(audit.actorId, p.id) : sql`false`);
}
const esc = (v: string) => v.replace(/[%_\\]/g, (c) => "\\" + c);
if (f.action) conds.push(ilike(audit.action, `${esc(f.action)}%`));
if (f.q) {
const like = `%${esc(f.q)}%`;
conds.push(or(ilike(audit.action, like), ilike(audit.target, like), sql`${audit.detail}::text ilike ${like}`)!);
}
const day = (v: string | undefined) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined);
if (day(f.from)) conds.push(sql`${audit.at} >= ${day(f.from)}::date`);
if (day(f.to)) conds.push(sql`${audit.at} < ${day(f.to)}::date + 1`);
if (f.before) conds.push(sql`${audit.id} < ${f.before}`);
const rows = await db
.select({
id: audit.id,
at: audit.at,
actor: user.username,
action: audit.action,
target: audit.target,
tags: audit.tags,
detail: audit.detail,
})
.from(audit)
.leftJoin(user, eq(user.id, audit.actorId))
.where(conds.length ? and(...conds) : undefined)
.orderBy(desc(audit.id))
.limit(Math.min(f.limit ?? 100, 200));
// Names for the tags, so the console can show and link them.
const ids = (k: keyof AuditTags) => [...new Set(rows.map((r) => r.tags[k]).filter((v): v is string => !!v))];
const [cs, ps, people] = await Promise.all([
ids("catalog").length ? db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, ids("catalog"))) : [],
ids("product").length ? db.select({ id: product.id, name: product.name }).from(product).where(inArray(product.id, ids("product"))) : [],
ids("person").length
? db.select({ id: user.id, name: user.username }).from(user).where(inArray(user.id, ids("person")))
: [],
]);
const names: Record<string, string> = {};
for (const x of [...cs, ...ps, ...people]) names[x.id] = x.name ?? "";
return rows.map((r) => ({
id: r.id,
at: r.at.toISOString(),
actor: r.actor ?? "",
action: r.action,
target: r.target,
tags: r.tags,
tagNames: Object.fromEntries(Object.values(r.tags).filter((v): v is string => !!v && names[v] !== undefined).map((v) => [v, names[v]!])),
detail: r.detail,
}));
};
const personByUsername = async (username: string) => {
const [p] = await db.select().from(user).where(sql`lower(${user.username}) = lower(${username.trim()})`);
if (!p) throw new Problem(404, "not_found", `Nobody on T5DE is called ${username.trim()}.`);
return p;
};
/** Runs a hosted-room operation, turning its errors into Problems. */ /** Runs a hosted-room operation, turning its errors into Problems. */
const hostedCall = async <T>(f: () => Promise<T>): Promise<T> => { const hostedCall = async <T>(f: () => Promise<T>): Promise<T> => {
try { try {
@@ -452,7 +615,7 @@ export function createApp({
.onError(({ code, error, set }) => { .onError(({ code, error, set }) => {
if (code === "Problem") { if (code === "Problem") {
set.status = error.status; set.status = error.status;
return { error: error.slug, message: error.message }; return { error: error.slug, message: error.message, ...error.extra };
} }
if (code === "VALIDATION") { if (code === "VALIDATION") {
set.status = 400; set.status = 400;
@@ -501,7 +664,19 @@ export function createApp({
}) })
// Who's signed in, and the home page. // Who's signed in, and the home page.
.get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { account: true }) .get(
"/me",
async ({ me, person }) => {
// Whether the console is for them: moderators, and anyone controlling a catalog, product or room.
let console = false;
if (person) {
const scope = await consoleScope(person);
console = isModerator(person) || scope.catalogs.length + scope.products.length + scope.rooms.length > 0;
}
return { person: { ...personView(me), picture: await picture(me.id), console } };
},
{ account: true },
)
// The hub's public settings: whether new accounts wait for approval. // The hub's public settings: whether new accounts wait for approval.
.get("/hub", async () => ({ registration: await settings.registration() })) .get("/hub", async () => ({ registration: await settings.registration() }))
.get("/home", async ({ person }) => { .get("/home", async ({ person }) => {
@@ -1104,6 +1279,298 @@ export function createApp({
catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })), catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })),
})) }))
// Managing a catalog (docs/products.md; permissions in docs/console.md).
.get(
"/catalogs/:id",
async ({ person, params }) => {
const c = await productCall(() => catalogs.get(params.id));
const held = [...(await perms.at(person, catalogScope(c.id)))];
const team = held.includes("audit:read");
if (c.status !== "approved" && !team) throw new Problem(404, "not_found", "No such catalog.");
return {
catalog: { ...catalogView(c), mine: c.ownerId === person?.id },
membership: person ? ((await catalogs.membership(c.id, person.id)) ?? null) : null,
permissions: held,
roles: await perms.rolesAt(person, catalogScope(c.id)),
hosted: await catalogs.hostedCounts(c.id),
members: team ? await catalogs.memberCounts(c.id) : null,
};
},
{ params: catalogParam },
)
.patch(
"/catalogs/:id",
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 });
return { catalog: catalogView(r.catalog) };
},
{
signedIn: true,
params: catalogParam,
body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })),
url: t.Optional(t.String({ maxLength: 300 })),
contact: t.Optional(t.String({ maxLength: 200 })),
visibility: t.Optional(t.Union([t.Literal("public"), t.Literal("private")])),
membership: t.Optional(t.Union([t.Literal("open"), t.Literal("approval"), t.Literal("invite")])),
maxRating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])),
uploads: t.Optional(t.Union([t.Literal("anyone"), t.Literal("members"), t.Literal("team")])),
mirrors: t.Optional(t.Union([t.Literal("auto"), t.Literal("approve"), t.Literal("off")])),
}),
},
)
.post(
"/catalogs/:id/key",
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);
return { key };
},
{ signedIn: true, params: catalogParam },
)
.post(
"/catalogs/:id/suspend",
async ({ me, params, body }) => {
await need(me, "catalog:suspend", catalogScope(params.id));
const c = await productCall(() => catalogs.setSuspended(params.id, true, body.reason.trim()));
await audited(me.id, "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 }) }) },
)
.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 });
return { catalog: catalogView(c) };
},
{ signedIn: true, params: catalogParam },
)
// A catalog's team: the owner, and the roles it gave.
.get(
"/catalogs/:id/team",
async ({ me, params }) => {
await need(me, "audit:read", catalogScope(params.id));
const team = await perms.team(params.id);
return {
...team,
assignable: assignableCatalogRoles.map((r) => ({
id: r,
name: roleBundles[r].name,
permissions: [...roleBundles[r].permissions],
})),
};
},
{ signedIn: true, params: catalogParam },
)
.put(
"/catalogs/:id/team/:username",
async ({ me, params, body }) => {
const scope = catalogScope(params.id);
await need(me, "team:manage", scope);
if (!(await perms.mayGive(me, body.role, scope)))
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 });
return { team: await perms.team(params.id) };
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
body: t.Object({ role: t.Literal("catalog-operator") }),
},
)
.delete(
"/catalogs/:id/team/:username",
async ({ me, params, query }) => {
const scope = catalogScope(params.id);
const p = await personByUsername(params.username);
// 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 });
return { team: await perms.team(params.id) };
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
query: t.Object({ role: t.Literal("catalog-operator") }),
},
)
// Membership: joining and leaving; the team's view and decisions; invitations.
.post(
"/catalogs/:id/join",
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 } : {}, {
catalog: params.id,
person: me.id,
});
if (status === "member") realtime.notify(me.id, { type: "library" });
return { membership: status };
},
{
signedIn: true,
params: catalogParam,
body: t.Optional(t.Object({ invite: t.Optional(t.String({ maxLength: 32 })) })),
},
)
.delete(
"/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 });
realtime.notify(me.id, { type: "library" });
set.status = 204;
},
{ signedIn: true, params: catalogParam },
)
.get(
"/catalogs/:id/members",
async ({ me, params, query }) => {
await need(me, "member:approve", catalogScope(params.id));
return { members: await catalogs.members(params.id, query.status), counts: await catalogs.memberCounts(params.id) };
},
{
signedIn: true,
params: catalogParam,
query: t.Object({
status: t.Optional(t.Union([t.Literal("requested"), t.Literal("member"), t.Literal("banned")])),
}),
},
)
.put(
"/catalogs/:id/members/:username",
async ({ me, params, body }) => {
await need(me, body.status === "banned" ? "member:ban" : "member:approve", catalogScope(params.id));
const p = await personByUsername(params.username);
const before = await catalogs.membership(params.id, p.id);
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 } : {}) }, {
catalog: params.id,
person: p.id,
});
realtime.notify(p.id, { type: "library" });
return { membership: body.status };
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
body: t.Object({ status: t.Union([t.Literal("member"), t.Literal("banned")]) }),
},
)
.delete(
"/catalogs/:id/members/:username",
async ({ me, params, set }) => {
await need(me, "member:remove", catalogScope(params.id));
const p = await personByUsername(params.username);
const before = await catalogs.membership(params.id, p.id);
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 });
realtime.notify(p.id, { type: "library" });
set.status = 204;
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
},
)
.get(
"/catalogs/:id/invites",
async ({ me, params }) => {
await need(me, "member:invite", catalogScope(params.id));
return { invites: (await catalogs.invites(params.id)).map(inviteView) };
},
{ signedIn: true, params: catalogParam },
)
.post(
"/catalogs/:id/invites",
async ({ me, params, body, set }) => {
await need(me, "member:invite", catalogScope(params.id));
const inv = await productCall(() => catalogs.createInvite(params.id, me.id, body));
await audited(me.id, "catalog.invite_create", params.id, {
maxUses: inv.maxUses,
expiresAt: inv.expiresAt?.toISOString() ?? null,
});
set.status = 201;
return { invite: inviteView(inv) };
},
{
signedIn: true,
params: catalogParam,
body: t.Object({
maxUses: t.Optional(t.Integer({ minimum: 1, maximum: 10_000 })),
expiresInDays: t.Optional(t.Integer({ minimum: 1, maximum: 365 })),
}),
},
)
.delete(
"/catalogs/:id/invites/:code",
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) + "…" });
set.status = 204;
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), code: t.String({ maxLength: 32 }) }),
},
)
// What a catalog hosts, and delisting.
.get(
"/catalogs/:id/products",
async ({ me, params }) => {
await need(me, "audit:read", catalogScope(params.id));
return { products: await catalogs.hosted(params.id) };
},
{ signedIn: true, params: catalogParam },
)
.delete(
"/catalogs/:id/products/:product",
async ({ me, params, query }) => {
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 }, {
catalog: params.id,
product: params.product,
});
return { revisions };
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), product: t.String({ pattern: "^[a-z2-7]{10}$" }) }),
query: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }),
},
)
// For catalogs: what to act on (files to delete, revisions to mirror, its status). A suspended catalog still
// reads it, to learn that it's suspended.
.get(
"/catalog/changes",
async ({ request, query }) => {
const c = await products.catalogFor(request.headers.get("authorization"), true);
if (!c) throw new Problem(401, "forbidden", "Not an approved catalog.");
return catalogs.changes(c.id, query.after ?? 0);
},
{ query: t.Object({ after: t.Optional(t.Numeric({ minimum: 0, multipleOf: 1 })) }) },
)
// For catalogs: registering an uploaded revision. // For catalogs: registering an uploaded revision.
.post( .post(
"/catalog/revisions", "/catalog/revisions",
@@ -1170,6 +1637,14 @@ export function createApp({
inLibrary: lib !== undefined, inLibrary: lib !== undefined,
stayOn: lib?.stayOn ?? null, stayOn: lib?.stayOn ?? null,
mine: person?.id === p.product.creatorId, mine: person?.id === p.product.creatorId,
available: person ? await products.available(person.id, p.product.id) : false,
catalogs: await Promise.all(
(await products.hostingCatalogs(p.product.id)).map(async (c) => ({
...c,
membership: c.membership as "open" | "approval" | "invite",
joined: person ? ((await catalogs.membership(c.id, person.id)) ?? null) : null,
})),
),
}; };
}, },
{ params: productParam }, { params: productParam },
@@ -1262,7 +1737,14 @@ export function createApp({
.put( .put(
"/library/:id", "/library/:id",
async ({ me, params }) => { async ({ me, params }) => {
await productCall(async () => products.add(me.id, params.id, await personAdult(me))); try {
await products.add(me.id, params.id, await personAdult(me));
} catch (e) {
if (e instanceof ProductError && e.slug === "members_only")
throw new Problem(e.status, e.slug, e.message, { catalogs: await products.hostingCatalogs(params.id) });
if (e instanceof ProductError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
realtime.notify(me.id, { type: "library" }); realtime.notify(me.id, { type: "library" });
return { ok: true }; return { ok: true };
}, },
@@ -1388,8 +1870,17 @@ export function createApp({
// The console. // The console.
.get( .get(
"/console/catalogs", "/console/catalogs",
async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }), async ({ me }) => {
{ moderator: true }, const mod = isModerator(me);
const mine = await perms.teamCatalogs(me.id);
if (!mod && mine.length === 0) throw new Problem(403, "forbidden", "You're not on any catalog's team.");
const rows = mod ? await products.allCatalogs() : await db.select().from(catalog).where(inArray(catalog.id, mine));
return {
catalogs: rows.map((c) => ({ ...catalogView(c), mine: c.ownerId === me.id, team: mine.includes(c.id) })),
moderator: mod,
};
},
{ signedIn: true },
) )
.post( .post(
"/console/catalogs/:id/:decision", "/console/catalogs/:id/:decision",
@@ -1405,28 +1896,89 @@ export function createApp({
.get( .get(
"/console", "/console",
async ({ me }) => { async ({ me }) => {
const [counts] = await db const mod = isModerator(me);
.select({ const scope = await consoleScope(me);
people: sql<number>`(select count(*) from ${user})::int`, const sections: ConsoleSection[] = ["overview"];
waiting: sql<number>`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`, if (mod || scope.products.length) sections.push("products");
links: sql<number>`(select count(*) from ${imvuLink})::int`, if (mod || scope.catalogs.length) sections.push("catalogs");
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`, if (mod) sections.push("people");
}) if (sections.length > 1 || scope.rooms.length) sections.push("audit");
.from(sql`(select 1) as one`); if (me.role === "admin") sections.push("settings");
const log = await db if (sections.length === 1)
.select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail }) throw new Problem(403, "forbidden", "The console is for people who manage something on T5DE: a product, a catalog or a room.");
.from(audit) const [requests] = scope.catalogs.length
.leftJoin(user, eq(user.id, audit.actorId)) ? await db
.orderBy(desc(audit.id)) .select({ n: count() })
.limit(50); .from(schema.catalogMember)
.where(and(inArray(schema.catalogMember.catalogId, scope.catalogs), eq(schema.catalogMember.status, "requested")))
: [{ n: 0 }];
let moderation = null;
if (mod) {
const [counts] = await db
.select({
people: sql<number>`(select count(*) from ${user})::int`,
waiting: sql<number>`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`,
links: sql<number>`(select count(*) from ${imvuLink})::int`,
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`,
pendingCatalogs: sql<number>`(select count(*) from ${catalog} where ${catalog.status} = 'pending')::int`,
})
.from(sql`(select 1) as one`);
moderation = { counts: counts!, connections: realtime.connections(), online: await visibleOnline(me) };
}
return { return {
counts: counts!, sections,
connections: realtime.connections(), mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length },
online: await visibleOnline(me), todo: {
audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })), memberRequests: requests?.n ?? 0,
pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0,
waitingPeople: moderation?.counts.waiting ?? 0,
},
moderation,
audit: await auditFor(me, scope, { limit: 20 }),
}; };
}, },
{ moderator: true }, { signedIn: true },
)
.get(
"/console/audit",
async ({ me, query }) => {
const scope = await consoleScope(me);
if (!isModerator(me) && !scope.catalogs.length && !scope.products.length && !scope.rooms.length)
throw new Problem(403, "forbidden", "The console is for people who manage something on T5DE.");
const entries = await auditFor(me, scope, query);
return { entries, before: entries.length ? entries[entries.length - 1]!.id : null };
},
{
signedIn: true,
query: t.Object({
catalog: t.Optional(t.String({ maxLength: 32 })),
product: t.Optional(t.String({ maxLength: 32 })),
room: t.Optional(t.String({ maxLength: 32 })),
person: t.Optional(t.String({ maxLength: 64 })),
actor: t.Optional(t.String({ maxLength: 64 })),
action: t.Optional(t.String({ maxLength: 64 })),
q: t.Optional(t.String({ maxLength: 100 })),
from: t.Optional(t.String({ maxLength: 32 })),
to: t.Optional(t.String({ maxLength: 32 })),
before: t.Optional(t.Numeric({ minimum: 1, multipleOf: 1 })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
}),
},
)
.get(
"/console/permissions",
async ({ me, query }) => {
const scope: Scope =
query.catalog ? { type: "catalog", id: query.catalog } : query.product ? { type: "product", id: query.product } : hubScope;
return { roles: await perms.rolesAt(me, scope), permissions: [...(await perms.at(me, scope))] };
},
{
signedIn: true,
query: t.Object({
catalog: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
product: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
}),
},
) )
.get( .get(
"/console/people", "/console/people",
+387
View File
@@ -0,0 +1,387 @@
// Managing catalogs (docs/products.md, docs/console.md): settings, keys, teams, members, invites, what they host,
// suspension, and the changes feed catalogs poll. Permission checks are the routes'; this is what they do.
import { createHash } from "node:crypto";
import { and, asc, count, desc, eq, gt, inArray, isNull, or, sql, schema, type Database } from "@t5de/db";
import { ProductError } from "./products";
const { catalog, catalogChange, catalogInvite, catalogMember, hosting, product, productRevision, roleAssignment, user } =
schema;
export type Catalog = typeof catalog.$inferSelect;
export type MemberStatus = "requested" | "member" | "banned";
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
// Invite codes: 10 letters and digits without look-alikes.
const inviteCode = () => {
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
return [...crypto.getRandomValues(new Uint8Array(10))].map((b) => alphabet[b % alphabet.length]).join("");
};
const plain = (s: unknown, max: number) => {
const v = typeof s === "string" ? s.trim() : "";
// eslint-disable-next-line no-control-regex
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
return v;
};
/** A catalog's address, checked and without a trailing slash (https, or localhost for development). */
export function catalogURL(input: string): string {
let url: URL;
try {
url = new URL(input);
} catch {
throw new ProductError(400, "invalid", "That address isn't a URL.");
}
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
throw new ProductError(400, "invalid", "Catalogs need an https address.");
return url.origin + url.pathname.replace(/\/+$/, "");
}
export type CatalogSettings = {
name?: string;
url?: string;
contact?: string;
visibility?: "public" | "private";
membership?: "open" | "approval" | "invite";
maxRating?: "general" | "adult";
uploads?: "anyone" | "members" | "team";
mirrors?: "auto" | "approve" | "off";
};
export class Catalogs {
constructor(
private readonly db: Database,
/** Whether a catalog answers at an address (its /health); checked when its address changes. */
private readonly healthy: (url: string) => Promise<boolean> = defaultHealthy,
) {}
async get(id: string): Promise<Catalog> {
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, id));
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
return c;
}
/** Changes a catalog's settings; returns the catalog and the fields that changed (old and new values). */
async update(id: string, input: CatalogSettings) {
const c = await this.get(id);
const set: Partial<Catalog> = {};
if (input.name !== undefined) {
const name = plain(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
set.name = name;
}
if (input.contact !== undefined) {
const contact = plain(input.contact, 200);
if (!contact) throw new ProductError(400, "invalid", "A takedown contact is needed.");
set.contact = contact;
}
if (input.url !== undefined) {
const url = catalogURL(input.url);
if (url !== c.url) {
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, url));
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
if (!(await this.healthy(url)))
throw new ProductError(400, "unreachable", "Nothing answered at that address's /health. Is the catalog running there?");
set.url = url;
}
}
for (const k of ["visibility", "membership", "maxRating", "uploads", "mirrors"] as const)
if (input[k] !== undefined) (set as Record<string, unknown>)[k] = input[k];
const changed: Record<string, { from: unknown; to: unknown }> = {};
for (const [k, v] of Object.entries(set)) {
const before = (c as Record<string, unknown>)[k];
if (before !== v) changed[k] = { from: before, to: v };
}
if (Object.keys(changed).length === 0) return { catalog: c, changed };
const [updated] = await this.db.update(catalog).set(set).where(eq(catalog.id, id)).returning();
return { catalog: updated!, changed };
}
/** A new key for the catalog; the old one stops working at once. */
async rotateKey(id: string): Promise<string> {
await this.get(id);
const key = randomKey();
await this.db.update(catalog).set({ keyHash: sha256(key) }).where(eq(catalog.id, id));
return key;
}
/** Suspends or reinstates an approved catalog; tells it through its changes feed. */
async setSuspended(id: string, suspended: boolean, reason: string) {
const c = await this.get(id);
if (suspended && c.status !== "approved")
throw new ProductError(409, "not_approved", "Only an approved catalog can be suspended.");
if (!suspended && c.status !== "suspended") throw new ProductError(409, "not_suspended", "That catalog isn't suspended.");
const status = suspended ? "suspended" : "approved";
const [updated] = await this.db
.update(catalog)
.set({ status, statusReason: suspended ? reason : "" })
.where(eq(catalog.id, id))
.returning();
await this.change(id, { type: "status", status });
return updated!;
}
// ---- teams ----
async addRole(catalogId: string, personId: string, role: string, by: string) {
const c = await this.get(catalogId);
if (c.ownerId === personId) throw new ProductError(409, "owner", "That's the catalog's owner.");
await this.db
.insert(roleAssignment)
.values({ userId: personId, role, scopeType: "catalog", scopeId: catalogId, grantedBy: by })
.onConflictDoNothing();
}
async removeRole(catalogId: string, personId: string, role: string) {
const gone = await this.db
.delete(roleAssignment)
.where(
and(
eq(roleAssignment.userId, personId),
eq(roleAssignment.role, role),
eq(roleAssignment.scopeType, "catalog"),
eq(roleAssignment.scopeId, catalogId),
),
)
.returning();
if (gone.length === 0) throw new ProductError(404, "not_found", "They don't have that role here.");
}
// ---- members ----
async membership(catalogId: string, personId: string): Promise<MemberStatus | undefined> {
const [m] = await this.db
.select({ status: catalogMember.status })
.from(catalogMember)
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)));
return m?.status as MemberStatus | undefined;
}
/**
* A person joins: an open catalog at once, an approval one as a request, an invite one only with an invite (which
* also lets them into approval catalogs at once). Returns their status.
*/
async join(catalogId: string, personId: string, invite?: string): Promise<MemberStatus> {
const c = await this.get(catalogId);
if (c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
const now = await this.membership(catalogId, personId);
if (now === "banned") throw new ProductError(403, "banned", "This catalog's team banned you from it.");
if (now === "member") return "member";
let status: MemberStatus;
let usedInvite: string | null = null;
if (invite) {
const updated = await this.db
.update(catalogInvite)
.set({ uses: sql`${catalogInvite.uses} + 1` })
.where(
and(
eq(catalogInvite.code, invite),
eq(catalogInvite.catalogId, catalogId),
isNull(catalogInvite.revokedAt),
or(isNull(catalogInvite.expiresAt), gt(catalogInvite.expiresAt, new Date())),
or(isNull(catalogInvite.maxUses), sql`${catalogInvite.uses} < ${catalogInvite.maxUses}`),
),
)
.returning();
if (updated.length === 0) throw new ProductError(410, "invite_invalid", "That invitation is used up, expired or revoked.");
status = "member";
usedInvite = invite;
} else if (c.membership === "open") status = "member";
else if (c.membership === "approval") status = "requested";
else throw new ProductError(403, "invite_only", "This catalog takes members by invitation only.");
await this.db
.insert(catalogMember)
.values({ catalogId, userId: personId, status, invite: usedInvite })
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, invite: usedInvite } });
return status;
}
/** A person leaves (or withdraws their request). A ban stays. */
async leave(catalogId: string, personId: string) {
await this.db
.delete(catalogMember)
.where(
and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId), sql`${catalogMember.status} <> 'banned'`),
);
}
async members(catalogId: string, status?: MemberStatus) {
const conds = [eq(catalogMember.catalogId, catalogId)];
if (status) conds.push(eq(catalogMember.status, status));
const rows = await this.db
.select({
personId: catalogMember.userId,
username: user.username,
name: user.displayUsername,
status: catalogMember.status,
invite: catalogMember.invite,
since: catalogMember.updatedAt,
})
.from(catalogMember)
.innerJoin(user, eq(user.id, catalogMember.userId))
.where(and(...conds))
.orderBy(desc(catalogMember.updatedAt))
.limit(500);
return rows.map((r) => ({
personId: r.personId,
username: r.name ?? r.username ?? "",
status: r.status as MemberStatus,
invited: r.invite !== null,
since: r.since.toISOString(),
}));
}
async memberCounts(catalogId: string) {
const rows = await this.db
.select({ status: catalogMember.status, n: count() })
.from(catalogMember)
.where(eq(catalogMember.catalogId, catalogId))
.groupBy(catalogMember.status);
const get = (s: string) => rows.find((r) => r.status === s)?.n ?? 0;
return { members: get("member"), requests: get("requested"), banned: get("banned") };
}
/** The team sets a person's membership: let them in, or ban them. */
async setMember(catalogId: string, personId: string, status: "member" | "banned", by: string) {
await this.get(catalogId);
await this.db
.insert(catalogMember)
.values({ catalogId, userId: personId, status, decidedBy: by })
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, decidedBy: by } });
}
async removeMember(catalogId: string, personId: string) {
const gone = await this.db
.delete(catalogMember)
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)))
.returning();
if (gone.length === 0) throw new ProductError(404, "not_found", "They aren't a member, and haven't asked to be.");
}
// ---- invites ----
async createInvite(catalogId: string, by: string, opts: { maxUses?: number; expiresInDays?: number }) {
await this.get(catalogId);
const [inv] = await this.db
.insert(catalogInvite)
.values({
code: inviteCode(),
catalogId,
createdBy: by,
maxUses: opts.maxUses ?? null,
expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86_400_000) : null,
})
.returning();
return inv!;
}
async invites(catalogId: string) {
return this.db
.select()
.from(catalogInvite)
.where(and(eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt)))
.orderBy(desc(catalogInvite.createdAt));
}
async revokeInvite(catalogId: string, code: string) {
const r = await this.db
.update(catalogInvite)
.set({ revokedAt: new Date() })
.where(and(eq(catalogInvite.catalogId, catalogId), eq(catalogInvite.code, code), isNull(catalogInvite.revokedAt)))
.returning();
if (r.length === 0) throw new ProductError(404, "not_found", "No such invitation.");
}
// ---- what it hosts ----
/** The products a catalog hosts, newest first, with the revisions it hosts of each. */
async hosted(catalogId: string) {
const rows = await this.db
.select({
id: product.id,
name: product.name,
rating: product.rating,
creator: user.displayUsername,
creatorUsername: user.username,
removed: product.removedAt,
deleted: product.deletedAt,
revisions: sql<number[]>`array_agg(${hosting.number} order by ${hosting.number})`,
since: sql<string>`min(${hosting.createdAt})`,
})
.from(hosting)
.innerJoin(product, eq(product.id, hosting.productId))
.innerJoin(user, eq(user.id, product.creatorId))
.where(eq(hosting.catalogId, catalogId))
.groupBy(product.id, user.id)
.orderBy(desc(sql`min(${hosting.createdAt})`))
.limit(500);
return rows.map((r) => ({
id: r.id,
name: r.name,
rating: r.rating,
creator: r.creator ?? r.creatorUsername ?? "",
removed: r.removed !== null,
deleted: r.deleted !== null,
revisions: r.revisions,
since: new Date(r.since).toISOString(),
}));
}
async hostedCounts(catalogId: string) {
const [r] = await this.db
.select({
products: sql<number>`count(distinct ${hosting.productId})::int`,
revisions: sql<number>`count(*)::int`,
bytes: sql<number>`coalesce(sum((select sum((f->>'size')::bigint) from jsonb_array_elements(${productRevision.files}) f)), 0)::bigint`,
})
.from(hosting)
.innerJoin(
productRevision,
and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)),
)
.where(eq(hosting.catalogId, catalogId));
return { products: r?.products ?? 0, revisions: r?.revisions ?? 0, bytes: Number(r?.bytes ?? 0) };
}
/** The catalog stops hosting every revision of a product; its files go in the changes feed for deletion. */
async delist(catalogId: string, productId: string) {
const gone = await this.db
.delete(hosting)
.where(and(eq(hosting.catalogId, catalogId), eq(hosting.productId, productId)))
.returning();
if (gone.length === 0) throw new ProductError(404, "not_found", "This catalog doesn't host that product.");
const revs = await this.db
.select({ number: productRevision.number, files: productRevision.files })
.from(productRevision)
.where(and(eq(productRevision.productId, productId), inArray(productRevision.number, gone.map((g) => g.number))));
for (const r of revs) await this.change(catalogId, { type: "delete", product: productId, revision: r.number, files: r.files });
return gone.map((g) => g.number);
}
// ---- the changes feed ----
async change(catalogId: string, data: Record<string, unknown>) {
await this.db.insert(catalogChange).values({ catalogId, data });
}
/** Changes after a cursor, oldest first (at most 500 at a time). */
async changes(catalogId: string, after: number) {
const rows = await this.db
.select()
.from(catalogChange)
.where(and(eq(catalogChange.catalogId, catalogId), gt(catalogChange.id, after)))
.orderBy(asc(catalogChange.id))
.limit(500);
return { changes: rows.map((r) => r.data), cursor: rows.length ? rows[rows.length - 1]!.id : after };
}
}
async function defaultHealthy(url: string): Promise<boolean> {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
return res.ok;
} catch {
return false;
}
}
+259
View File
@@ -0,0 +1,259 @@
// 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";
const { catalog, product, roleAssignment, user } = schema;
export const permissions = [
"product:edit",
"product:edit-locked",
"product:lock",
"product:rate-lower",
"product:publish",
"product:delete",
"product:restore",
"product:remove",
"product:unremove",
"product:transfer",
"revision:upload",
"revision:retract",
"catalog:approve",
"catalog:edit",
"catalog:rotate-key",
"catalog:suspend",
"catalog:delete-request",
"catalog:delete-review",
"catalog:transfer",
"team:manage",
"member:approve",
"member:invite",
"member:remove",
"member:ban",
"hosting:choose",
"hosting:delist",
"import:grant",
"import:grant-on-behalf",
"import:accept",
"import:run",
"import:pause",
"import:withdraw",
"case:read",
"case:resolve",
"case:assign",
"audit:read",
"audit:read-sensitive",
"stats:read",
"person:approve",
"person:ban",
"person:link-cap",
"person:notes",
"person:role",
"person:view-as",
"room:edit",
"room:close",
"room:kick",
"room:ban",
"room:mute",
"settings:edit",
] as const;
export type Permission = (typeof permissions)[number];
/** Where a permission applies. The hub covers everything. */
export type Scope = { type: "hub" } | { type: "catalog"; id: string } | { type: "product"; id: string };
export const hubScope: Scope = { type: "hub" };
const all = (prefix: string) => permissions.filter((p) => p.startsWith(prefix + ":"));
const without = (list: readonly Permission[], ...drop: Permission[]) => list.filter((p) => !drop.includes(p));
const catalogOwner: Permission[] = [
"catalog:edit",
"catalog:rotate-key",
"catalog:delete-request",
"catalog:transfer",
"team:manage",
...all("member"),
"hosting:delist",
"import:grant",
"import:accept",
"import:run",
"import:pause",
"import:withdraw",
"case:read",
"case:resolve",
"audit:read",
"stats:read",
];
/** The built-in roles: name, the scope they're given at, and their permissions. */
export const roles = {
admin: { name: "Admin", scope: "hub", permissions: [...permissions] as Permission[] },
moderator: {
name: "Moderator",
scope: "hub",
permissions: [
...without(all("product"), "product:transfer"),
"revision:retract",
"catalog:approve",
"catalog:suspend",
"catalog:delete-review",
"hosting:delist",
"import:grant-on-behalf",
"import:withdraw",
"import:pause",
...all("case"),
"person:approve",
"person:ban",
"person:link-cap",
"person:notes",
"audit:read",
"stats:read",
...all("room"),
] as Permission[],
},
"catalog-owner": { name: "Owner", scope: "catalog", permissions: catalogOwner },
"catalog-operator": {
name: "Operator",
scope: "catalog",
permissions: without(catalogOwner, "catalog:delete-request", "catalog:transfer", "team:manage"),
},
creator: {
name: "Creator",
scope: "product",
permissions: [
"product:edit",
"product:publish",
"product:delete",
"product:restore",
"product:transfer",
"revision:upload",
"revision:retract",
"hosting:choose",
"case:read",
"audit:read",
"stats:read",
] as Permission[],
},
} as const satisfies Record<string, { name: string; scope: string; permissions: Permission[] }>;
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 };
/** Thrown when a permission is missing; the API answers 403 with what was missing. */
export class Forbidden extends Error {
constructor(
readonly permission: Permission,
readonly scope: Scope,
) {
super("You don't have permission to do that.");
}
}
export class Permissions {
constructor(private readonly db: Database) {}
/** A person's hub-wide permissions (their user.role). */
hub(who: Who | undefined): Set<Permission> {
if (who?.role === "admin") return new Set(roles.admin.permissions);
if (who?.role === "moderator") return new Set(roles.moderator.permissions);
return new Set();
}
/** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */
async rolesAt(who: Who | undefined, scope: Scope): Promise<RoleId[]> {
if (!who) return [];
const out: RoleId[] = [];
if (who.role === "admin") out.push("admin");
else if (who.role === "moderator") out.push("moderator");
if (scope.type === "catalog") {
const [c] = await this.db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, scope.id));
if (c?.ownerId === who.id) out.push("catalog-owner");
const given = await this.db
.select({ role: roleAssignment.role })
.from(roleAssignment)
.where(
and(eq(roleAssignment.userId, who.id), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, scope.id)),
);
for (const g of given) if (g.role in roles) out.push(g.role as RoleId);
} else if (scope.type === "product") {
const [p] = await this.db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, scope.id));
if (p?.creatorId === who.id) out.push("creator");
}
return out;
}
/** Every permission a person holds at a scope. */
async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> {
const out = new Set<Permission>();
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) out.add(p);
return out;
}
async can(who: Who | undefined, permission: Permission, scope: Scope): Promise<boolean> {
if (this.hub(who).has(permission)) return true;
if (scope.type === "hub") return false;
return (await this.at(who, scope)).has(permission);
}
/** Throws Forbidden unless the person holds the permission at the scope. */
async require(who: Who | undefined, permission: Permission, scope: Scope): Promise<void> {
if (!(await this.can(who, permission, scope))) throw new Forbidden(permission, scope);
}
/** May `who` give (or take away) `role` at `scope`? Only if they hold every permission in it there. */
async mayGive(who: Who | undefined, role: RoleId, scope: Scope): Promise<boolean> {
const held = await this.at(who, scope);
return roles[role].permissions.every((p) => held.has(p));
}
/** The catalogs a person is on the team of (owner or a role there). */
async teamCatalogs(personId: string): Promise<string[]> {
const owned = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.ownerId, personId));
const given = await this.db
.select({ id: roleAssignment.scopeId })
.from(roleAssignment)
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog")));
return [...new Set([...owned.map((c) => c.id), ...given.map((g) => g.id)])];
}
/** The products a person created. */
async ownProducts(personId: string): Promise<string[]> {
return (await this.db.select({ id: product.id }).from(product).where(eq(product.creatorId, personId))).map((p) => p.id);
}
/** A catalog's team: the owner and everyone given a role there, with their names. */
async team(catalogId: string) {
const [c] = await this.db
.select({ ownerId: catalog.ownerId, name: user.displayUsername, username: user.username })
.from(catalog)
.innerJoin(user, eq(user.id, catalog.ownerId))
.where(eq(catalog.id, catalogId));
const given = await this.db
.select({
personId: roleAssignment.userId,
role: roleAssignment.role,
name: user.displayUsername,
username: user.username,
since: roleAssignment.createdAt,
})
.from(roleAssignment)
.innerJoin(user, eq(user.id, roleAssignment.userId))
.where(and(eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId)));
return {
owner: c ? { personId: c.ownerId, username: c.name ?? c.username ?? "" } : undefined,
roles: given.map((g) => ({
personId: g.personId,
role: g.role,
username: g.name ?? g.username ?? "",
since: g.since.toISOString(),
})),
};
}
}
+59 -5
View File
@@ -20,7 +20,7 @@ import { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu"; import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys"; import type { Signer } from "./keys";
const { catalog, product, productRevision, hosting, library, user } = schema; const { catalog, catalogMember, product, productRevision, hosting, library, roleAssignment, user } = schema;
export type Catalog = typeof catalog.$inferSelect; export type Catalog = typeof catalog.$inferSelect;
export type Product = typeof product.$inferSelect; export type Product = typeof product.$inferSelect;
@@ -74,6 +74,21 @@ const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
// A parent's info another revision looked up lately is reused rather than asking IMVU again. // A parent's info another revision looked up lately is reused rather than asking IMVU again.
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000; const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
/**
* Whether a product is available to a person (docs/products.md, "Member"): they made it, or a catalog hosting it is
* approved, hasn't banned them, and is open or has them as a member or on its team.
*/
export const availableTo = (personId: string) => sql`(
${product.creatorId} = ${personId} or exists (
select 1 from ${hosting} h
join ${catalog} c on c.id = h.catalog_id
left join ${catalogMember} m on m.catalog_id = c.id and m.user_id = ${personId}
where h.product_id = ${product.id} and c.status = 'approved' and coalesce(m.status, '') <> 'banned'
and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
or exists (select 1 from ${roleAssignment} r
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id))
))`;
/** Each product's latest revision (joined on product.latest, for lists). */ /** Each product's latest revision (joined on product.latest, for lists). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest)); const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
@@ -123,12 +138,13 @@ export class Products {
return { catalog: c!, key }; return { catalog: c!, key };
} }
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved and the key is right. */ /** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved (or suspended, when asked)
async catalogFor(header: string | null): Promise<Catalog | undefined> { * and the key is right. */
async catalogFor(header: string | null, suspendedToo = false): Promise<Catalog | undefined> {
const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/); const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/);
if (!m) return undefined; if (!m) return undefined;
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!)); const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!));
if (!c || c.status !== "approved") return undefined; if (!c || !(c.status === "approved" || (suspendedToo && c.status === "suspended"))) return undefined;
return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined; return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined;
} }
@@ -163,6 +179,23 @@ export class Products {
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId)); const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId));
if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog."); if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
if (productId) await this.own(personId, productId); if (productId) await this.own(personId, productId);
if (c.uploads !== "anyone" && c.ownerId !== personId) {
const [team] = await this.db
.select({ role: roleAssignment.role })
.from(roleAssignment)
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, c.id)));
const [m] = await this.db
.select({ status: catalogMember.status })
.from(catalogMember)
.where(and(eq(catalogMember.catalogId, c.id), eq(catalogMember.userId, personId)));
const ok = team !== undefined || (c.uploads === "members" && m?.status === "member");
if (!ok)
throw new ProductError(
403,
"forbidden",
c.uploads === "members" ? "Only this catalog's members can upload to it." : "Only this catalog's team can upload to it.",
);
}
const token = await this.signer.sign( const token = await this.signer.sign(
"t5de-upload", "t5de-upload",
{ sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) }, { sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) },
@@ -183,6 +216,8 @@ export class Products {
if (!files) throw new ProductError(400, "invalid", "The file list isn't valid."); if (!files) throw new ProductError(400, "invalid", "The file list isn't valid.");
if (body.rating !== "general" && body.rating !== "adult") if (body.rating !== "general" && body.rating !== "adult")
throw new ProductError(400, "invalid", "The rating must be general or adult."); throw new ProductError(400, "invalid", "The rating must be general or adult.");
if (body.rating === "adult" && c.maxRating === "general")
throw new ProductError(400, "rating_ceiling", "This catalog hosts general-audience products only.");
const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0; const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0;
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000); const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
const personId = claims.sub; const personId = claims.sub;
@@ -427,7 +462,7 @@ export class Products {
.innerJoin(product, eq(product.id, library.productId)) .innerJoin(product, eq(product.id, library.productId))
.innerJoin(user, eq(user.id, product.creatorId)) .innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin) .leftJoin(productRevision, latestJoin)
.where(and(eq(library.userId, personId), isNull(product.removedAt))) .where(and(eq(library.userId, personId), isNull(product.removedAt), availableTo(personId)))
.orderBy(desc(library.addedAt)); .orderBy(desc(library.addedAt));
} }
@@ -443,9 +478,28 @@ export class Products {
const p = await this.get(productId, adult); const p = await this.get(productId, adult);
if (p.product.deletedAt && !(await this.inLibrary(personId, productId))) if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product."); throw new ProductError(410, "deleted", "Its creator deleted this product.");
if (!(await this.available(personId, productId)))
throw new ProductError(403, "members_only", "Join a catalog hosting this product to get it.");
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing(); await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
} }
async available(personId: string, productId: string): Promise<boolean> {
const [row] = await this.db
.select({ id: product.id })
.from(product)
.where(and(eq(product.id, productId), availableTo(personId)));
return row !== undefined;
}
/** The approved catalogs hosting a product, with their membership rule (for "join to get it"). */
async hostingCatalogs(productId: string) {
return this.db
.selectDistinct({ id: catalog.id, name: catalog.name, membership: catalog.membership, visibility: catalog.visibility })
.from(hosting)
.innerJoin(catalog, eq(catalog.id, hosting.catalogId))
.where(and(eq(hosting.productId, productId), eq(catalog.status, "approved")));
}
async remove(personId: string, productId: string) { async remove(personId: string, productId: string) {
await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId))); await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId)));
} }
+15 -1
View File
@@ -121,8 +121,15 @@ export type RealtimeDeps = {
hostedVisit?: (userId: string, n: number, imvuId: number) => void; hostedVisit?: (userId: string, n: number, imvuId: number) => void;
/** The IMVU username of a linked account, from its verified link ("" if unknown). */ /** The IMVU username of a linked account, from its verified link ("" if unknown). */
accountName?: (imvuId: number) => Promise<string>; accountName?: (imvuId: number) => Promise<string>;
/**
* Of these hub product IDs, the ones a person may wear (available to them: docs/products.md, "Member"). Outfit
* items with other hub IDs aren't relayed; items that aren't hub IDs (local packs) are.
*/
wearable?: (userId: string, productIds: string[]) => Promise<Set<string>>;
}; };
const hubProductID = /^[a-z2-7]{10}$/;
const maxChats = 16; const maxChats = 16;
const chatPattern = /^[0-9]{1,20}$/; const chatPattern = /^[0-9]{1,20}$/;
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/; const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
@@ -179,6 +186,7 @@ export class Realtime {
hostedBoot: async () => {}, hostedBoot: async () => {},
hostedVisit: () => {}, hostedVisit: () => {},
accountName: async () => "", accountName: async () => "",
wearable: async (_, ids) => new Set(ids),
...d, ...d,
}; };
} }
@@ -644,10 +652,16 @@ export class Realtime {
const items = m.items ?? []; const items = m.items ?? [];
if (!Array.isArray(items)) return { type: "error", message: "items must be a list" }; if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
if (items.length > 64) return { type: "error", message: "too many items" }; if (items.length > 64) return { type: "error", message: "too many items" };
s.outfit = items let outfit = items
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string") .filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128) .filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true })); .map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
const hubIds = outfit.filter((it) => hubProductID.test(it.id)).map((it) => it.id);
if (hubIds.length > 0) {
const ok = await this.deps.wearable(s.userId, hubIds);
outfit = outfit.filter((it) => !hubProductID.test(it.id) || ok.has(it.id));
}
s.outfit = outfit;
this.changed(s.chats.keys()); this.changed(s.chats.keys());
return; return;
} }
+338
View File
@@ -0,0 +1,338 @@
// Managing catalogs (docs/products.md) with permissions (docs/console.md): settings, keys, teams, membership and
// what it gates, invites, delisting, suspension, the changes feed, and the console's scoped audit log.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// Catalog addresses that answer /health (for address changes).
const healthy = new Set<string>(["https://moved.example.test"]);
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({
db,
auth,
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
apiURL,
webURL,
catalogHealthy: async (url) => healthy.has(url),
});
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; name: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
/** A person's catalog, registered and approved; its key's Authorization header. */
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
const reg = await call(owner, "POST", "/api/v1/catalogs", {
name,
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
contact: "[email protected]",
visibility: "public",
membership,
});
expect(reg.status).toBe(201);
const id = reg.json.catalog.id as string;
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
}
/** A product uploaded by a person to a catalog; its ID. */
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const r = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
c.auth,
);
expect(r.status).toBe(201);
return r.json.product as string;
}
let admin: Person;
test("the first account is the admin", async () => {
admin = await person("admin1");
expect((await call(admin, "GET", "/api/v1/me")).json.person.role).toBe("admin");
});
test("a catalog's owner changes its settings and key; others can't, and are told which permission they lack", async () => {
const owner = await person("owner1");
const stranger = await person("stranger1");
const c = await catalogOf(owner, admin, "Owner Shelf");
// Anyone can see an approved catalog; the owner sees their permissions there.
const seen = await call(owner, "GET", `/api/v1/catalogs/${c.id}`);
expect(seen.status).toBe(200);
expect(seen.json.roles).toContain("catalog-owner");
expect(seen.json.permissions).toContain("catalog:edit");
expect((await call(stranger, "GET", `/api/v1/catalogs/${c.id}`)).json.permissions).toEqual([]);
// A stranger is refused, naming what's missing.
const refused = await call(stranger, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Mine now" });
expect(refused.status).toBe(403);
expect(refused.json).toMatchObject({ error: "forbidden", missing: { permission: "catalog:edit", scope: { catalog: c.id } } });
// The owner changes settings; an address must answer /health.
const edited = await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Owner's Shelf", maxRating: "general", uploads: "members" });
expect(edited.status).toBe(200);
expect(edited.json.catalog).toMatchObject({ name: "Owner's Shelf", maxRating: "general", uploads: "members" });
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://nowhere.example.test" })).json.error).toBe("unreachable");
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://moved.example.test/" })).json.catalog.url).toBe(
"https://moved.example.test",
);
// A new key: the old one stops working at once.
const rotated = await call(owner, "POST", `/api/v1/catalogs/${c.id}/key`);
expect(rotated.status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
expect(
(await call(undefined, "GET", "/api/v1/catalog/changes", undefined, { Authorization: `Catalog ${c.id}:${rotated.json.key}` })).status,
).toBe(200);
});
test("teams: the owner gives the operator role; operators manage members but not the team", async () => {
const owner = await person("owner2");
const op = await person("op2");
const other = await person("other2");
const c = await catalogOf(owner, admin, "Team Shelf", "approval");
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).status).toBe(403);
const added = await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/op2`, { role: "catalog-operator" });
expect(added.status).toBe(200);
expect(added.json.team.roles).toMatchObject([{ username: "op2", role: "catalog-operator" }]);
// The operator holds member:approve, not team:manage.
const perms = (await call(op, "GET", `/api/v1/console/permissions?catalog=${c.id}`)).json;
expect(perms.roles).toEqual(["catalog-operator"]);
expect(perms.permissions).toContain("member:approve");
expect(perms.permissions).not.toContain("team:manage");
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).json.missing.permission).toBe(
"team:manage",
);
// An approval catalog: other asks, the operator approves.
expect((await call(other, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
const requests = await call(op, "GET", `/api/v1/catalogs/${c.id}/members?status=requested`);
expect(requests.json.members).toMatchObject([{ username: "other2", status: "requested" }]);
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/members/other2`, { status: "member" })).status).toBe(200);
expect((await call(other, "GET", `/api/v1/catalogs/${c.id}`)).json.membership).toBe("member");
// Operators leave on their own; the console lists the catalog for them while they're on the team.
expect((await call(op, "GET", "/api/v1/console/catalogs")).json.catalogs.map((x: any) => x.id)).toEqual([c.id]);
expect((await call(op, "DELETE", `/api/v1/catalogs/${c.id}/team/op2?role=catalog-operator`)).status).toBe(200);
expect((await call(op, "GET", "/api/v1/console/catalogs")).status).toBe(403);
});
test("membership gates getting and wearing products; seeing them doesn't need it", async () => {
const owner = await person("owner3");
const maker = await person("maker3");
const fan = await person("fan3");
const c = await catalogOf(owner, admin, "Invite Shelf", "invite");
// Uploads to an invite catalog: anyone may upload by default.
const pid = await upload(maker, c, "Hat", 31);
// Seeing works; getting doesn't, and says which catalogs host it.
const page = await call(fan, "GET", `/api/v1/products/${pid}`);
expect(page.json).toMatchObject({ available: false, catalogs: [{ id: c.id, membership: "invite", joined: null }] });
expect((await call(fan, "GET", `/api/v1/products/${pid}/files`)).status).toBe(200);
const refused = await call(fan, "PUT", `/api/v1/library/${pid}`);
expect(refused.status).toBe(403);
expect(refused.json).toMatchObject({ error: "members_only", catalogs: [{ id: c.id }] });
// Invite only: joining without an invite is refused; with one, they're in.
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("invite_only");
const inv = await call(owner, "POST", `/api/v1/catalogs/${c.id}/invites`, { maxUses: 1 });
expect(inv.status).toBe(201);
expect(inv.json.invite.url).toBe(`${webURL}/catalogs/${c.id}?invite=${inv.json.invite.code}`);
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.membership).toBe("member");
const late = await person("late3");
expect((await call(late, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.error).toBe("invite_invalid");
expect((await call(fan, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
// Banned: the product stays in the table but isn't listed (or wearable) until they're back.
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "banned" })).status).toBe(200);
expect((await call(fan, "GET", "/api/v1/library")).json.library).toEqual([]);
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("banned");
expect((await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/members/fan3`)).status).toBe(204);
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "member" })).status).toBe(200);
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
// Its creator always has their own product.
expect((await call(maker, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
});
test("a catalog's upload policy and rating ceiling apply to uploads", async () => {
const owner = await person("owner4");
const maker = await person("maker4");
const c = await catalogOf(owner, admin, "Strict Shelf");
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "members", maxRating: "general" })).status).toBe(200);
const refused = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(refused).toMatchObject({ status: 403, json: { error: "forbidden" } });
expect((await call(maker, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("member");
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const adult = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", 41)], rating: "adult", parent: 0, name: "Spicy", notes: "" },
c.auth,
);
expect(adult).toMatchObject({ status: 400, json: { error: "rating_ceiling" } });
// Team only: members can't either.
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "team" })).status).toBe(200);
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(403);
expect((await call(owner, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(200);
});
test("delisting and suspension reach the catalog through its changes feed", async () => {
const owner = await person("owner5");
const maker = await person("maker5");
const c = await catalogOf(owner, admin, "Feed Shelf");
const pid = await upload(maker, c, "Scarf", 51);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toMatchObject([{ id: pid, revisions: [1] }]);
expect((await call(maker, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=no`)).status).toBe(403);
const delisted = await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=${encodeURIComponent("off-topic")}`);
expect(delisted.json.revisions).toEqual([1]);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toEqual([]);
const feed = await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth);
expect(feed.json.changes).toEqual([{ type: "delete", product: pid, revision: 1, files: [file("index.xml", 51)] }]);
const cursor = feed.json.cursor;
expect((await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth)).json.changes).toEqual([]);
// Owners can't suspend their own catalog; moderators can. A suspended catalog can't register revisions, still
// reads its feed, and is told.
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "x" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "spam" })).json.catalog).toMatchObject({
status: "suspended",
statusReason: "spam",
});
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(404);
const told = await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth);
expect(told.json.changes).toEqual([{ type: "status", status: "suspended" }]);
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/reinstate`)).json.catalog.status).toBe("approved");
});
test("the console is for everyone who controls something; its audit log shows only what they may see", async () => {
const owner = await person("owner6");
const maker = await person("maker6");
const nobody = await person("nobody6");
const c = await catalogOf(owner, admin, "Audit Shelf");
const other = await catalogOf(await person("owner6b"), admin, "Other Shelf");
const pid = await upload(maker, c, "Gloves", 61);
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { contact: "[email protected]" })).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${pid}`, { name: "Warm Gloves" })).status).toBe(200);
expect((await call(admin, "PATCH", `/api/v1/catalogs/${other.id}`, { name: "Other Shelf 2" })).status).toBe(200);
// Sections by what they control.
expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403);
expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403);
const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json;
expect(ownerConsole.sections).toEqual(["overview", "catalogs", "audit"]);
expect(ownerConsole.moderation).toBeNull();
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "audit"]);
expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "people", "audit", "settings"]);
// The owner sees their catalog's entries, not the other catalog's; the maker sees their product's.
const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries;
expect(ownerLog.some((e: any) => e.action === "catalog.edit" && e.tags.catalog === c.id)).toBe(true);
expect(ownerLog.some((e: any) => e.tags.catalog === other.id)).toBe(false);
expect(ownerLog.find((e: any) => e.tags.catalog === c.id).tagNames[c.id]).toBe("Audit Shelf");
const makerLog = (await call(maker, "GET", "/api/v1/console/audit")).json.entries;
expect(makerLog.map((e: any) => e.action)).toContain("product.edit");
expect(makerLog.some((e: any) => e.tags.catalog === c.id && e.action === "catalog.edit")).toBe(false);
// Admins see everything, and filter.
const filtered = (await call(admin, "GET", `/api/v1/console/audit?catalog=${other.id}&action=catalog.edit`)).json.entries;
expect(filtered).toHaveLength(1);
expect(filtered[0]).toMatchObject({ actor: "admin1", action: "catalog.edit" });
expect((await call(admin, "GET", "/api/v1/console/audit?actor=maker6")).json.entries.every((e: any) => e.actor === "maker6")).toBe(true);
expect((await call(admin, "GET", "/api/v1/console/audit?q=legal@example")).json.entries).toHaveLength(1);
});
test("outfits relay only the hub products the wearer may wear; local packs pass", async () => {
const { Realtime } = await import("../src/realtime");
const rt = new Realtime({
owner: async () => "u1",
wearable: async (_, ids) => new Set(ids.filter((id) => id === "aaaaaaaaaa")),
});
rt.open("s1", "u1", () => {});
await rt.message("s1", { type: "session", imvu_id: 7 });
await rt.message("s1", {
type: "outfit",
items: [
{ id: "aaaaaaaaaa", name: "Mine", adult: false },
{ id: "bbbbbbbbbb", name: "Members only", adult: false },
{ id: "local-pack-1", name: "A pack", adult: false },
],
});
expect(rt.online()[0]!.outfit.map((i) => i.id)).toEqual(["aaaaaaaaaa", "local-pack-1"]);
});
+2 -2
View File
@@ -229,8 +229,8 @@ describe("T5DE's proxy", () => {
const admin = new Browser(); const admin = new Browser();
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" }); await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const consoleView = (await admin.call("GET", "/api/v1/console")).json; const consoleView = (await admin.call("GET", "/api/v1/console")).json;
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true }); expect(consoleView.moderation.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
expect(consoleView.counts.computers).toBe(1); expect(consoleView.moderation.counts.computers).toBe(1);
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true }); await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
const online = (await visitor.call("GET", "/api/v1/online")).json.online; const online = (await visitor.call("GET", "/api/v1/online")).json.online;
expect(online).toEqual([ expect(online).toEqual([
+1 -1
View File
@@ -63,7 +63,7 @@ test("closed: new accounts wait for approval; the first one runs the hub", async
expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403); expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403);
// Moderators see who's waiting, and approve them. // Moderators see who's waiting, and approve them.
expect((await call(admin, "GET", "/api/v1/console")).json.counts.waiting).toBe(1); expect((await call(admin, "GET", "/api/v1/console")).json.moderation.counts.waiting).toBe(1);
const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people; const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]); expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]);
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true); expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true);
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { membership as membershipLabel } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api, getMe, unwrap } from "@/lib/api";
import { membership } from "../../console/catalogs/actions";
export const metadata: Metadata = { title: "Catalog" };
export default async function CatalogPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ invite?: string | string[] }>;
}) {
const { id } = await params;
const raw = (await searchParams).invite;
const invite = typeof raw === "string" && /^[a-z0-9]{1,32}$/.test(raw) ? raw : "";
const [me, answer] = await Promise.all([getMe(), api.catalogs({ id }).get()]);
if (answer.error) notFound();
const data = unwrap(answer);
const c = data.catalog;
const manage = data.permissions.includes("audit:read");
return (
<div className="space-y-6">
<PageHeader title={c.name}>
<span className="flex flex-wrap items-center gap-2">
<span>{membershipLabel[c.membership] ?? c.membership}</span>
{c.maxRating === "general" && <Tag>General audiences</Tag>}
{data.membership === "member" && <Tag tone="strong">Member</Tag>}
{data.membership === "requested" && <Tag>Asked to join</Tag>}
{data.membership === "banned" && <Tag tone="danger">Banned</Tag>}
</span>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Products</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{data.hosted.products}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Takedown Contact</CardDescription>
<CardTitle className="text-base font-normal break-all">{c.contact}</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Membership</CardTitle>
<CardDescription>
{c.membership === "open"
? "Anyone on T5DE can get its products."
: c.membership === "approval"
? "Ask to join; its team decides. Anyone can see its products on others."
: "Join with an invitation. Anyone can see its products on others."}
</CardDescription>
</CardHeader>
<CardContent>
{!me ? (
<Link href={`/signin?next=${encodeURIComponent(`/catalogs/${id}${invite ? `?invite=${invite}` : ""}`)}`} className={buttonVariants()}>
Sign in to join
</Link>
) : data.membership === "banned" ? (
<p className="text-sm text-muted-foreground">This catalog&apos;s team banned you from it.</p>
) : data.membership === "member" || data.membership === "requested" ? (
<ActionForm action={membership} fields={{ id, intent: "leave" }}>
<SubmitButton variant="outline" pendingLabel="Leaving…" confirm={data.membership === "member" ? `Leave ${c.name}?` : undefined}>
{data.membership === "member" ? "Leave" : "Withdraw request"}
</SubmitButton>
</ActionForm>
) : c.membership === "open" ? (
<p className="text-sm text-muted-foreground">Nothing to join: get its products from their pages.</p>
) : c.membership === "invite" && !invite ? (
<p className="text-sm text-muted-foreground">You need an invitation link from its team.</p>
) : (
<ActionForm action={membership} fields={{ id, intent: "join", invite }}>
<SubmitButton pendingLabel="Joining…">{invite ? "Accept invitation" : "Ask to join"}</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
{manage && (
<Link href={`/console/catalogs/${id}`} className={buttonVariants({ variant: "outline" })}>
Manage
</Link>
)}
</div>
);
}
+6 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { CatalogStatus, membership } from "@/components/catalogs"; import { CatalogStatus, membership } from "@/components/catalogs";
import { PageHeader } from "@/components/status"; import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -71,7 +72,11 @@ function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; s
<TableBody> <TableBody>
{catalogs.map((c) => ( {catalogs.map((c) => (
<TableRow key={c.id}> <TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell> <TableCell className="font-medium">
<Link href={showStatus ? `/console/catalogs/${c.id}` : `/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
{c.name}
</Link>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell> <TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
<TableCell>{membership[c.membership] ?? c.membership}</TableCell> <TableCell>{membership[c.membership] ?? c.membership}</TableCell>
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>} {showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { DateTime } from "@/components/time";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { AuditEntry } from "@/lib/api";
const tagLinks: Record<string, (id: string) => string> = {
catalog: (id) => `/console/catalogs/${id}`,
product: (id) => `/products/${id}`,
room: (id) => `/rooms/${id}/edit`,
};
/** Audit entries: when, who, what, which resources (linked), and the details. */
export function AuditTable({ entries, empty = "Nothing yet." }: { entries: AuditEntry[]; empty?: string }) {
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{empty}</p>;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Who</TableHead>
<TableHead>What</TableHead>
<TableHead>About</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={e.id}>
<TableCell className="text-muted-foreground">
<DateTime iso={e.at} />
</TableCell>
<TableCell>
{e.actor ? (
<Link href={`/console/audit?actor=${encodeURIComponent(e.actor)}`} className="underline-offset-4 hover:underline">
{e.actor}
</Link>
) : (
<span className="text-muted-foreground">hub</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">{e.action}</TableCell>
<TableCell className="space-x-2 text-sm">
{Object.entries(e.tags as Record<string, string | undefined>).map(([kind, id]) => {
if (!id) return null;
const name = (e.tagNames as Record<string, string>)[id] ?? id;
const href = tagLinks[kind]?.(id);
return (
<span key={kind}>
<span className="text-muted-foreground">{kind} </span>
{href ? (
<Link href={href} className="underline-offset-4 hover:underline">
{name}
</Link>
) : (
name
)}
</span>
);
})}
{Object.keys(e.tags).length === 0 && e.target}
</TableCell>
<TableCell className="text-xs whitespace-normal text-muted-foreground">{detailText(e.detail)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function detailText(detail: unknown): string {
return Object.entries((detail ?? {}) as Record<string, unknown>)
.map(([k, v]) => {
if (k === "changed" && v && typeof v === "object")
return Object.entries(v as Record<string, { from: unknown; to: unknown }>)
.map(([f, c]) => `${f}: ${String(c?.from)} → ${String(c?.to)}`)
.join(", ");
return `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`;
})
.join(", ");
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/status";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api, unwrap } from "@/lib/api";
import { AuditTable } from "../audit-table";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Audit Log" };
const filters = ["action", "actor", "person", "catalog", "product", "room", "q", "from", "to"] as const;
type Filters = Partial<Record<(typeof filters)[number], string>>;
export default async function AuditPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
const { console: data } = await requireConsole("/console/audit");
const params = await searchParams;
const f: Filters = {};
for (const k of filters) {
const v = params[k];
if (typeof v === "string" && v.trim()) f[k] = v.trim().slice(0, 100);
}
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
const { entries, before: next } = unwrap(await api.console.audit.get({ query: { ...f, ...(before ? { before } : {}), limit: 100 } }));
const older = new URLSearchParams({ ...f, ...(next ? { before: String(next) } : {}) });
const moderator = data.moderation !== null;
return (
<>
<PageHeader title="Audit Log">
{moderator ? "Every change on the hub." : "Changes to what you manage, and the ones you made."}
</PageHeader>
<ConsoleNav />
<form action="/console/audit" className="mb-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<Input name="q" defaultValue={f.q} placeholder="Search" aria-label="Search" />
<Input name="action" defaultValue={f.action} placeholder="Action, e.g. catalog." aria-label="Action" />
<Input name="actor" defaultValue={f.actor} placeholder="Done by (username)" aria-label="Done by" />
{moderator && <Input name="person" defaultValue={f.person} placeholder="About or by a person" aria-label="Person" />}
<Input name="catalog" defaultValue={f.catalog} placeholder="Catalog ID" aria-label="Catalog" />
<Input name="product" defaultValue={f.product} placeholder="Product ID" aria-label="Product" />
<Input type="date" name="from" defaultValue={f.from} aria-label="From" />
<Input type="date" name="to" defaultValue={f.to} aria-label="To" />
<div className="flex gap-2">
<Button type="submit">Filter</Button>
{Object.keys(f).length > 0 && (
<Link href="/console/audit" className={buttonVariants({ variant: "outline" })}>
Clear
</Link>
)}
</div>
</form>
<Card>
<CardContent>
<AuditTable entries={entries} empty={Object.keys(f).length ? "Nothing matches." : "Nothing yet."} />
</CardContent>
</Card>
{next && entries.length === 100 && (
<div className="mt-4">
<Link href={`/console/audit?${older}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
+498
View File
@@ -0,0 +1,498 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CatalogStatus } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { formatSize, RatingTag } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api";
import { AuditTable } from "../../audit-table";
import { ConsoleNav, requireConsole } from "../../console-nav";
import {
addOperator,
createInvite,
decideMember,
delist,
removeFromTeam,
revokeInvite,
rotateKey,
saveCatalog,
setSuspended,
} from "../actions";
export const metadata: Metadata = { title: "Catalog" };
type Perm = CatalogPage["permissions"][number];
export default async function ManageCatalog({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { me } = await requireConsole(`/console/catalogs/${id}`);
const answer = await api.catalogs({ id }).get();
if (answer.error) notFound();
const data = unwrap(answer);
const has = (p: Perm) => data.permissions.includes(p);
if (!has("audit:read")) notFound();
const c = data.catalog;
const [team, members, invites, hosted, audit] = await Promise.all([
api.catalogs({ id }).team.get().then(unwrap),
has("member:approve") ? api.catalogs({ id }).members.get().then(unwrap) : undefined,
has("member:invite") ? api.catalogs({ id }).invites.get().then(unwrap) : undefined,
api.catalogs({ id }).products.get().then(unwrap),
api.console.audit.get({ query: { catalog: id, limit: 20 } }).then(unwrap),
]);
const byStatus = (s: CatalogMember["status"]) => members?.members.filter((m) => m.status === s) ?? [];
const onTeam = team.roles.some((r) => r.personId === me.id);
return (
<>
<PageHeader title={c.name}>
<span className="flex flex-wrap items-center gap-2">
<CatalogStatus status={c.status} />
<span className="font-mono text-xs">{c.url}</span>
{c.status === "approved" && (
<Link href={`/catalogs/${id}`} className="text-sm underline-offset-4 hover:underline">
Public page
</Link>
)}
</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
{(c.status === "suspended" || c.status === "rejected") && (
<Alert>
<AlertTitle>{c.status === "suspended" ? "Suspended by moderation" : "Rejected"}</AlertTitle>
<AlertDescription>
{c.statusReason || "No reason given."}{" "}
{c.status === "suspended" && "Its products aren't available from it, and it can't take uploads."}
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="Products" value={String(data.hosted.products)} note={`${data.hosted.revisions} revisions`} />
<Stat label="Storage" value={formatSize(data.hosted.bytes)} note="in the revisions it hosts" />
{data.members && (
<>
<Stat label="Members" value={String(data.members.members)} note={`${data.members.banned} banned`} />
<Stat label="Requests" value={String(data.members.requests)} note="waiting" />
</>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
{!has("catalog:edit") && <CardDescription>Only the owner and operators change these.</CardDescription>}
</CardHeader>
<CardContent>
<ActionForm action={saveCatalog} fields={{ id }}>
<fieldset disabled={!has("catalog:edit")} className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={c.name} required maxLength={64} />
</Field>
<Field>
<FieldLabel htmlFor="url">Address</FieldLabel>
<Input id="url" name="url" type="url" defaultValue={c.url} required maxLength={300} />
</Field>
<Field>
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
<Input id="contact" name="contact" defaultValue={c.contact} required maxLength={200} />
</Field>
<Choice
name="visibility"
label="Visibility"
value={c.visibility}
options={[
["public", "Public (listed)"],
["private", "Private (by link)"],
]}
/>
<Choice
name="membership"
label="Membership"
value={c.membership}
options={[
["open", "Open"],
["approval", "By approval"],
["invite", "Invite only"],
]}
/>
<Choice
name="maxRating"
label="Highest rating"
value={c.maxRating}
options={[
["general", "General only"],
["adult", "Adult too"],
]}
/>
<Choice
name="uploads"
label="Who may upload"
value={c.uploads}
options={[
["anyone", "Anyone"],
["members", "Members"],
["team", "Only the team"],
]}
/>
<Choice
name="mirrors"
label="Mirroring others' products"
value={c.mirrors}
options={[
["auto", "Accept"],
["approve", "Team approves"],
["off", "Off"],
]}
/>
</fieldset>
{has("catalog:edit") && <SubmitButton pendingLabel="Saving…">Save</SubmitButton>}
</ActionForm>
</CardContent>
</Card>
{has("catalog:rotate-key") && (
<Card>
<CardHeader>
<CardTitle>Key</CardTitle>
<CardDescription>
The catalog signs in to the hub with its ID ({id}) and key. A new key stops the old one at once.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={rotateKey} fields={{ id }}>
<SubmitButton variant="outline" pendingLabel="Making one…" confirm="The old key stops working at once. Go on?">
New key
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Team</CardTitle>
<CardDescription>The owner, and operators: they manage everything but the team itself.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="divide-y text-sm">
{team.owner && (
<li className="flex items-center justify-between py-2">
<span>{team.owner.username}</span>
<Tag tone="strong">owner</Tag>
</li>
)}
{team.roles.map((r) => (
<li key={r.personId + r.role} className="flex items-center justify-between gap-2 py-2">
<span>{r.username}</span>
<span className="flex items-center gap-2">
<Tag>operator</Tag>
{(has("team:manage") || r.personId === me.id) && (
<ActionForm action={removeFromTeam} fields={{ id, username: r.username }}>
<SubmitButton
size="sm"
variant="outline"
pendingLabel="Removing…"
confirm={r.personId === me.id ? "Leave this catalog's team?" : `Take ${r.username} off the team?`}
>
{r.personId === me.id ? "Leave" : "Remove"}
</SubmitButton>
</ActionForm>
)}
</span>
</li>
))}
</ul>
{has("team:manage") && (
<ActionForm action={addOperator} fields={{ id }}>
<div className="flex max-w-md gap-2">
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
<SubmitButton pendingLabel="Adding…">Add operator</SubmitButton>
</div>
</ActionForm>
)}
{!onTeam && team.owner?.personId !== me.id && (
<p className="text-sm text-muted-foreground">You see this as a moderator.</p>
)}
</CardContent>
</Card>
{members && (
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
<CardDescription>
{c.membership === "open"
? "Open: anyone can get its products. Bans still apply."
: c.membership === "approval"
? "By approval: people ask, the team decides."
: "Invite only: people join with an invitation."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<MemberList id={id} title="Requests" members={byStatus("requested")} actions={["approve", "decline"]} />
<MemberList id={id} title="Members" members={byStatus("member")} actions={["ban", "remove"]} />
<MemberList id={id} title="Banned" members={byStatus("banned")} actions={["unban"]} />
<ActionForm action={decideMember} fields={{ id, decision: "add" }}>
<div className="flex max-w-md gap-2">
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
<SubmitButton variant="outline" pendingLabel="Adding…">
Add member
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
{invites && (
<Card>
<CardHeader>
<CardTitle>Invitations</CardTitle>
<CardDescription>Links that let people join (also an approval or invite-only catalog) at once.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{invites.invites.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Link</TableHead>
<TableHead>Used</TableHead>
<TableHead>Expires</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{invites.invites.map((i) => (
<TableRow key={i.code}>
<TableCell className="font-mono text-xs break-all whitespace-normal">{i.url}</TableCell>
<TableCell className="tabular-nums">
{i.uses}
{i.maxUses !== null && ` of ${i.maxUses}`}
</TableCell>
<TableCell className="text-muted-foreground">
{i.expiresAt ? <DateTime iso={i.expiresAt} dateOnly /> : "never"}
</TableCell>
<TableCell className="text-right">
<ActionForm action={revokeInvite} fields={{ id, code: i.code }}>
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…">
Revoke
</SubmitButton>
</ActionForm>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<ActionForm action={createInvite} fields={{ id }}>
<div className="flex flex-wrap items-end gap-2">
<Field className="w-36">
<FieldLabel htmlFor="maxUses">Uses</FieldLabel>
<Input id="maxUses" name="maxUses" type="number" min={1} max={10000} placeholder="Any" />
</Field>
<Field className="w-36">
<FieldLabel htmlFor="expiresInDays">Days</FieldLabel>
<Input id="expiresInDays" name="expiresInDays" type="number" min={1} max={365} placeholder="Never" />
</Field>
<SubmitButton pendingLabel="Making it…">New invitation</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Products</CardTitle>
<CardDescription>What it hosts. Delisting stops hosting a product here; other catalogs may still host it.</CardDescription>
</CardHeader>
<CardContent>
{hosted.products.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Creator</TableHead>
<TableHead>Revisions</TableHead>
<TableHead>Since</TableHead>
{has("hosting:delist") && <TableHead />}
</TableRow>
</TableHeader>
<TableBody>
{hosted.products.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
{p.name}
</Link>{" "}
<RatingTag rating={p.rating === "adult" ? "adult" : "general"} />{" "}
{p.removed && <Tag tone="danger">removed</Tag>}
{p.deleted && <Tag>deleted</Tag>}
</TableCell>
<TableCell>{p.creator}</TableCell>
<TableCell className="tabular-nums">{p.revisions.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={p.since} dateOnly />
</TableCell>
{has("hosting:delist") && (
<TableCell className="text-right">
<ActionForm action={delist} fields={{ id, product: p.id }}>
<div className="flex justify-end gap-2">
<Input name="reason" placeholder="Reason" aria-label="Reason" required maxLength={500} className="h-8 w-40" />
<SubmitButton size="sm" variant="outline" pendingLabel="Delisting…" confirm={`Stop hosting ${p.name}?`}>
Delist
</SubmitButton>
</div>
</ActionForm>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{has("catalog:suspend") && (c.status === "approved" || c.status === "suspended") && (
<Card>
<CardHeader>
<CardTitle>Moderation</CardTitle>
<CardDescription>
A suspended catalog's products aren't available from it, it can't take uploads, and its team sees why.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setSuspended} fields={{ id }}>
{c.status === "suspended" ? (
<SubmitButton name="intent" value="reinstate" pendingLabel="Reinstating…">
Reinstate
</SubmitButton>
) : (
<div className="flex max-w-xl gap-2">
<Input name="reason" placeholder="Reason (shown to its team)" aria-label="Reason" required maxLength={500} />
<SubmitButton name="intent" value="suspend" variant="outline" pendingLabel="Suspending…" confirm={`Suspend ${c.name}?`}>
Suspend
</SubmitButton>
</div>
)}
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Recent Changes</CardTitle>
<CardDescription>
<Link href={`/console/audit?catalog=${id}`} className="underline-offset-4 hover:underline">
All changes to this catalog
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<AuditTable entries={audit.entries} />
</CardContent>
</Card>
</div>
</>
);
}
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">{note}</CardContent>
</Card>
);
}
function Choice({ name, label, value, options }: { name: string; label: string; value: string; options: [string, string][] }) {
return (
<Field>
<FieldLabel htmlFor={name}>{label}</FieldLabel>
<NativeSelect id={name} name={name} defaultValue={value} className="w-full">
{options.map(([v, l]) => (
<NativeSelectOption key={v} value={v}>
{l}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
);
}
const memberActions = {
approve: { label: "Approve", variant: "default" as const },
decline: { label: "Decline", variant: "outline" as const },
ban: { label: "Ban", variant: "outline" as const },
remove: { label: "Remove", variant: "outline" as const },
unban: { label: "Unban", variant: "outline" as const },
};
function MemberList({
id,
title,
members,
actions,
}: {
id: string;
title: string;
members: CatalogMember[];
actions: (keyof typeof memberActions)[];
}) {
if (members.length === 0) return null;
return (
<div className="space-y-2">
<h3 className="text-sm font-medium">
{title} <span className="text-muted-foreground tabular-nums">{members.length}</span>
</h3>
<ul className="divide-y text-sm">
{members.map((m) => (
<li key={m.personId} className="flex items-center justify-between gap-2 py-2">
<span>
{m.username}{" "}
<span className="text-muted-foreground">
{m.invited ? "invited, " : ""}
<DateTime iso={m.since} dateOnly />
</span>
</span>
<ActionForm action={decideMember} fields={{ id, username: m.username }}>
<div className="flex gap-2">
{actions.map((a) => (
<SubmitButton
key={a}
size="sm"
variant={memberActions[a].variant}
name="decision"
value={a}
confirm={a === "ban" ? `Ban ${m.username} from this catalog?` : undefined}
>
{memberActions[a].label}
</SubmitButton>
))}
</div>
</ActionForm>
</li>
))}
</ul>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use server";
// Managing a catalog from the console: settings, key, team, members, invitations, what it hosts, suspension.
import { revalidatePath } from "next/cache";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
/** Runs a change to a catalog, then shows its pages again. */
async function onCatalog(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const notice = await change(id);
revalidatePath(`/console/catalogs/${id}`);
revalidatePath(`/catalogs/${id}`);
revalidatePath("/console/catalogs");
revalidatePath("/catalogs");
return notice;
});
}
const pick = <T extends string>(v: string, options: readonly T[]): T | undefined => options.find((o) => o === v);
export async function saveCatalog(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(
await api.catalogs({ id }).patch({
name: field(form, "name"),
url: field(form, "url"),
contact: field(form, "contact"),
visibility: pick(field(form, "visibility"), ["public", "private"] as const),
membership: pick(field(form, "membership"), ["open", "approval", "invite"] as const),
maxRating: pick(field(form, "maxRating"), ["general", "adult"] as const),
uploads: pick(field(form, "uploads"), ["anyone", "members", "team"] as const),
mirrors: pick(field(form, "mirrors"), ["auto", "approve", "off"] as const),
}),
);
return "Saved.";
});
}
export async function rotateKey(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const { key } = unwrap(await api.catalogs({ id }).key.post());
return `New key (shown once; put it in the catalog's CATALOG_KEY and restart it): ${key}`;
});
}
export async function setSuspended(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
if (field(form, "intent") === "reinstate") unwrap(await api.catalogs({ id }).reinstate.post());
else unwrap(await api.catalogs({ id }).suspend.post({ reason: field(form, "reason") }));
});
}
export async function addOperator(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const username = field(form, "username");
unwrap(await api.catalogs({ id }).team({ username }).put({ role: "catalog-operator" }));
return `${username} is an operator now.`;
});
}
export async function removeFromTeam(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(await api.catalogs({ id }).team({ username: field(form, "username") }).delete({}, { query: { role: "catalog-operator" } }));
});
}
/** A member or request: approve, decline, add, ban, unban or remove. */
export async function decideMember(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const member = api.catalogs({ id }).members({ username: field(form, "username") });
switch (field(form, "decision")) {
case "approve":
case "add":
unwrap(await member.put({ status: "member" }));
break;
case "ban":
unwrap(await member.put({ status: "banned" }));
break;
default: // decline, unban, remove
unwrap(await member.delete());
}
});
}
export async function createInvite(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const maxUses = Number(field(form, "maxUses")) || undefined;
const expiresInDays = Number(field(form, "expiresInDays")) || undefined;
const { invite } = unwrap(await api.catalogs({ id }).invites.post({ maxUses, expiresInDays }));
return `Invitation link: ${invite.url}`;
});
}
export async function revokeInvite(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(await api.catalogs({ id }).invites({ code: field(form, "code") }).delete());
});
}
export async function delist(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const product = field(form, "product");
unwrap(await api.catalogs({ id }).products({ product }).delete({}, { query: { reason: field(form, "reason") } }));
revalidatePath(`/products/${product}`);
});
}
/** Someone joins a catalog (or asks to), with an invitation code if they have one; or leaves it. */
export async function membership(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
if (field(form, "intent") === "leave") {
unwrap(await api.catalogs({ id }).join.delete());
revalidatePath("/library");
return "You left.";
}
const invite = field(form, "invite");
const { membership } = unwrap(await api.catalogs({ id }).join.post(invite ? { invite } : {}));
revalidatePath("/library");
return membership === "member" ? "You're a member." : "Asked. The catalog's team will decide.";
});
}
+38 -32
View File
@@ -1,23 +1,24 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { CatalogStatus, membership } from "@/components/catalogs"; import { CatalogStatus, membership } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms"; import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status"; import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time"; import { DateTime } from "@/components/time";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api"; import { api, unwrap } from "@/lib/api";
import { decideCatalog } from "../actions"; import { decideCatalog } from "../actions";
import { ConsoleNav, requireModerator } from "../console-nav"; import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Catalogs" }; export const metadata: Metadata = { title: "Catalogs" };
export default async function ConsoleCatalogs() { export default async function ConsoleCatalogs() {
await requireModerator("/console/catalogs"); await requireConsole("/console/catalogs");
const { catalogs } = unwrap(await api.console.catalogs.get()); const { catalogs, moderator } = unwrap(await api.console.catalogs.get());
return ( return (
<> <>
<PageHeader title="Catalogs" /> <PageHeader title="Catalogs">{moderator ? "Every registered catalog." : "Catalogs you're on the team of."}</PageHeader>
<ConsoleNav /> <ConsoleNav />
<Card> <Card>
<CardContent> <CardContent>
@@ -29,19 +30,22 @@ export default async function ConsoleCatalogs() {
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Address</TableHead> <TableHead>Address</TableHead>
<TableHead>Takedown contact</TableHead>
<TableHead>Access</TableHead> <TableHead>Access</TableHead>
<TableHead>Registered</TableHead> <TableHead>Registered</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead /> {moderator && <TableHead />}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{catalogs.map((c) => ( {catalogs.map((c) => (
<TableRow key={c.id}> <TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell> <TableCell className="font-medium">
<Link href={`/console/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
{c.name}
</Link>{" "}
{c.mine ? <Tag>owner</Tag> : c.team ? <Tag>team</Tag> : null}
</TableCell>
<TableCell className="font-mono text-xs">{c.url}</TableCell> <TableCell className="font-mono text-xs">{c.url}</TableCell>
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership} <span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
</TableCell> </TableCell>
@@ -51,29 +55,31 @@ export default async function ConsoleCatalogs() {
<TableCell> <TableCell>
<CatalogStatus status={c.status} /> <CatalogStatus status={c.status} />
</TableCell> </TableCell>
<TableCell className="text-right"> {moderator && (
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> {(c.status === "pending" || c.status === "rejected") && (
{c.status !== "approved" && ( <ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…"> <div className="flex justify-end gap-2">
Approve <SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
</SubmitButton> Approve
)} </SubmitButton>
{c.status !== "rejected" && ( {c.status !== "rejected" && (
<SubmitButton <SubmitButton
size="sm" size="sm"
variant="outline" variant="outline"
name="decision" name="decision"
value="reject" value="reject"
pendingLabel="Rejecting…" pendingLabel="Rejecting…"
confirm={`Reject ${c.name}?`} confirm={`Reject ${c.name}?`}
> >
Reject Reject
</SubmitButton> </SubmitButton>
)} )}
</div> </div>
</ActionForm> </ActionForm>
</TableCell> )}
</TableCell>
)}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
+42 -8
View File
@@ -1,8 +1,9 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { NavLink } from "@/components/header-client"; import { NavLink } from "@/components/header-client";
import { getMe, isModerator, type Person } from "@/lib/api"; import { api, getMe, isModerator, unwrap, type ConsoleData, type Person } from "@/lib/api";
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */ /** The signed-in moderator, or off to sign in; others get not found (these pages aren't for them). */
export async function requireModerator(path: string): Promise<Person> { export async function requireModerator(path: string): Promise<Person> {
const me = await getMe(); const me = await getMe();
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`); if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
@@ -10,14 +11,47 @@ export async function requireModerator(path: string): Promise<Person> {
return me; return me;
} }
export function ConsoleNav() { /** The console as this person sees it (once per request); undefined if it isn't for them. */
const loadConsole = cache(async (): Promise<ConsoleData | undefined> => {
const answer = await api.console.get();
if (answer.error) return undefined;
return unwrap(answer);
});
/**
* The signed-in person and their console, or off to sign in. People who manage nothing get not found: the console
* is for those controlling a product, catalog or room.
*/
export async function requireConsole(path: string): Promise<{ me: Person; console: ConsoleData }> {
const me = await getMe();
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
const data = await loadConsole();
if (!data) notFound();
return { me, console: data };
}
type Section = ConsoleData["sections"][number];
// Products are managed on their own pages for now; settings are on the overview.
const tabs: { section: Section; href: string; label: string }[] = [
{ section: "overview", href: "/console", label: "Overview" },
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
{ section: "people", href: "/console/people", label: "People" },
{ section: "audit", href: "/console/audit", label: "Audit" },
];
/** The console's sections this person may see. */
export async function ConsoleNav() {
const sections = new Set<Section>((await loadConsole())?.sections ?? ["overview"]);
return ( return (
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm"> <nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
<NavLink href="/console" exact> {tabs
Overview .filter((t) => sections.has(t.section))
</NavLink> .map((t) => (
<NavLink href="/console/people">People</NavLink> <NavLink key={t.section} href={t.href} exact={t.section === "overview"}>
<NavLink href="/console/catalogs">Catalogs</NavLink> {t.label}
</NavLink>
))}
</nav> </nav>
); );
} }
+86 -86
View File
@@ -3,61 +3,77 @@ import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms"; import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list"; import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status"; import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api, unwrap } from "@/lib/api";
import { api, unwrap, type AuditEntry } from "@/lib/api";
import { setRegistration } from "./actions"; import { setRegistration } from "./actions";
import { ConsoleNav, requireModerator } from "./console-nav"; import { AuditTable } from "./audit-table";
import { ConsoleNav, requireConsole } from "./console-nav";
export const metadata: Metadata = { title: "Console" }; export const metadata: Metadata = { title: "Console" };
type Stat = { label: string; value: number; note: string; href?: string };
export default async function Console() { export default async function Console() {
const me = await requireModerator("/console"); const { me, console: data } = await requireConsole("/console");
const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]); const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
const stats = [
{ label: "People", value: data.counts.people, note: "accounts" }, const todo: Stat[] = [
{ label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" }, { label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
{ label: "Computers", value: data.counts.computers, note: "signed in" },
]; ];
if (data.moderation) {
todo.push(
{ label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" },
{ label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" },
);
}
const mine: Stat[] = [
{ label: "Your Products", value: data.mine.products, note: "you created", href: `/products?creator=${encodeURIComponent(me.username)}` },
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
];
const hub: Stat[] = data.moderation
? [
{ label: "People", value: data.moderation.counts.people, note: "accounts" },
{ label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" },
{ label: "Computers", value: data.moderation.counts.computers, note: "signed in" },
]
: [];
return ( return (
<> <>
<PageHeader title="Console">Moderation.</PageHeader> <PageHeader title="Console">What you manage on T5DE.</PageHeader>
<ConsoleNav /> <ConsoleNav />
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <StatGrid stats={todo.filter((s) => s.value > 0 || data.moderation)} />
{stats.map((s) => ( <StatGrid stats={mine.filter((s) => s.value > 0)} />
<Card key={s.label}> {hub.length > 0 && <StatGrid stats={hub} />}
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{s.href ? (
<Link href={s.href} className="underline-offset-4 hover:underline">
{s.note}
</Link>
) : (
s.note
)}
</CardContent>
</Card>
))}
</div>
<Card> {data.moderation && (
<CardHeader> <Card>
<CardTitle>Registration</CardTitle> <CardHeader>
<CardDescription> <CardTitle>Online Now</CardTitle>
{settings.registration === "closed" <CardDescription>
? "Closed: new accounts can sign in, but wait for a moderator's approval." {data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
: "Open: new accounts can use the hub at once."} the page loaded
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{me.role === "admin" && ( <CardContent>
<OnlineList initial={data.moderation.online} moderator />
</CardContent>
</Card>
)}
{settings && (
<Card id="settings">
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
<CardContent> <CardContent>
<ActionForm action={setRegistration}> <ActionForm action={setRegistration}>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -69,28 +85,21 @@ export default async function Console() {
</div> </div>
</ActionForm> </ActionForm>
</CardContent> </CardContent>
)} </Card>
</Card> )}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Online now</CardTitle> <CardTitle>Recent Changes</CardTitle>
<CardDescription> <CardDescription>
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded {data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "}
<Link href="/console/audit" className="underline-offset-4 hover:underline">
Full audit log
</Link>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<OnlineList initial={data.online} moderator /> <AuditTable entries={data.audit} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Audit log</CardTitle>
<CardDescription>The latest 50 changes.</CardDescription>
</CardHeader>
<CardContent>
<AuditLog entries={data.audit} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -98,36 +107,27 @@ export default async function Console() {
); );
} }
function AuditLog({ entries }: { entries: AuditEntry[] }) { function StatGrid({ stats }: { stats: Stat[] }) {
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>; if (stats.length === 0) return null;
return ( return (
<Table> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<TableHeader> {stats.map((s) => (
<TableRow> <Card key={s.label}>
<TableHead>When</TableHead> <CardHeader>
<TableHead>Who</TableHead> <CardDescription>{s.label}</CardDescription>
<TableHead>What</TableHead> <CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
<TableHead>Target</TableHead> </CardHeader>
<TableHead>Detail</TableHead> <CardContent className="text-sm text-muted-foreground">
</TableRow> {s.href ? (
</TableHeader> <Link href={s.href} className="underline-offset-4 hover:underline">
<TableBody> {s.note}
{entries.map((e, i) => ( </Link>
<TableRow key={i}> ) : (
<TableCell className="text-muted-foreground"> s.note
<DateTime iso={e.at} /> )}
</TableCell> </CardContent>
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell> </Card>
<TableCell className="font-mono text-xs">{e.action}</TableCell> ))}
<TableCell>{e.target}</TableCell> </div>
<TableCell className="text-xs whitespace-normal text-muted-foreground">
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(", ")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
); );
} }
+16 -2
View File
@@ -19,7 +19,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const [me, data] = await Promise.all([getMe(), loadProduct(id)]); const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
const { product, revisions, inLibrary, stayOn, mine } = data; const { product, revisions, inLibrary, stayOn, mine, available, catalogs } = data;
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined; const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
return ( return (
@@ -62,10 +62,24 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
Remove Remove
</SubmitButton> </SubmitButton>
</> </>
) : ( ) : available ? (
<SubmitButton name="intent" value="get" pendingLabel="Getting…"> <SubmitButton name="intent" value="get" pendingLabel="Getting…">
Get Get
</SubmitButton> </SubmitButton>
) : (
<span className="text-sm text-muted-foreground">
Members only. Join{" "}
{catalogs.map((c, i) => (
<span key={c.id}>
{i > 0 && (i === catalogs.length - 1 ? " or " : ", ")}
<Link href={`/catalogs/${c.id}`} className="font-medium text-foreground underline-offset-4 hover:underline">
{c.name}
</Link>
{c.joined === "requested" && " (asked)"}
</span>
))}{" "}
to get it.
</span>
)} )}
</div> </div>
</ActionForm> </ActionForm>
+15 -5
View File
@@ -8,15 +8,24 @@ import { api, getMe, unwrap } from "@/lib/api";
export const metadata: Metadata = { title: "Products" }; export const metadata: Metadata = { title: "Products" };
export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { export default async function Products({
const raw = (await searchParams).q; searchParams,
const q = typeof raw === "string" ? raw.trim().slice(0, 100) : ""; }: {
const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]); searchParams: Promise<{ q?: string | string[]; creator?: string | string[] }>;
}) {
const params = await searchParams;
const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
const q = one(params.q, 100);
const creator = one(params.creator, 64);
const [me, { products }] = await Promise.all([
getMe(),
api.products.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}) } }).then(unwrap),
]);
return ( return (
<> <>
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">Community products for T5DE.</PageHeader> <PageHeader title="Products">{creator ? `By ${creator}.` : "Community products for T5DE."}</PageHeader>
{me && ( {me && (
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}> <Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product New product
@@ -25,6 +34,7 @@ export default async function Products({ searchParams }: { searchParams: Promise
</div> </div>
<form action="/products" className="mb-6 flex w-full max-w-md gap-2"> <form action="/products" className="mb-6 flex w-full max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" /> <Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
{creator && <input type="hidden" name="creator" value={creator} />}
<Button type="submit" variant="outline"> <Button type="submit" variant="outline">
Search Search
</Button> </Button>
+2 -2
View File
@@ -2,9 +2,9 @@ import { Tag } from "@/components/status";
export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" }; export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" };
/** A catalog's review status: approved, pending or rejected. */ /** A catalog's status: approved, pending, rejected or suspended. */
export function CatalogStatus({ status }: { status: string }) { export function CatalogStatus({ status }: { status: string }) {
if (status === "approved") return <Tag tone="strong">approved</Tag>; if (status === "approved") return <Tag tone="strong">approved</Tag>;
if (status === "rejected") return <Tag tone="danger">rejected</Tag>; if (status === "rejected" || status === "suspended") return <Tag tone="danger">{status}</Tag>;
return <Tag>{status}</Tag>; return <Tag>{status}</Tag>;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { getMe, isModerator } from "@/lib/api"; import { getMe } from "@/lib/api";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { NavLink, ProfileMenu } from "./header-client"; import { NavLink, ProfileMenu } from "./header-client";
import logo from "./t5de.png"; import logo from "./t5de.png";
@@ -28,7 +28,7 @@ export async function Header() {
Download Download
</a> </a>
{me ? ( {me ? (
<ProfileMenu username={me.username} picture={me.picture} console={isModerator(me)} /> <ProfileMenu username={me.username} picture={me.picture} console={me.console} />
) : ( ) : (
<> <>
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}> <Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
+5
View File
@@ -41,6 +41,11 @@ export type AgeInfo = Data<V1["age"]["get"]>;
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number]; export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
export type ConsoleData = Data<V1["console"]["get"]>; export type ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number]; export type AuditEntry = ConsoleData["audit"][number];
export type CatalogPage = Data<ReturnType<V1["catalogs"]>["get"]>;
export type CatalogMember = Data<ReturnType<V1["catalogs"]>["members"]["get"]>["members"][number];
export type CatalogInvite = Data<ReturnType<V1["catalogs"]>["invites"]["get"]>["invites"][number];
export type HostedProduct = Data<ReturnType<V1["catalogs"]>["products"]["get"]>["products"][number];
export type CatalogTeam = Data<ReturnType<V1["catalogs"]>["team"]["get"]>;
export type ProductSummary = Data<V1["products"]["get"]>["products"][number]; export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>; export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
export type Revision = ProductPage["revisions"][number]; export type Revision = ProductPage["revisions"][number];
+14 -3
View File
@@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. | | `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. |
| `POST /api/auth/sign-in/username` | `{username, password}` | | `POST /api/auth/sign-in/username` | `{username, password}` |
| `POST /api/auth/sign-out` | | | `POST /api/auth/sign-out` | |
| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` | | `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null`, and `console`: whether the console is for them |
| `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` | | `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` |
## Proxies ## Proxies
@@ -103,11 +103,22 @@ person-level providers (phone wallets, document checks) are started here.
| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers | | `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers |
| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) | | `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) |
## Console (moderators; roles: admins) ## Console
Permissions, roles and scopes are in docs/console.md. A missing permission answers 403 `forbidden` with
`missing: {permission, scope}`. Catalog management routes are in docs/products.md.
| | |
| --- | --- |
| `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see |
| `GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit=` | `{entries, before}`: entries the person may see (docs/console.md), newest first; `action` matches a prefix, `from`/`to` are days, `before` an entry ID for the next page. Entries are `{id, at, actor, action, target, tags, tagNames, detail}` |
| `GET /api/v1/console/permissions?catalog=&product=` | `{roles, permissions}` at that scope (the hub if neither) |
| `GET /api/v1/console/catalogs` | `{catalogs, moderator}`: every catalog for moderators, else those on whose team the person is |
Moderators:
| | | | | |
| --- | --- | | --- | --- |
| `GET /api/v1/console` | `{counts, connections, online, audit}` |
| `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) | | `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) |
| `POST /api/v1/console/people/:id/approve` | `{person}` | | `POST /api/v1/console/people/:id/approve` | `{person}` |
| `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it | | `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it |
+2
View File
@@ -470,6 +470,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md):
1. Permissions and console foundations: the permission check, roles, the console for everyone who controls 1. Permissions and console foundations: the permission check, roles, the console for everyone who controls
something, a tagged audit log, membership enforced, catalog settings, team and key rotation, suspension. something, a tagged audit log, membership enforced, catalog settings, team and key rotation, suspension.
**Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases
(phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles.
2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category. 2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category.
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens; 3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
two-factor sign-in for sensitive permissions. two-factor sign-in for sensitive permissions.
@@ -0,0 +1,62 @@
CREATE TABLE "catalog_change" (
"id" serial PRIMARY KEY NOT NULL,
"catalog_id" text NOT NULL,
"data" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "catalog_invite" (
"code" text PRIMARY KEY NOT NULL,
"catalog_id" text NOT NULL,
"created_by" text,
"max_uses" integer,
"uses" integer DEFAULT 0 NOT NULL,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "catalog_member" (
"catalog_id" text NOT NULL,
"user_id" text NOT NULL,
"status" text NOT NULL,
"invite" text,
"decided_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "catalog_member_catalog_id_user_id_pk" PRIMARY KEY("catalog_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "role_assignment" (
"user_id" text NOT NULL,
"role" text NOT NULL,
"scope_type" text NOT NULL,
"scope_id" text NOT NULL,
"granted_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "role_assignment_user_id_role_scope_type_scope_id_pk" PRIMARY KEY("user_id","role","scope_type","scope_id")
);
--> statement-breakpoint
ALTER TABLE "audit" ADD COLUMN "tags" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "catalog" ADD COLUMN "status_reason" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "catalog" ADD COLUMN "max_rating" text DEFAULT 'adult' NOT NULL;--> statement-breakpoint
ALTER TABLE "catalog" ADD COLUMN "uploads" text DEFAULT 'anyone' NOT NULL;--> statement-breakpoint
ALTER TABLE "catalog" ADD COLUMN "mirrors" text DEFAULT 'approve' NOT NULL;--> statement-breakpoint
ALTER TABLE "catalog" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "catalog_change" ADD CONSTRAINT "catalog_change_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_decided_by_user_id_fk" FOREIGN KEY ("decided_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_granted_by_user_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "catalog_change_catalog" ON "catalog_change" USING btree ("catalog_id","id");--> statement-breakpoint
CREATE INDEX "catalog_invite_catalog" ON "catalog_invite" USING btree ("catalog_id");--> statement-breakpoint
CREATE INDEX "catalog_member_user" ON "catalog_member" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "role_assignment_scope" ON "role_assignment" USING btree ("scope_type","scope_id");--> statement-breakpoint
CREATE INDEX "audit_tags" ON "audit" USING gin ("tags");--> statement-breakpoint
-- Tags for entries written before tags existed (target was the catalog, product or room).
UPDATE "audit" SET "tags" = jsonb_build_object('catalog', "target") WHERE "action" LIKE 'catalog.%' AND "target" <> '';--> statement-breakpoint
UPDATE "audit" SET "tags" = jsonb_build_object('product', "target") WHERE "action" LIKE 'product.%' AND "target" <> '';--> statement-breakpoint
UPDATE "audit" SET "tags" = jsonb_build_object('room', "target") WHERE "action" LIKE 'hosted_room.%' AND "target" <> '';
File diff suppressed because it is too large. Load diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1793707706030, "when": 1793707706030,
"tag": "0009_registration", "tag": "0009_registration",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1793862017365,
"tag": "0010_catalog_management",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
import * as schema from "./schema"; import * as schema from "./schema";
export { schema }; export { schema };
export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema"; export type { AuditTags, DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
export * from "drizzle-orm"; export * from "drizzle-orm";
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
+83 -2
View File
@@ -221,10 +221,15 @@ export const audit = pgTable(
action: text("action").notNull(), action: text("action").notNull(),
target: text("target").notNull().default(""), target: text("target").notNull().default(""),
detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}), detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}),
// The resources the change touches ({catalog, product, room, person}): who may see the entry (docs/console.md).
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
}, },
(t) => [index("audit_at").on(t.at)], (t) => [index("audit_at").on(t.at), index("audit_tags").using("gin", t.tags)],
); );
/** The resources an audit entry touches. */
export type AuditTags = { catalog?: string; product?: string; room?: string; person?: string };
// ---- Products (docs/products.md) ---- // ---- Products (docs/products.md) ----
export type FileEntry = { name: string; sha256: string; size: number }; export type FileEntry = { name: string; sha256: string; size: number };
@@ -259,13 +264,89 @@ export const catalog = pgTable(
contact: text("contact").notNull(), // takedown and legal contact contact: text("contact").notNull(), // takedown and legal contact
visibility: text("visibility").notNull().default("public"), // "public", "private" visibility: text("visibility").notNull().default("public"), // "public", "private"
membership: text("membership").notNull().default("open"), // "open", "approval", "invite" membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected" status: text("status").notNull().default("pending"), // "pending", "approved", "rejected", "suspended"
statusReason: text("status_reason").notNull().default(""), // why it was rejected or suspended
maxRating: text("max_rating").notNull().default("adult"), // the highest rating it hosts: "general", "adult"
uploads: text("uploads").notNull().default("anyone"), // who may upload: "anyone", "members", "team"
mirrors: text("mirrors").notNull().default("approve"), // mirroring others' revisions: "auto", "approve", "off"
keyHash: text("key_hash").notNull(), // sha256 of its key, hex keyHash: text("key_hash").notNull(), // sha256 of its key, hex
createdAt: createdAt(), createdAt: createdAt(),
updatedAt: updatedAt(),
}, },
(t) => [uniqueIndex("catalog_url").on(t.url)], (t) => [uniqueIndex("catalog_url").on(t.url)],
); );
// A role given to a person at a scope (docs/console.md). Hub-wide roles are user.role; owners are derived (a
// catalog's owner_id, a product's creator_id), so they're never here.
export const roleAssignment = pgTable(
"role_assignment",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").notNull(), // e.g. "catalog-operator"
scopeType: text("scope_type").notNull(), // "catalog"
scopeId: text("scope_id").notNull(),
grantedBy: text("granted_by").references(() => user.id, { onDelete: "set null" }),
createdAt: createdAt(),
},
(t) => [
primaryKey({ columns: [t.userId, t.role, t.scopeType, t.scopeId] }),
index("role_assignment_scope").on(t.scopeType, t.scopeId),
],
);
// A person's membership of a catalog: asked to join, a member, or banned from it.
export const catalogMember = pgTable(
"catalog_member",
{
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: text("status").notNull(), // "requested", "member", "banned"
invite: text("invite"), // the invite code they joined with
decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [primaryKey({ columns: [t.catalogId, t.userId] }), index("catalog_member_user").on(t.userId)],
);
// An invitation to join a catalog: a code, used up to maxUses times (null: no limit) until it expires.
export const catalogInvite = pgTable(
"catalog_invite",
{
code: text("code").primaryKey(),
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }),
maxUses: integer("max_uses"),
uses: integer("uses").notNull().default(0),
expiresAt: timestamp("expires_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [index("catalog_invite_catalog").on(t.catalogId)],
);
// What a catalog must act on (GET /api/v1/catalog/changes): files to delete, revisions to mirror, its status.
export const catalogChange = pgTable(
"catalog_change",
{
id: serial("id").primaryKey(),
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
data: jsonb("data").$type<Record<string, unknown>>().notNull(), // {type, ...}
createdAt: createdAt(),
},
(t) => [index("catalog_change_catalog").on(t.catalogId, t.id)],
);
// A product: its info, owned by its creator. Its files are in revisions. // A product: its info, owned by its creator. Its files are in revisions.
export const product = pgTable( export const product = pgTable(
"product", "product",