feat: permissions, a console for everyone who manages something, and catalog management
Phase 1 of docs/console.md: permissions as resource:action strings in role bundles at a scope; the console for anyone controlling a catalog, product or room, with a tagged audit log scoped by permission and filters; catalog settings (rating ceiling, upload policy), key rotation, operators, membership (approval, invitations, bans) enforced when getting and wearing products, delisting, suspension, and the catalogs' changes feed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
76497c9b41
commit
370ae0646a
29 files changed
+5657
-181
No files matched your search
+580
-28
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user