capabilities: the api, and one honest exception

GET /api/user/capabilities is what the caller may reach, and every account
may ask — it is mounted on a core capability so an account granted almost
nothing can still find out what it has. the dock and route guards read it.
it is a courtesy, never enforcement: hiding an icon is not access control
and the 403 in origin-validation stays the lock.

GET/PUT /api/users/capabilities edit the policy, owner-gated. the write path
is where the registry's authority over capability keys is applied, which is
why the column has no CHECK: unknown keys and non-app kinds are refused
rather than stored for the resolver to drop on read.

the exception is `selfService`. useAuth calls PUT /api/users to change your
own name and avatar, and that route has always lived on the same router as
the owner-only account administration around it — so declaring /users an
admin capability locked every member out of their own profile. moving it to
/api/user would be tidier and would break every shipped mobile client, so
instead the registry says out loud that this one route is not what the
capability around it is. exact method and exact path, so it cannot widen:
verified that PUT /api/users passes while GET /api/users, PATCH
/api/users/:id/role, DELETE /api/users/:id and PUT /api/users/:id are all
still refused.

23 unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:57:56 +00:00
co-authored by Claude Opus 5
parent 8e9d53b2d2
commit dbd471c32d
6 changed files with 169 additions and 0 deletions
+30
View File
@@ -67,6 +67,18 @@ export type Capability = {
* Written relative to the capability's `api` prefix, like `personal`.
*/
readOnlyWrites?: string[];
/**
* Routes any authenticated account may call even holding NO grant on this capability, because they act
* on the caller themselves. `METHOD /exact/path`, relative to the capability's prefix — exact, not a
* prefix, so this cannot widen by accident.
*
* One entry exists and it should stay that way. `PUT /api/users` is self-profile update (useAuth.ts
* calls it to change your own name and avatar) and has always lived on the same router as the owner-only
* account administration beside it. Moving it to `/api/user` would be tidier and would break every
* shipped mobile client, so the honest fix is to say out loud that this one route is not what the
* capability around it is.
*/
selfService?: string[];
};
export const CAPABILITIES: Capability[] = [
@@ -297,6 +309,9 @@ export const CAPABILITIES: Capability[] = [
kind: 'admin',
api: ['/users'],
routes: ['/settings/user-management'],
// Changing your own name, username and avatar. Owner-only account administration is every other route
// on this router and stays owner-only — see ownerGate in users-router.ts, which is the second lock.
selfService: ['PUT /'],
},
{
key: 'headscale',
@@ -357,6 +372,21 @@ export function capabilityForWsProvider(provider: string): Capability | null {
return CAPABILITIES.find((c) => c.ws?.includes(provider)) ?? null;
}
/**
* Is this an exact self-service route — one any authenticated account may call without holding the
* capability at all? Matched exactly on method AND path, never as a prefix.
*/
export function isSelfServiceRoute(capability: Capability, method: string, path: string): boolean {
if (!capability.selfService?.length) return false;
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
const upper = method.toUpperCase();
return capability.api.some((prefix) => {
if (!isPrefixOf(prefix, rest)) return false;
const sub = rest.slice(prefix.length) || '/';
return capability.selfService!.includes(`${upper} ${sub}`);
});
}
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**