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,
schema,
type Database,
type AuditTags,
type ParentInfo,
type RoomModerator,
} from "@t5de/db";
@@ -25,7 +26,7 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { ProductError, Products } from "./products";
import { availableTo, ProductError, Products } from "./products";
import {
chatId,
HostedError,
@@ -41,8 +42,10 @@ import {
} from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
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 = {
db: Database;
@@ -55,6 +58,8 @@ export type Deps = {
apiURL?: string;
/** The website's address: providers send people back only to it. */
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). */
settings?: Settings;
};
@@ -65,11 +70,19 @@ export class Problem extends Error {
readonly status: number,
readonly slug: string,
message: string,
/** More for the website, e.g. the permission that was missing. */
readonly extra: Record<string, unknown> = {},
) {
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 Link = typeof imvuLink.$inferSelect;
@@ -117,11 +130,14 @@ export function createApp({
apiURL,
webURL,
settings: hubSettings,
catalogHealthy,
}: Deps) {
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const settings = hubSettings ?? new Settings(db);
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
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 hosted = new HostedRooms(db);
const decorations = new Decorations(db);
@@ -154,6 +170,13 @@ export function createApp({
hostedVisit: (userId, n, imvuId) => {
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();
@@ -220,8 +243,33 @@ export function createApp({
};
const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!;
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) =>
db.insert(audit).values({ actorId, action, target, detail });
/** Writes an audit entry. Tags (who may see it, docs/console.md) default from the action: catalog.*, product.*
* and hosted_room.* entries are about their target. */
const audited = (
actorId: string | null,
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). */
const visibleOnline = async (viewer: Person | undefined) => {
@@ -298,9 +346,124 @@ export function createApp({
visibility: c.visibility,
membership: c.membership,
status: c.status,
statusReason: c.statusReason,
maxRating: c.maxRating,
uploads: c.uploads,
mirrors: c.mirrors,
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. */
const hostedCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
@@ -452,7 +615,7 @@ export function createApp({
.onError(({ code, error, set }) => {
if (code === "Problem") {
set.status = error.status;
return { error: error.slug, message: error.message };
return { error: error.slug, message: error.message, ...error.extra };
}
if (code === "VALIDATION") {
set.status = 400;
@@ -501,7 +664,19 @@ export function createApp({
})
// 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.
.get("/hub", async () => ({ registration: await settings.registration() }))
.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 })),
}))
// 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.
.post(
"/catalog/revisions",
@@ -1170,6 +1637,14 @@ export function createApp({
inLibrary: lib !== undefined,
stayOn: lib?.stayOn ?? null,
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 },
@@ -1262,7 +1737,14 @@ export function createApp({
.put(
"/library/:id",
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" });
return { ok: true };
},
@@ -1388,8 +1870,17 @@ export function createApp({
// The console.
.get(
"/console/catalogs",
async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }),
{ moderator: true },
async ({ me }) => {
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(
"/console/catalogs/:id/:decision",
@@ -1405,28 +1896,89 @@ export function createApp({
.get(
"/console",
async ({ me }) => {
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`,
})
.from(sql`(select 1) as one`);
const log = await db
.select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail })
.from(audit)
.leftJoin(user, eq(user.id, audit.actorId))
.orderBy(desc(audit.id))
.limit(50);
const mod = isModerator(me);
const scope = await consoleScope(me);
const sections: ConsoleSection[] = ["overview"];
if (mod || scope.products.length) sections.push("products");
if (mod || scope.catalogs.length) sections.push("catalogs");
if (mod) sections.push("people");
if (sections.length > 1 || scope.rooms.length) sections.push("audit");
if (me.role === "admin") sections.push("settings");
if (sections.length === 1)
throw new Problem(403, "forbidden", "The console is for people who manage something on T5DE: a product, a catalog or a room.");
const [requests] = scope.catalogs.length
? await db
.select({ n: count() })
.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 {
counts: counts!,
connections: realtime.connections(),
online: await visibleOnline(me),
audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })),
sections,
mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length },
todo: {
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(
"/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 { 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 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.
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). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
@@ -123,12 +138,13 @@ export class Products {
return { catalog: c!, key };
}
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved and the key is right. */
async catalogFor(header: string | null): Promise<Catalog | undefined> {
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved (or suspended, when asked)
* 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+)$/);
if (!m) return undefined;
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;
}
@@ -163,6 +179,23 @@ export class Products {
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 (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(
"t5de-upload",
{ 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 (body.rating !== "general" && body.rating !== "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 notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
const personId = claims.sub;
@@ -427,7 +462,7 @@ export class Products {
.innerJoin(product, eq(product.id, library.productId))
.innerJoin(user, eq(user.id, product.creatorId))
.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));
}
@@ -443,9 +478,28 @@ export class Products {
const p = await this.get(productId, adult);
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
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();
}
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) {
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;
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
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 chatPattern = /^[0-9]{1,20}$/;
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
@@ -179,6 +186,7 @@ export class Realtime {
hostedBoot: async () => {},
hostedVisit: () => {},
accountName: async () => "",
wearable: async (_, ids) => new Set(ids),
...d,
};
}
@@ -644,10 +652,16 @@ export class Realtime {
const items = m.items ?? [];
if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
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.id && it.id.length <= 128 && it.name.length <= 128)
.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());
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();
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;
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
expect(consoleView.counts.computers).toBe(1);
expect(consoleView.moderation.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
expect(consoleView.moderation.counts.computers).toBe(1);
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
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);
// 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;
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);