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]>
260 lines
8.5 KiB
TypeScript
260 lines
8.5 KiB
TypeScript
// 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(),
|
|
})),
|
|
};
|
|
}
|
|
}
|