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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
// Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of
// permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's
// creator), and other roles are role_assignment rows.
import { and, eq, schema, type Database } from "@t5de/db";
const { catalog, product, roleAssignment, user } = schema;
export const permissions = [
"product:edit",
"product:edit-locked",
"product:lock",
"product:rate-lower",
"product:publish",
"product:delete",
"product:restore",
"product:remove",
"product:unremove",
"product:transfer",
"revision:upload",
"revision:retract",
"catalog:approve",
"catalog:edit",
"catalog:rotate-key",
"catalog:suspend",
"catalog:delete-request",
"catalog:delete-review",
"catalog:transfer",
"team:manage",
"member:approve",
"member:invite",
"member:remove",
"member:ban",
"hosting:choose",
"hosting:delist",
"import:grant",
"import:grant-on-behalf",
"import:accept",
"import:run",
"import:pause",
"import:withdraw",
"case:read",
"case:resolve",
"case:assign",
"audit:read",
"audit:read-sensitive",
"stats:read",
"person:approve",
"person:ban",
"person:link-cap",
"person:notes",
"person:role",
"person:view-as",
"room:edit",
"room:close",
"room:kick",
"room:ban",
"room:mute",
"settings:edit",
] as const;
export type Permission = (typeof permissions)[number];
/** Where a permission applies. The hub covers everything. */
export type Scope = { type: "hub" } | { type: "catalog"; id: string } | { type: "product"; id: string };
export const hubScope: Scope = { type: "hub" };
const all = (prefix: string) => permissions.filter((p) => p.startsWith(prefix + ":"));
const without = (list: readonly Permission[], ...drop: Permission[]) => list.filter((p) => !drop.includes(p));
const catalogOwner: Permission[] = [
"catalog:edit",
"catalog:rotate-key",
"catalog:delete-request",
"catalog:transfer",
"team:manage",
...all("member"),
"hosting:delist",
"import:grant",
"import:accept",
"import:run",
"import:pause",
"import:withdraw",
"case:read",
"case:resolve",
"audit:read",
"stats:read",
];
/** The built-in roles: name, the scope they're given at, and their permissions. */
export const roles = {
admin: { name: "Admin", scope: "hub", permissions: [...permissions] as Permission[] },
moderator: {
name: "Moderator",
scope: "hub",
permissions: [
...without(all("product"), "product:transfer"),
"revision:retract",
"catalog:approve",
"catalog:suspend",
"catalog:delete-review",
"hosting:delist",
"import:grant-on-behalf",
"import:withdraw",
"import:pause",
...all("case"),
"person:approve",
"person:ban",
"person:link-cap",
"person:notes",
"audit:read",
"stats:read",
...all("room"),
] as Permission[],
},
"catalog-owner": { name: "Owner", scope: "catalog", permissions: catalogOwner },
"catalog-operator": {
name: "Operator",
scope: "catalog",
permissions: without(catalogOwner, "catalog:delete-request", "catalog:transfer", "team:manage"),
},
creator: {
name: "Creator",
scope: "product",
permissions: [
"product:edit",
"product:publish",
"product:delete",
"product:restore",
"product:transfer",
"revision:upload",
"revision:retract",
"hosting:choose",
"case:read",
"audit:read",
"stats:read",
] as Permission[],
},
} as const satisfies Record<string, { name: string; scope: string; permissions: Permission[] }>;
export type RoleId = keyof typeof roles;
/** Roles that can be given at a catalog (the owner is derived, never given). */
export const assignableCatalogRoles = ["catalog-operator"] as const;
/** A person, as the check needs them. */
export type Who = { id: string; role: string };
/** Thrown when a permission is missing; the API answers 403 with what was missing. */
export class Forbidden extends Error {
constructor(
readonly permission: Permission,
readonly scope: Scope,
) {
super("You don't have permission to do that.");
}
}
export class Permissions {
constructor(private readonly db: Database) {}
/** A person's hub-wide permissions (their user.role). */
hub(who: Who | undefined): Set<Permission> {
if (who?.role === "admin") return new Set(roles.admin.permissions);
if (who?.role === "moderator") return new Set(roles.moderator.permissions);
return new Set();
}
/** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */
async rolesAt(who: Who | undefined, scope: Scope): Promise<RoleId[]> {
if (!who) return [];
const out: RoleId[] = [];
if (who.role === "admin") out.push("admin");
else if (who.role === "moderator") out.push("moderator");
if (scope.type === "catalog") {
const [c] = await this.db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, scope.id));
if (c?.ownerId === who.id) out.push("catalog-owner");
const given = await this.db
.select({ role: roleAssignment.role })
.from(roleAssignment)
.where(
and(eq(roleAssignment.userId, who.id), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, scope.id)),
);
for (const g of given) if (g.role in roles) out.push(g.role as RoleId);
} else if (scope.type === "product") {
const [p] = await this.db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, scope.id));
if (p?.creatorId === who.id) out.push("creator");
}
return out;
}
/** Every permission a person holds at a scope. */
async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> {
const out = new Set<Permission>();
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) out.add(p);
return out;
}
async can(who: Who | undefined, permission: Permission, scope: Scope): Promise<boolean> {
if (this.hub(who).has(permission)) return true;
if (scope.type === "hub") return false;
return (await this.at(who, scope)).has(permission);
}
/** Throws Forbidden unless the person holds the permission at the scope. */
async require(who: Who | undefined, permission: Permission, scope: Scope): Promise<void> {
if (!(await this.can(who, permission, scope))) throw new Forbidden(permission, scope);
}
/** May `who` give (or take away) `role` at `scope`? Only if they hold every permission in it there. */
async mayGive(who: Who | undefined, role: RoleId, scope: Scope): Promise<boolean> {
const held = await this.at(who, scope);
return roles[role].permissions.every((p) => held.has(p));
}
/** The catalogs a person is on the team of (owner or a role there). */
async teamCatalogs(personId: string): Promise<string[]> {
const owned = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.ownerId, personId));
const given = await this.db
.select({ id: roleAssignment.scopeId })
.from(roleAssignment)
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog")));
return [...new Set([...owned.map((c) => c.id), ...given.map((g) => g.id)])];
}
/** The products a person created. */
async ownProducts(personId: string): Promise<string[]> {
return (await this.db.select({ id: product.id }).from(product).where(eq(product.creatorId, personId))).map((p) => p.id);
}
/** A catalog's team: the owner and everyone given a role there, with their names. */
async team(catalogId: string) {
const [c] = await this.db
.select({ ownerId: catalog.ownerId, name: user.displayUsername, username: user.username })
.from(catalog)
.innerJoin(user, eq(user.id, catalog.ownerId))
.where(eq(catalog.id, catalogId));
const given = await this.db
.select({
personId: roleAssignment.userId,
role: roleAssignment.role,
name: user.displayUsername,
username: user.username,
since: roleAssignment.createdAt,
})
.from(roleAssignment)
.innerJoin(user, eq(user.id, roleAssignment.userId))
.where(and(eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId)));
return {
owner: c ? { personId: c.ownerId, username: c.name ?? c.username ?? "" } : undefined,
roles: given.map((g) => ({
personId: g.personId,
role: g.role,
username: g.name ?? g.username ?? "",
since: g.since.toISOString(),
})),
};
}
}