Permissions
Every authorization question in the API has the same shape: can this actor do this action? Both the server's REST middleware and the WebSocket Gateway funnel through one can() function — there is no second source of truth.
Action variants
An Action is a discriminated union. The type names what's being attempted; additional fields carry whatever resource context is needed to answer the question.
export type Action =
| { type: 'users.read' }
| { type: 'users.write' }
| { type: 'items.write' }
| { type: 'channels.write_scene' }
| { type: 'equipment.write' }
| { type: 'analytics.read' }
| { type: 'analytics.write' };
These are the live actions today. New ones land as new variants on the union, and the exhaustive switch inside can() won't compile until they're handled — so a new permission can never be silently forgotten.
- Name
users.read- Description
Read another user's row, search by username prefix, list per-user event feeds.
- Name
users.write- Description
Admin edits on
/v1/users/:userId(suspend, role change, manual verify).
- Name
items.write- Description
Create / update / delete catalog rows, plus all three upload endpoints under
/v1/items/uploads/*.
- Name
channels.write_scene- Description
Patch a
HUBorHOMEchannel'ssceneJSON. The Wormhole service principal is the normal caller; other fields on the samePATCH /v1/channels/:idroute stay open to regular users.
- Name
equipment.write- Description
Bulk equipment update on someone else's behalf via
PATCH /v1/users/:userId/equipment. Self-equip onPUT /v1/users/@me/equipment/:slotneeds no permission.
- Name
analytics.read- Description
Aggregate dashboards plus the per-user event feed at
/v1/users/:userId/events.
- Name
analytics.write- Description
Cross-service event ingest via
POST /v1/analytics/events. The wormhole game server recordsFISH_CAUGHT,WARP_JOIN, etc. on the user's behalf — events about a user's actions must be server-attested to be trustworthy.
The middleware
requirePermission is a thin shim over can() for route declarations. Pass either a static Action (for global flags) or a function that builds one from the request:
apps/api/src/v1/routes/items.route.ts
items.post(
'/',
...userOnly,
requirePermission({ type: 'items.write' }),
zValidator('json', CreateItemSchema),
createItem,
);
items.delete(
'/:itemId',
...userOnly,
requirePermission({ type: 'items.write' }),
deleteItem,
);
A failed check throws ApiError.forbidden('insufficient_permissions'). The middleware runs after the auth + token-type checks (so a bot token never reaches a user-only permission gate) but before the controller, so resource loading happens at most once per request.
Calling can() outside middleware
The Gateway and service-internal codepaths call can() directly. Same answers, same caching, same source of truth.
apps/gateway/src/handlers/typing-start.ts
const allowed = await can(socket.userId, {
type: 'channel.send_messages',
channelId: payload.channel_id,
});
if (!allowed) return;
broadcastTypingStart(payload.channel_id, socket.userId);
The point of one function isn't symmetry, it's auditability: when you ask "where does the API check X?", the answer is exactly one place.
The four layers
Authorization is enforced in layers. Earlier layers are cheap to evaluate; later ones can hit the database. Most requests stop at layer 2 or 3.
| Layer | Question | Mechanism |
|---|---|---|
| 1 — Authentication | Is the token real and active? | requireAuth middleware |
| 2 — Route access | Is this token-type allowed on this route? | requireTokenType, requireActiveAccount, requireScopes |
| 3 — Resource scope | Can the caller see this resource at all? | Loaded inside the controller / can() |
| 4 — Resource permission | Does the caller have the specific right? | requirePermission / can() |
The split matters: layer-1 and layer-2 checks reject before any DB query for the requested resource. That's why a missing token never causes a guild lookup, and a bot token can never trigger a row read on a user-only route.
404 vs. 403
The API hides resources you can't see. If you ask for a channel you aren't a member of, you get 404 not_found — not 403 insufficient_permissions. Returning 403 would leak the existence of the resource.
Once you can see a resource at all (you're a member of the guild, the channel is public, etc.), the API switches to honest 403s for "exists but you can't edit it" — at that point existence is already common knowledge.
The frontend hint endpoint
The frontend needs to know which buttons to hide. Asking can() per-button would be a round-trip storm, so there's a batched endpoint:
Batched permission check
curl -X POST https://api.localuniverse.io/v1/permissions/check \
-H "authorization: <token>" \
-H "content-type: application/json" \
-d '{ "actions": [
{ "type": "items.write" },
{ "type": "users.read" }
] }'
Response
{ "results": [true, false] }
This endpoint is a hint, not authority. The server still re-runs can() on every mutation. The hint exists so the UI doesn't render buttons that would 403; that's its only job.