Roles & Permissions
LaWallet NWC RBAC model: four hierarchical roles, granular permissions, and how the caller's role is resolved.
LaWallet NWC uses an RBAC model with four hierarchical roles plus a PUBLIC level for unauthenticated calls. Each role has a fixed set of granular permissions; route guards check either a minimum role or a specific permission, depending on the case.
Hierarchy
PUBLIC → USER < VIEWER < OPERATOR < ADMINA higher role includes everything a lower role can do. Guards use hasRole(actual, required), which compares positions in the hierarchy.
| Role | Who it is | Privilege summary |
|---|---|---|
| PUBLIC | Unauthenticated caller | Open endpoints: LUD-16 payRequest/callback, /.well-known/verify, /api/setup/status, and POST /api/jwt token exchange. |
| USER | Any authenticated pubkey with no additional permissions | Reads and edits its own data (LUD-16 and its own NWC). Cannot access the admin panel. |
| VIEWER | Auditor or observer | Read-only access to cards, designs, addresses, ntags, users, settings, and the activity log. |
| OPERATOR | Day-to-day operator | VIEWER access plus write access to cards, designs, addresses, and ntags. Cannot change settings or other users' roles. |
| ADMIN | System root | Every permission, without exception. Automatically assigned to the first pubkey that claims the bootstrap endpoint (POST /api/admin/assign). |
Permission matrix
Permissions are defined as an enum in lib/auth/permissions.ts, and each role maps to a fixed permission list.
| Permission | USER | VIEWER | OPERATOR | ADMIN |
|---|---|---|---|---|
settings:read | ✓ | ✓ | ||
settings:write | ✓ | |||
users:read | ✓ | ✓ | ✓ | |
users:write | ✓ | |||
users:manage_roles | ✓ | |||
cards:read | ✓ | ✓ | ✓ | |
cards:write | ✓ | ✓ | ||
card_designs:read | ✓ | ✓ | ✓ | |
card_designs:write | ✓ | ✓ | ||
addresses:read | ✓ | ✓ | ✓ | |
addresses:write | ✓ | ✓ | ||
ntags:read | ✓ | ✓ | ✓ | |
ntags:write | ✓ | ✓ | ||
activity:read | ✓ | ✓ | ✓ |
USER has none of these platform permissions. It is the default role for authenticated pubkeys operating only on their own identity.
How the caller's role is resolved
Every authenticated request (NIP-98 or Bearer JWT) passes through resolveRole(pubkey) in lib/auth/resolve-role.ts:
- It looks up a
Userwith thatpubkeyin the database. If the user has a role other thanUSER, it returns that role. - Otherwise, it checks the
rootsetting (for compatibility with bootstrap before theUsertable). If the pubkey matches, the role isADMIN. - In every other case, the role is
USER.
The JWT embeds the role when it is issued, but that claim is only a hint: session tokens are stateless and cannot be revoked, so resolveRole(pubkey) re-runs on every request and the freshly resolved role is what authorizes it. Demoting or deleting a user takes effect immediately, without waiting for the token to expire.
Two deliberate exceptions:
- Device tokens (
kind: 'device') keep the role they were minted with. Theirscopesclaim is the authoritative restriction — an explicit subset of the minting admin's permissions — so re-resolving the owner's role would widen, not narrow, what the token can do. - Session lifetimes are capped at 24 hours (
MAX_SESSION_JWT_SECONDS), since a leaked stateless token stays valid until it expires.
How a role is assigned
- Bootstrap (first admin): the first pubkey to call
POST /api/admin/assignwith NIP-98 signs therootsetting and becomesADMIN. See the API Playground and the JWT Authentication guide. - Promoting or demoting users: requires the
users:manage_rolespermission (ADMINonly). Endpoint:PUT /api/users/{userId}/role. - Self-demotion is blocked: an admin cannot lower its own role, and the system prevents the platform from having no
ADMINusers.
How a route checks a role
Protected routes use helpers in lib/auth/:
requireRole(req, Role.OPERATOR)— requires the minimumOPERATORrole. Returns 403 withAuthorizationErrorif the caller does not qualify.requirePermission(req, Permission.CARDS_WRITE)— performs a granular check against the permission map.withAdminAuth(handler)— HOF inlib/admin-auth.tsthat validates NIP-98 and requiresADMIN. Bootstrap endpoints use it.
Routes that accept both methods (NIP-98 or JWT) use lib/auth/unified-auth.ts, which detects the Authorization header and applies the appropriate flow before resolving the role.
In the API Playground
In the interactive API Playground, every endpoint shows a badge with the required minimum role (PUBLIC, USER, VIEWER, OPERATOR, ADMIN). The colors help you quickly identify the endpoints available to each role before testing them with your NIP-07 signer.