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,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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user