data:read orresource:delete. Every part of the platform uses the same list: the APIsrc/capabilities.ts. The module ists-prorm-orm/capabilities, and the API imports it from the| Where | What |
|---|---|
src/capabilities.ts | The ids, tiers, TEAM_ROLES / ROLE_CAPABILITIES / roleCan, ADMIN_TYPES / ADMIN_SCOPES / ADMIN_PRESETS, session kinds and limits, PLAN_ENTITLEMENTS, API_KEY_CAPS, resolveCapabilities(), resolveResourceCaps(), unlockHint(). Pure: no imports, no I/O. |
api/middleware/capabilities.js | requireCapability, the enforcement modes, and ROUTE_CAPABILITIES, the table of what each route needs. |
api/controllers/ownership.ts | resolveResource(): how a caller reaches one database, store or transfer job (owner, team or API key). |
api/security/controllers/klock.ts | The principals (bearer, klock, API key, guest), step-up, and GET /me/capabilities. |
api/admin/controllers/access.ts | requireScope / requireAnyScope, which add admin:<scope> to the admin checks. |
src/cli/core/command-needs.ts, src/cli/core/framework.ts, src/cli/core/capability-view.ts | What each CLI command needs, and the CLI's guard. |
src/mcp/registry.ts | What each MCP account tool needs. |
CAPABILITIES_VERSION is 1. /me/capabilities returns it, so a client candomain:verb. expand() resolves the wildcards *ns:* (every id in one domain). Only the team owner rolesatisfies(needs, held) treats an array of needs assrc/capabilities.ts (see regenerating these tables).| Group | Count | Ids | Who holds them |
|---|---|---|---|
| public | 5 | docs:read, reference:read, scaffold:use, catalog:read, local:use | Everyone, including guests |
| self | 15 | account:read, account:write, session:read, session:manage, device:read, device:manage, access-rule:read, access-rule:write, behaviour:read, behaviour:write, team:create, klock:sync, mcp:clients, files:send, files:receive | Any signed-in account, for itself. files:* depends on the plan. |
| resource | 22 | team:read, team:manage-members, project:read, project:write, resource:read, resource:provision, resource:operate, resource:delete, data:read, data:write, migration:write, migration:apply, key:read, key:write, access:read, access:write, audit:read, billing:read, billing:manage, sso:manage, chat:manage, kerberos:manage | The owner of a resource holds all of them. A team member holds the ones their role grants. |
| team-admin | 5 | device:manage-members, session:manage-members, access-rule:team, behaviour:team, files:team | Team roles owner, admin and security, and operator for files:team |
| service | 2 | passwordless:use, transfer:report | service sessions only |
| admin | 11 | admin:dashboard, admin:users, admin:resources, admin:operations, admin:billing, admin:logs, admin:support, admin:email, admin:dev, admin:grant-admin, admin:assume | Platform staff, by admin scope. These are secret. |
| Id | Group | Description |
|---|---|---|
docs:read | public | Read the documentation. |
reference:read | public | Read the API and dialect reference. |
scaffold:use | public | Generate and review code from templates. |
catalog:read | public | List engines and regions. |
local:use | public | Run the ORM against databases on this machine. |
account:read | self | Read your own account. |
account:write | self | Change your own account. |
session:read | self | List your sessions. |
session:manage | self | Revoke, rotate and freeze your sessions. |
device:read | self | List your devices. |
device:manage | self | Rename, revoke, wipe and pair your devices. |
access-rule:read | self | Read your access rules. |
access-rule:write | self | Change your access rules. |
behaviour:read | self | Read your behaviour profiles and findings. |
behaviour:write | self | Change your behaviour profiles. |
team:create | self | Create a team. |
klock:sync | self | Sync this device with the klock service. |
mcp:clients | self | Create and revoke delegated MCP client tokens. |
files:send | self | Send files to your own devices. |
files:receive | self | Receive files on this device. |
team:read | resource | See a team and its members. |
team:manage-members | resource | Invite, change and remove team members. |
project:read | resource | See projects and what they group. |
project:write | resource | Create projects and attach resources to them. |
resource:read | resource | See databases and stores, their operations and metrics. |
resource:provision | resource | Provision databases and stores (billed to the owner: you, or the team). |
resource:operate | resource | Resize, pause, resume, back up and move resources. |
resource:delete | resource | Delete resources. |
data:read | resource | Read data. |
data:write | resource | Write data. |
migration:write | resource | Write and preview migrations. |
migration:apply | resource | Apply migrations. |
key:read | resource | List keys and connection details. |
key:write | resource | Issue and revoke keys. |
access:read | resource | Read engine logins, roles, passwordless sessions and access settings. |
access:write | resource | Change engine logins, roles, passwordless sessions and access settings. |
audit:read | resource | Read audit logs and policies. |
billing:read | resource | Read the owner's billing: your own account's, or the team's. |
billing:manage | resource | Manage the owner's billing account: your own, or the team's. |
sso:manage | resource | Configure single sign-on. |
chat:manage | resource | Configure team chat. |
kerberos:manage | resource | Manage Kerberos principals and certificates. |
device:manage-members | team-admin | Revoke team members' devices used on team resources. |
session:manage-members | team-admin | Revoke team members' sessions in the team's context. |
access-rule:team | team-admin | Set mandatory team access rules. |
behaviour:team | team-admin | Set team behaviour profiles. |
files:team | team-admin | Send files to team members' devices. |
passwordless:use | service | Query a database as a bound passwordless session. |
transfer:report | service | Report the status of one transfer job. |
admin:dashboard | admin | See the admin area. |
admin:users | admin | Read and act on customer accounts. |
admin:resources | admin | See every database and store on the platform. |
admin:operations | admin | See and drive the operations queue. |
admin:billing | admin | Invoices, plans, rates and refunds. |
admin:logs | admin | Database event and data logs. |
admin:support | admin | The support tools. |
admin:email | admin | The email accounts and inboxes. |
admin:dev | admin | Database health and metrics. |
admin:grant-admin | admin | Create, change and revoke admins. |
admin:assume | admin | Act as a customer account. |
TIERS is guest, user, team and admin. The tier is a display and| Tier | When | Capabilities (an interactive session on the usage plan) |
|---|---|---|
guest | No account | The 5 public ids |
user | Signed in, no team context | 42: public, self and resource. The account owns its own resources, so its ceiling includes every resource capability. |
team | Signed in with a team context (--team, the x-prorm-team header, or the grant's teamId) that is an active membership | The user's 42, plus the team-admin ids the role grants. An admin in the team gets 47: access-rule:team, behaviour:team, device:manage-members, files:team, session:manage-members. A developer gets no extra ids. |
admin | An account with role: 'admin' and at least one admin scope, in an interactive or browser session | The user's 42, plus admin:<scope> for each scope. For interactive sessions these are dormant until a step-up. |
resolveCapabilities(principal, session) computes a session's ceilingcaps), its dormant capabilities, its tier, its team roles and itsadd and remove). In a team context, the plan is the team's.team_not_active.restricted.noProvisioning) then removesresource:provision and team:create, so a team role cannot restore them.KIND_MASK) is applied. A service sessionnarrowTo, if set, narrows both the ceiling and the dormant set.admin:<scope>, for interactive and browserADMIN_KINDS). See step-up.mergeLimits).usage plan):| Session kind | Ceiling | Notes |
|---|---|---|
interactive, device, browser, cli-legacy, handoff, assumed | 42 | Every non-admin capability the account holds. Only interactive and browser can carry admin:*. |
delegated (an MCP client token) | 33 | Loses access-rule:read, access-rule:write, behaviour:read, behaviour:write, billing:manage, device:manage, mcp:clients, session:manage and team:manage-members, and never holds the team-admin ids or admin:*. |
service | 7 | data:read, data:write, files:receive, migration:apply, passwordless:use, resource:read, transfer:report |
guest | 5 | The public set |
delegated mask is stricter than the design's list. It also removesdevice:manage-members and session:manage-members, so a delegated token canSESSION_KINDS is browser, cli-legacy, interactive, device,service, delegated, guest, handoff and assumed. SESSION_DEFAULTS– means no limit):| Kind (tier) | Max duration | Idle timeout | Concurrent (evict) | Check in every | Grant TTL | Step-up TTL | Rotate |
|---|---|---|---|---|---|---|---|
interactive (user, team) | 30d | 7d | 10 (lru) | 1h | 1d | – | – |
interactive (admin) | 12h | 30m | 3 (lru) | 5m | 12h | 15m | – |
device | – | 90d | 10 (refuse) | 1d | 7d | – | 365d |
service | – | 180d | 100 (refuse) | 1d | 7d | – | 90d |
delegated | 30d | 7d | 5 (lru) | 0 | 0 | – | – |
guest | 1d | 2h | 1 (lru), 5 new per IP per hour | 1d | 1d | – | – |
browser | 1h | – | – | 0 | 0 | 15m | – |
cli-legacy | 30d | – | – | 0 | 0 | – | – |
handoff | 1h | – | – | 0 | 0 | – | – |
assumed | 8h | – | – | 0 | 0 | – | – |
checkInEverySec of 0 means the server checks the session on everygrantTtlSec of 0 means the kind carries no signed grant. TheseHARD_LIMITS are ceilings that no layer can exceed:| Limit | Value |
|---|---|
| Interactive session duration | 90 days |
| Any admin-tier session | 24 hours |
| Step-up TTL | 60 minutes |
| Service check-in | at most every 72 hours, and a team cannot tighten it below 1 hour |
| Devices per account | 100 |
| Grant TTL | 7 days |
mergeLimits layers overrides over the defaults. A plan or admin layerteam or user layerevict: 'refuse' counts as tighter than'lru'. For how the session kinds behave, seePLAN_ENTITLEMENTS says what each plan adds, removes and allows. An unknownfree. In a team context the team's plan applies,src/capabilities.ts| Plan | Adds | Removes | Devices | File transfer (per transfer / per day) |
|---|---|---|---|---|
free | – | files:send, files:receive | 3 | off |
starter | – | files:send, files:receive | 3 | off |
usage | – | – | 10 | 2 GB / 10 GB |
membership | – | – | 25 | 10 GB / 100 GB |
pro | – | – | 25 | 10 GB / 100 GB |
SESSION_DEFAULTS.device.concurrent,x-api-key) is not a session. Its principal holdsAPI_KEY_CAPS[privilege] and is locked to the one database it was issued forresourceLock):| Privilege | Capabilities |
|---|---|
owner | data:read, data:write, migration:apply, key:read |
admin | data:read, data:write, migration:apply |
readwrite | data:read, data:write |
readonly | data:read |
readonly.TEAM_ROLES has twelve roles. roleCan(role, cap) answers fromROLE_CAPABILITIES, and owner holds *. Only active members holdinvited and suspended members get nothing.roleCan):| Capability | owner | admin | billing | security | operator | developer | deployer | analyst | support | auditor | viewer | guest |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
team:read | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | |
team:manage-members | yes | yes | ||||||||||
project:read | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
project:write | yes | yes | yes | |||||||||
resource:read | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
resource:provision | yes | yes | yes | |||||||||
resource:operate | yes | yes | yes | yes | ||||||||
resource:delete | yes | yes | ||||||||||
data:read | yes | yes | yes | yes | yes | |||||||
data:write | yes | yes | yes | yes | ||||||||
migration:write | yes | yes | yes | yes | ||||||||
migration:apply | yes | yes | yes | |||||||||
key:read | yes | yes | yes | yes | ||||||||
key:write | yes | yes | yes | |||||||||
access:read | yes | yes | yes | yes | ||||||||
access:write | yes | yes | yes | |||||||||
audit:read | yes | yes | yes | yes | yes | |||||||
billing:read | yes | yes | yes | |||||||||
billing:manage | yes | yes | ||||||||||
sso:manage | yes | yes | yes | |||||||||
chat:manage | yes | yes | ||||||||||
kerberos:manage | yes | yes | yes | |||||||||
device:manage-members | yes | yes | yes | |||||||||
session:manage-members | yes | yes | yes | |||||||||
access-rule:team | yes | yes | yes | |||||||||
behaviour:team | yes | yes | yes | |||||||||
files:team | yes | yes | yes |
admin cannot manage billing (billing:manage); only the owner andbilling roles can.role: 'admin' and a permission set.ADMIN_TYPES is general, billing, logs, support, email, dev andother. other is a custom set, defined per account. ADMIN_SCOPES listsadminCap(scope) maps each one to one capability.| Preset | Capabilities | Can grant admin | What it is for |
|---|---|---|---|
general | all eleven admin:* | yes | Everything, including granting admin. |
billing | admin:dashboard, admin:billing | Billing, invoices, plans and rates. | |
logs | admin:dashboard, admin:logs | Database event and data logs. | |
support | admin:dashboard, admin:users, admin:resources, admin:support, admin:assume | Help customers with their accounts, including as them. | |
email | admin:dashboard, admin:email | The email accounts and inboxes we run. | |
dev | admin:dashboard, admin:resources, admin:operations, admin:dev | Monitor our databases: health, not data. |
requireScope(scope) still runs resolvePermissions first, andrequireCapability('admin:<scope>', { area: 'admin' }) against the session'srequireAnyScope does the same with an any-of list.interactive and browserinteractive (CLI) session they are dormant: the grantdormant, not caps. They become live when the accountadminUntil says until when.prorm step-up (TOTP). When a command needs a dormantRun `prorm step-up` first.POST /security/klock/sessions/:id/step-up with{ "totp": "123456" }. A browser session without a second factor may send{ "password": … } instead. A klock session never canpassword_browser_only), and an account that has TOTP must use ittotp_required). Failures count against the sign-in lockouttoo_many_attempts) and are audited as session.step-up-failed. A klockadminUntil.stepUp (today, revealing an SSH key); and for anyadmin:* capability on an unsafe method (POST, PUT, PATCH or DELETE) from astep_up_required. A klock session gets 401 withWWW-Authenticate: Klock error="step_up_required", and a bearer session{ "error": "step_up_required", "message": "This needs a fresh step-up: prorm step-up", "needs": "admin:users", "unlock": "step-up" }admin:* capabilities ofsupport preset are dormant until the step-up, then live for 15 minutes:$ prorm whoami
sam <sam@example.com> (admin)
tier admin · plan usage
session interactive · device-bound · dev_7QK2… · key in the OS keychain
check in by 28/09/2026, 10:05:00 (in 5m)
expires 28/09/2026, 22:00:00
klock active · ~/.prorm/klock/default.klock
dormant admin:assume, admin:dashboard, admin:resources, admin:support, admin:users — `prorm step-up` makes them live
42 capabilities (api); --caps lists them
API https://api.prod-orm.io
$ prorm step-up
Authenticator code (step-up): ******
✓ Stepped up; admin capabilities are live until 28/09/2026, 10:15:00.
$ prorm whoami --json | jq '.dormant, .session.adminUntil'
[]
"2026-09-28T09:15:00.000Z"$ prorm step-up --totp 123456
✓ Stepped up; admin capabilities are live until 28/09/2026, 10:15:00.--totp on a terminal, it prompts. In a script with no terminal and--totp, it exits with A step-up needs an authenticator code: pass
--totp <code>.{ "password": "…" } instead. A klock caller signs the request with its#/security?tab=capabilities) and use the401 step_up_required also asks for[!WARNING]
Failed step-ups count against the sign-in lockout. A script that retries a
stale TOTP code in a loop locks the account (too_many_attempts).
browser admin's capabilities stay live without a step-up (the legacy rule)cli-legacy token (the old prorm-admin login) carries noadmin:* at all. See enforcement for what that means forrequireCapability(needs, { resource, stepUp, area, all }) is the check, andROUTE_CAPABILITIES is the table of what every covered route needs. Eachcapability(router, route).capability() throws at import time for a route the table does not map, andapi/tests/capabilities-routes.test.js fails on a route that is missing, an| Variable | Values | Default | Effect |
|---|---|---|---|
CAPABILITY_ENFORCEMENT | off, report, enforce, or an area list | off | The mode. It is read on every call, so it can change without a restart. An unrecognised value with no area list anywhere means off. |
CAPABILITY_ENFORCEMENT_AREAS | a comma list of area or area=mode, for the areas resource, team, admin | empty | Per-area modes, for example resource=enforce,team=report. : works as well as =, case is ignored, and unknown areas and modes are dropped. See the precedence. |
CAPABILITY_E3 | 1 / true / yes / on | off | Enforcement stage E3. See below. |
off skips the ceiling, resource and step-up checks entirely, so anreport runs the checks. A request that would be refused is servedaccess.would-deny audit event (levelnotice, category access). Its detail has the area, the needs, thecapability_missing, step_up_required or unauthenticated), theunlock hint, the route, the principal's source, the session kind and howenforce refuses. A missing capability answers 403:{ "error": "capability_missing", "message": "This session cannot do that", "needs": "resource:delete", "unlock": "team-role" }unlock is one of login, upgrade, team-role, step-up and none,unlockHint(). Admin capabilities always answer none, so they areunauthenticated.off. Teamviewer could delete what they can only see. This answers 403capability_missing with unlock: 'team-role' and the messageYour role on this team (<role>) cannot do that.mcp:clients), which the route checksoff, then report everywhere, then enforceenforcementMode(area) in api/middleware/capabilities.js decides each areaadmin, resource, team). The first rule that applies wins:area=mode in CAPABILITY_ENFORCEMENT_AREAS gives that mode.area in CAPABILITY_ENFORCEMENT_AREAS givesCAPABILITY_ENFORCEMENT when that is a mode, and enforce otherwise.CAPABILITY_ENFORCEMENT may itself be written as an area list. There,area=mode gives that mode and a bare area gives enforce.off (unchanged);CAPABILITY_ENFORCEMENT is a mode, that mode;report.report until CAPABILITY_E3=1| Settings | resource | team | admin |
|---|---|---|---|
| nothing set | off | off | off |
CAPABILITY_ENFORCEMENT=report | report | report | report |
CAPABILITY_ENFORCEMENT_AREAS=resource=enforce | enforce | report | report |
CAPABILITY_ENFORCEMENT_AREAS=resource=enforce,team=enforce | enforce | enforce | report |
the same plus admin=enforce, and CAPABILITY_E3=1 | enforce | enforce | enforce |
CAPABILITY_ENFORCEMENT=enforce, CAPABILITY_ENFORCEMENT_AREAS=admin | enforce | enforce | enforce (report before E3) |
[!WARNING] Behaviour change
Before wave S2,CAPABILITY_ENFORCEMENT=enforcewithCAPABILITY_ENFORCEMENT_AREAS=adminmeant "admin enforced, everything else
off". Unlisted areas now fall back to the base mode, so the last row
enforces every area. To enforce admin alone and report the rest, writeCAPABILITY_ENFORCEMENT_AREAS=admin=enforce(withCAPABILITY_E3=1).
report even when it is set toenforce (effectiveMode). A prorm-admin CLI login is a cli-legacyadmin:*. Enforcing the admin area beforeaccess.would-deny and still served.[!CAUTION] CAPABILITY_ENFORCEMENT=enforcewithadmininCAPABILITY_ENFORCEMENT_AREASdoes not refuse admin requests yet. UntilCAPABILITY_E3=1, the admin area only recordsaccess.would-deny. Read
those findings before you turn E3 on, or CLI admins oncli-legacytokens
are locked out.
CAPABILITY_E3=1 turns on:enforce;admin:* capabilities on unsafe methods from browserrequireAdmin route (all of /admin/*, and /proxy/status) refuses{
"error": "mfa_required",
"message": "Admin accounts need a TOTP second factor. Enrol one (prorm security mfa enable, or the console security page), then try again.",
"enrol": {
"setup": { "method": "POST", "url": "https://api.prod-orm.io/security/mfa/setup" },
"confirm": { "method": "POST", "url": "https://api.prod-orm.io/security/mfa/confirm", "body": { "code": "<six digits from the app>" } }
}
}mfa_required/security/mfa/* are the account's own routes (requireAuth, neverrequireAdmin), so an admin can always enrol: prorm security mfa enable{ totp }. A password step-up isPOST /security/sessions/cli, and the v1 sign-in relayPOST /security/cli-logins without v: 2, answer 410login_v2_required:{
"error": "login_v2_required",
"message": "This API no longer issues legacy CLI tokens at sign-in. …",
"login": {
"v": 2,
"configuration": "https://api.prod-orm.io/.well-known/klock-configuration",
"open": "https://api.prod-orm.io/security/klock/sessions"
}
}--token orPRORM_TOKEN: POST /security/sessions/cli with purpose: "token" stilldays over 30 is a 400). The console'sv: 2) is unchanged, tokenscli-legacy tokens.scope=allGET /provision/databases and GET /provision/stores with no ?scope=scope=all: your own resources plus the teams', each item withaccess: { via, teamUuid?, role? }. Before E3 the no-scope answer isaccess). The transfer jobs list is unchanged.ROUTE_CAPABILITIES. The table inapi/middleware/capabilities.js is authoritative.| Router (mount) | Needs |
|---|---|
provision (/provision) | GET /engines and /regions are public. Listing and reading → resource:read. POST /databases and /stores → resource:provision (in the team, with a team context). PATCH and PUT …/usage → resource:operate. DELETE → resource:delete. GET /stores/:id/connect → key:read. POST /databases/:id/query → data:read for a select, data:write otherwise. |
databases (/provision/databases) | Keys: GET → key:read, POST/DELETE → key:write. Engine logins, SSH keys and /access: GET → access:read, changes → access:write. Revealing an SSH key → access:write with step-up. GET /:id/connect → key:read. |
cloud | Operations, backups, restores, migrations, replicas, metrics and events: GET → resource:read. Resize, backup, pause, resume, move, replicas, cancel and retry → resource:operate. restore → resource:operate and data:write. rotate-credentials → access:write. migrations and migrations/preview → migration:write. migrations/apply and migrate → migration:apply. |
objects | GET → resource:read. Create, change, preview and delete → migration:write. |
pools | GET → resource:read, otherwise resource:operate. |
policies | A database's: GET and trace → audit:read, changes, review and check → access:write. The catalogue at /policies is public. |
roles | GET → access:read, otherwise (including grant) → access:write. |
passwordless | List → access:read, issue and revoke → access:write. exchange and query take a signed grant, not a session. |
transfers (/transfer) | quote, create, start and delete → resource:operate. Reads → resource:read. events takes the runner token, the owner's session or the job's klock. |
teams (/teams) | By capability in the team: team:read, team:manage-members, project:read/project:write, sso:manage, kerberos:manage, and billing:manage for transfer-billing. POST /teams → team:create. Closing or transferring a team also needs the owner's password. |
teamBilling (/teams) | GET …/billing* → billing:read; checkout, portal, cards, plan and contact → billing:manage, in the team. See a team's billing account. |
mcpClients (/security/mcp) | Managing clients → mcp:clients (checked in the route). /capabilities and /call take a delegated token, and each call is checked against the tool's needs. |
mcpForward (/internal/mcp) | A klock grant and an RFC 9421 signature over the MCP request, then the tool's needs. |
requireScope, asteamId in the body, or theownerKind: 'team' and theteamId. userId still records the member who created it. ThisresolveResource(kind, id, principal) loads the row without filtering byvia | When | Resource capabilities |
|---|---|---|
owner | A user-owned row, and the caller is its userId | Everything (expand(['*'])) |
team | The row's teamId is a team the caller is an active member of. For a user-owned row, its owner must also still be an active member. | ROLE_CAPABILITIES[role] |
key | An API key presented for the database it was issued for | The key's API_KEY_CAPS |
| none | Anything else | The route answers 404, exactly as for a row that does not exist, so a non-member cannot tell that the resource exists. |
teamId on databases,data_stores and transfer_jobs gives team access, neverproject_resources. A resource therefore belongs to at most one team byPOST /teams/:teamId/projects/:projectId/resources) needs ownership andproject:write. Attaching records the team on the row. A resource alreadyresource_in_other_team, andresource.auto-detached for the member and for the team's owner andteamId only says which team's billing it settles on.billableTeamResources), and an account's invoice leaves out team-ownedGET /provision/databases and /stores take?scope=own|team|all. Without scope, they return the caller's own,scope=all from E3team or all, each itemaccess: { via, teamUuid?, role? }. Team items are listed onlyresource:read.DELETE /admin/teams/:teamId/members/:userId), the member's shareddetachOnLeave), audited as resource.auto-detached with the admin asactorId. The admin audit line carries detached: <count>.teams.stripeCustomerId, made on the team's first checkout, never ateams.plan, which the team's quota reads) and ateams.billingUserId; the owner when unset). The routes areapi/team/routes/teamBilling.js, mounted at /teams.404 {"error":"Unknown team"} before anything else is read. A403 capability_missing withneeds and unlock: "team-role". The owner and billing roles holdbilling:read and billing:manage; admin holds billing:read only.| Method | Path | Needs | Does |
|---|---|---|---|
GET | /teams/:teamId/billing | billing:read | The plan, subscription, customer (a boolean), contact, transfer billing, pools { used, max, remaining }, the monthly estimate, and configured |
GET | /teams/:teamId/billing/invoices | billing:read | The team's invoices, newest first |
GET | /teams/:teamId/billing/upcoming | billing:read | This month's lines for the team's own resources |
GET | /teams/:teamId/billing/payment-methods | billing:read | The cards on the team's customer: brand, last4, expiry, isDefault |
POST | /teams/:teamId/billing/checkout { membership? } | billing:manage | Stripe Checkout on the team's customer, which is created here on first need. 409 when the team already pays, with next: PUT /teams/:id/billing/plan. Honours Idempotency-Key. |
POST | /teams/:teamId/billing/portal | billing:manage | The Stripe billing portal for the team's customer |
POST | /teams/:teamId/billing/payment-methods | billing:manage | A Stripe-hosted setup session. When it completes, the webhook makes the card the team's default. |
PUT | /teams/:teamId/billing/payment-methods/:pmId/default | billing:manage | 404 for a card that is not on the team's customer |
DELETE | /teams/:teamId/billing/payment-methods/:pmId | billing:manage | 204; 404 for another customer's card |
PUT | /teams/:teamId/billing/plan { plan: free | usage | membership } | billing:manage | 202 { pending }: Stripe makes the change, and teams.plan moves when the webhook confirms it. free cancels at the period end. Before the team has checked out, 409 with next: POST /teams/:id/billing/checkout. |
PUT | /teams/:teamId/billing/contact { userId | null } | billing:manage | An active member whose role has billing:read (422 otherwise; a non-member is 404). null means the owner. Also updates the Stripe customer's email. |
PUT | /teams/:teamId/transfer-billing | billing:manage | Who pays for the team's transfers (on the team router) |
/billing/* does, and answer 503 when Stripe is not configured.POST /billing/webhook):checkout.session.completed and customer.subscription.*: a subscriptionmetadata.teamId names a team, or whose customer is a team's, isteamId and moves teams.plan. It must be on the team's owncheckout.session.completed in setup mode makes the new card theinvoice.paid and invoice.payment_failed: an invoice on a team'steamId, and its userId is the billing contact.team.billing.payment-failed.invoice.created adds the team's resources to the team's invoice.GET /billing/invoices and GET /billing/subscription list only theteamId null). A team's rows appear only under/teams/:id/billing/*.teamId is the team's. The team's402 ("A card on the team's billing account is required…"). The chargemetadata.teamId. The member is429next: "PUT /teams/:id/billing/plan raises the team's plan".GET /provision/databases/:id/pools reports allowance.owner as team oruser, and an account's own count no longer includes pools on teamoff: these routes change credentials, so they never run on the503 klock_unconfigured when the API has no| Who | Routes | Needs |
|---|---|---|
| You, on your own devices | GET /security/devices, GET /security/devices/:id | device:read |
PATCH, DELETE /security/devices/:id, POST …/suspend, …/reinstate, …/wipe, …/rotate-key, POST /security/devices/pairings/approve | device:manage. Approving a pairing as a device kind, like opening a device session, needs a fresh step-up. | |
| A team administrator, on members' devices | GET /security/devices?team=<t> and the same routes with team; GET /teams/:teamId/devices, DELETE /teams/:teamId/devices/:deviceId | device:manage-members in that team |
| A team administrator, on members' sessions | GET /teams/:teamId/sessions, DELETE /teams/:teamId/sessions/:sid | session:manage-members in that team |
| Staff | GET /admin/users/:id/devices, DELETE /admin/users/:id/devices/:deviceId, POST /admin/users/:id/devices/:deviceId/wipe, DELETE /admin/users/:id/sessions/:sid, GET /admin/klock/devices | the support admin scope (admin:support) |
reason (400 reason_requiredwarning,403 for anyone else).stepUp in the body), and a reason of 10 to 500admin.device-action oradmin.session-action at level alert, and the account holder is emailed.404 device_not_found, the404 as for a team that does not exist.browser, cli-legacy, interactive, device,delegated, handoff, assumed and guest (isServiceSession inapi/controllers/security.ts, the same rule a lockdown uses): database| Route | Ends | To end service sessions too |
|---|---|---|
DELETE /security/sessions ("sign out everywhere else") | every other person session | includeServices: true in the body, or ?includeServices=1. The audit detail records includeServices. |
POST /security/password/reset | every person session; revoke reason password-reset (it was user) | includeServices: true in the body |
POST /admin/users/:id/signout, PUT /admin/users/:id/lock (staff) | every person session; reason admin, revokedBy the admin | includeServices: true (default false) |
| A behaviour policy action that revokes the owner's sessions | every person session | never; it uses the default |
POST /security/lockdown | every session | includeServices: true |
DELETE
/security/sessions/:id, and every other revoke path) appends its dt_… id{ kind: "session", ref: "dt_…" }, as ks_…GET /me/capabilities?team=<uuid|slug|id> resolves the answer in that team's context,team_not_found for a team the caller is not an activeCache-Control: private, no-store.{
"tier": "team",
"caps": ["access-rule:read", "…sorted"],
"dormant": [], // admin:* awaiting a step-up
"teamRole": "developer", // null without a team context
"teams": [{ "uuid": "…", "slug": "acme", "name": "Acme", "role": "developer", "caps": ["…"] }],
"admin": null, // { type, scopes } for staff
"plan": { "name": "usage", "entitlements": { "add": [], "remove": [], "limits": {}, "files": {} } },
"limits": { "byKind": { "interactive": { "…": "…" } } },
"session": { "kind": "interactive", "binding": "device", "stepUpAt": null, "checkInBy": 1727343600, "deviceId": "dev_…" },
"source": "klock", // klock | bearer | apikey | guest
"capabilitiesVersion": 1
}source: 'guest'. An API key gets itsresourceLock. A signed-in bearer whose capabilitiescapabilities_unavailable, rather than a guess.?team=, the answer is still limited by the grant.ts-prorm-orm/capabilities is pure, so the same checks can run anywhere:import {
resolveCapabilities,
resolveResourceCaps,
effectiveCaps,
satisfies,
unlockHint,
roleCan,
type PrincipalFacts,
} from 'ts-prorm-orm/capabilities';
const ada: PrincipalFacts = {
user: {
uuid: 'u-ada',
accountRole: 'basic',
plan: 'free',
mfaEnrolled: false,
teams: [{ uuid: 't-acme', role: 'analyst', state: 'active' }],
},
};
const now = Math.floor(Date.now() / 1000);
const resolved = resolveCapabilities(ada, { kind: 'interactive', binding: 'device', teamUuid: 't-acme', now });
console.log(resolved.tier); // 'team'
// A database the team owns: Ada reaches it through her role.
const access = resolveResourceCaps({ kind: 'team', teamId: 't-acme' }, ada);
const effective = effectiveCaps(resolved.caps, access?.caps ?? null);
console.log(satisfies('data:read', effective)); // true
console.log(satisfies('data:write', effective)); // false: an analyst reads only
console.log(roleCan('analyst', 'data:write')); // false
console.log(unlockHint('files:send', resolved)); // 'upgrade': the free plan removes it[!IMPORTANT]
Nothing in the CLI is a security boundary. Help, completion and the guard
read the grant without verifying it, only to decide what to list. The
API enforces every capability on every request.
src/cli/core/command-needs.ts,register() applies when a command does not set needs itself.db init, schema …, new, config …, login,whoami, step-up, mcp) need local:use and are never gated: they runalways shown, shown when-permitted, orsecret. A command whose needs name an admin:* capability is secret. Toprorm help admin …sign in to unlock (prorm login). Staff groups print/me/capabilities, and nobody/me/capabilities for a legacy token (cached for anlogin exits with EXIT.AUTH andSign in to use `prorm <cmd>`: prorm login. step-up prompts on async runs prorm klock sync and continues.upgrade and team-role exit with a hint. When the server answersstep_up_required, the CLI offers a step-up and retries once. Acapability_missing answer prints the unlock hint. With --json, the{ "error", "needs", "unlock" }.prorm whoami --caps (and --limits,--json).prorm __complete, whichdeveloper on the payments team. The ceiling lists every resource$ prorm whoami --caps --team payments
ada <ada@example.com>
tier team — payments as developer · plan usage
session interactive · device-bound · dev_Q4M8… · key in the OS keychain
check in by 28/09/2026, 11:00:00 (in 58m)
expires 29/09/2026, 10:00:00
klock active · ~/.prorm/klock/default.klock
teams payments (developer)
Capabilities:
public catalog:read, docs:read, local:use, reference:read, scaffold:use
self access-rule:read, access-rule:write, account:read, account:write, behaviour:read, behaviour:write, device:manage, device:read, files:receive, files:send, klock:sync, mcp:clients, session:manage, session:read, team:create
resource access:read, access:write, audit:read, billing:manage, billing:read, chat:manage, data:read, data:write, kerberos:manage, key:read, key:write, migration:apply, migration:write, project:read, project:write, resource:delete, resource:operate, resource:provision, resource:read, sso:manage, team:manage-members, team:read
API https://api.prod-orm.io
$ curl -s -X DELETE "$PRORM_API_URL/provision/databases/118" \
-H "Authorization: Bearer $TOKEN" -H 'x-prorm-team: payments'
{"error":"capability_missing","message":"Your role on this team (developer) cannot do that","needs":"resource:delete","unlock":"team-role"}unlock value (here, Ask a team owner or admin for a role that allows
it.) and exits with code 3 (EXIT.AUTH). With --json, it prints{ "error", "needs", "unlock" }.src/mcp/registry.ts declares needs (an array isdestructive or stepUp. The catalogue tools needcatalog:read, and prorm_run_query needs ['data:read', 'data:write']. Theneeds controls what is listed, never what istools/list and prompts/list by the verifiedmcp.prod-orm.io) identifies the caller from theAuthorization header. A klock grant plus its RFC 9421 signature ischeckInBy only the public set is listed. A delegated tokenBearer prorm_dt_…) is listed from GET /security/mcp/capabilities. AnyGET /me/capabilities. No credential is aprorm mcp) uses the CLI's own session the same way:/me/capabilities for a legacy token (cached an hour),-32602 "No such tool: <name>".stepUp tool with a dormant capability is listed, but answersRun `prorm step-up`, then retry.destructive tool answers first with a confirmation token, and runs onlyprorm mcp --caps a,b [--hours N] runs on a child delegated token minteddelegated kind mask*, admin:*, session:manage, device:manage, the teamsrc/capabilities.ts, not typed by hand.import * as C from './src/capabilities';
const now = Math.floor(Date.now() / 1000);
const user = { uuid: 'u', accountRole: 'basic' as const, plan: 'usage', mfaEnrolled: true, teams: [] };
for (const kind of ['interactive', 'delegated', 'service', 'guest'] as const) {
const r = C.resolveCapabilities({ user: kind === 'guest' ? null : user }, { kind, binding: 'device', now });
console.log(kind, r.tier, r.caps.length, r.caps.join(' '));
}
for (const cap of C.ALL_CAPABILITIES) {
console.log(cap, C.TEAM_ROLES.filter((role) => C.roleCan(role, cap)).join(','));
}api/tests/capabilities-parity.test.js fails when the library it installsprorm whoami --caps, prorm step-up and prorm admin