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 HUB or HOME channel's scene JSON. The Wormhole service principal is the normal caller; other fields on the same PATCH /v1/channels/:id route 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 on PUT /v1/users/@me/equipment/:slot needs 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 records FISH_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.

LayerQuestionMechanism
1 — AuthenticationIs the token real and active?requireAuth middleware
2 — Route accessIs this token-type allowed on this route?requireTokenType, requireActiveAccount, requireScopes
3 — Resource scopeCan the caller see this resource at all?Loaded inside the controller / can()
4 — Resource permissionDoes 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] }

Was this page helpful?