feat: permissions, a scoped console, and catalog management (#7)
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:
1 parent
ed3a188b82
commit
2cbedd26fc
29 files changed
+5657
-181
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user