role CRUD, with a guard the spec did not ask for

Phase 2 part 3 of docs/roles-and-teams.md. Create, rename and delete, each
returning a discriminated result rather than throwing, so a router can say which
rule refused and why instead of parsing an error string. Written by a Sonnet
subagent against the plan; reviewed, exercised against the live database, and
amended here.

The amendment is the reason a review was worth doing. renameRole changes
roles.name, and there is no foreign key yet — part 2 ships ON UPDATE CASCADE as
its own release — so a rename would leave every holder pointing at a name that no
longer exists. It is unreachable today only by accident: manage-users.ts validates
against the USER_ROLES union, so a runtime-created role cannot be assigned and a
non-system role has no holders. That evaporates the moment part 4 makes UserRole a
plain string, and parts 2 and 4 could land in either order.

So rename now refuses while anybody holds it, sharing one holder-count definition
with delete so the two cannot disagree about what "in use" means. It comes out when
the cascade lands, and then the cascade does the job properly by moving holders
rather than refusing.

Adding that reason made the router's switch non-exhaustive, which the typechecker
caught — the discriminated-result shape earning its keep on its first change.

System roles refuse both rename and delete: the platform's own code names them by
string, so renaming one is a code change that has not happened. Delete never
reassigns holders to a fallback, because that is a permission change nobody asked
for.

Exercised end to end: duplicate, empty name, system rename, system delete, a real
rename, a rename onto an existing name, delete, and delete of something absent.
The four seeded roles survive with the owner counted against Super Admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-23 15:09:52 +00:00
co-authored by Claude Opus 5
parent 1e268d553d
commit 04c1a6bfc9
6 changed files with 276 additions and 3 deletions
+109
View File
@@ -0,0 +1,109 @@
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { listRoles, createRole, renameRole, deleteRole } from 'officerdb';
/*
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md).
This is the roles TABLE — a name, a description, the `isSystem` flag. It is not the GRANTS a role
holds; that stays `role_permissions`, administered at `/api/users/permissions` by
`permissionAdminRouter`. The two are separate screens for a reason: this router answers "which roles
exist", that one answers "what may each reach" — and deleting a role here has to know the second
question's table too, because the guard below is "does anyone hold it", not "what does it grant".
── Owner only, explicitly ──
`roles` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner
before a request reaches here. This is the same belt-and-braces every other admin router in this
directory adds on top of that (app-store, server-admin): a mistake in the registry must not turn into
the ability to rename or delete a role.
── Why delete refuses instead of reassigning ──
D4 in the design doc: never move a role's holders onto a fallback. That is a permission change nobody
asked for, and "absence denies" — the posture everywhere else in this system — means a role with
accounts still on it stays exactly as reachable as it was until every one of them is moved off by
someone who chose to.
*/
export const rolesRouter = createRouter();
rolesRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Role management is owner-only');
return next();
});
/** GET /api/roles — every role, with how many accounts hold each. */
rolesRouter.get('/', async (ctx) => ctx.json({ roles: await listRoles() }));
/** POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`. */
rolesRouter.post('/', async (ctx) => {
const body = ctx.get('body') as { name?: unknown; description?: unknown };
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
const description = typeof body.description === 'string' ? body.description : null;
const result = await createRole({ name: body.name, description });
if (!result.ok) {
throw result.reason === 'invalid-name'
? errors.BAD_REQUEST('a role name cannot be empty')
: errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
}
return ctx.json({ role: result.role });
});
/**
* PATCH /api/roles/:name — rename. Refuses a system role outright: `Developer`, `Super Admin` and
* `Member` are tested by string in code that has not been updated to read this table (see D4), so
* renaming one of those would silently detach the code's own idea of who holds what.
*/
rolesRouter.patch('/:name', async (ctx) => {
const from = ctx.req.param('name');
const body = ctx.get('body') as { name?: unknown };
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
const result = await renameRole(from, body.name);
if (!result.ok) {
switch (result.reason) {
case 'not-found':
throw errors.NOT_FOUND(`no such role '${from}'`);
case 'system':
throw errors.FORBIDDEN(
`'${from}' is a system role and the platform's own code names it — it cannot be renamed`,
);
case 'invalid-name':
throw errors.BAD_REQUEST('a role name cannot be empty');
case 'duplicate':
throw errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
case 'in-use':
// Refused while held, because there is no ON UPDATE CASCADE yet — see `renameRole`. The count is
// in the message because 'in use' without a number is something nobody can act on.
throw errors.CONFLICT(
`'${from}' is held by ${result.holderCount ?? 'some'} account(s) — renaming it would leave them ` +
'pointing at a role that no longer exists',
);
}
}
return ctx.json({ role: result.role });
});
/** DELETE /api/roles/:name — refuses a system role, and refuses one any account still holds. */
rolesRouter.delete('/:name', async (ctx) => {
const name = ctx.req.param('name');
const result = await deleteRole(name);
if (!result.ok) {
switch (result.reason) {
case 'not-found':
throw errors.NOT_FOUND(`no such role '${name}'`);
case 'system':
throw errors.FORBIDDEN(
`'${name}' is a system role and the platform's own code names it — it cannot be deleted`,
);
case 'in-use':
throw errors.CONFLICT(
`${result.holderCount} account(s) still hold '${name}' — move them to another role first`,
);
}
}
return ctx.json({ ok: true });
});