Service API Reference

The orbit-service management API runs on port 3001. The introspect hot path runs separately on port 3000. All management endpoints require a tenant-scoped session token via Authorization: Bearer <token> unless noted.

Pagination: All list endpoints support cursor-based pagination via ?cursor=&limit= query parameters. Responses return { data: T[], next_cursor: string | null }. Default limit is 20, max is 100.

Introspect (Port 3000)

The hot-path endpoint for real-time authorization checks. Authenticated with an API key, not a session token.

POST /check

Auth: Authorization: Bearer <api_key>

Resolves a user session into an authorization decision. Single CTE query: session → user → roles → role permissions → direct overrides → merged set → optional policy evaluation.

Request Body
FieldTypeRequiredDescription
tokenstringYes*User session token (opaque)
access_tokenstringYes*JWT access token (from orbit-oauth2)
tenant_idstringYesTarget tenant UUID
permissionstringNoSingle permission to check
permissionsstring[]NoBatch permission check
resourceobjectNoResource context for policy evaluation
includestring[]NoExtra fields to return: permissions, roles, flags
flagsstring[]NoFeature flags to evaluate

* Provide token (session-based) or access_token (JWT-based), not both.

Response
{
  "authenticated": true,
  "user": { "id": "...", "email": "...", "name": "...", "tenant_id": "..." },
  "granted": true,           // present if "permission" was requested
  "grants": { "perm": true },// present if "permissions" batch was requested
  "permissions": ["..."],    // present if include contains "permissions"
  "roles": ["..."],          // present if include contains "roles"
  "feature_flags": { "flag": true } // present if include contains "flags"
}
curl Example
curl -X POST https://orbit.example.com/check \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "user-session-token",
    "tenant_id": "tenant-uuid",
    "permission": "documents.edit",
    "resource": { "owner_id": "user-123" }
  }'

Authentication

MethodPathAuthDescription
POST/auth/signupNoneStage a self-signup and email a verification link. Nothing is created yet — returns 202 { email, message }. See /auth/verify/confirm.
POST/auth/verify/confirmNoneConfirm a signup or identity verification token. For a pending signup this creates the tenant + owner user and returns 200 { token, expires_at }; for an already-existing identity it just flips the verified flag and returns 204.
POST/auth/verify/resendNoneRe-send the verification email for a pending signup or unverified identity. Body: { email }. Always returns 204 (no enumeration).
POST/auth/loginNoneAuthenticate. With tenant_id: single-step. Without: pre-tenant session (next: "tenant_select").
POST/auth/logoutSessionDelete current session. Returns 204.
POST/auth/logout/allSessionDelete all sessions for the user. Returns 204.
GET/auth/meSessionCurrent user + tenant + effective permissions.
GET/auth/membershipsSessionList tenants the user belongs to. Returns TenantMembership[].
POST/auth/session/exchangePre-tenantSwap pre-tenant session for tenant-scoped session. Body: { tenant_slug }.
POST/auth/invite/checkNoneCheck an invite token without consuming it. Body: { token }. Returns { status: "valid" | "used" | "expired" | "unknown" }.
POST/auth/invite/acceptNoneAccept invite, set password, get session. Body: { token, password }.
POST/auth/password/resetNoneRequest password reset. Body: { email }, optional tenant_id.
POST/auth/password/reset/checkNoneCheck a reset token without consuming it. Body: { token }. Returns { status: "valid" | "used" | "expired" | "unknown" }.
POST/auth/password/reset/confirmNoneConfirm reset. Body: { token, password }. Returns 204.
Login Flow
# Step 1: Login (no tenant_id → pre-tenant session)
curl -X POST https://orbit.example.com/auth/login \
  -H "Content-Type: application/json" \
  -d '{ "email": "admin@example.com", "password": "secret" }'
# → { "token": "pre-token", "expires_at": "...", "next": "tenant_select" }

# Step 2: List memberships
curl https://orbit.example.com/auth/memberships \
  -H "Authorization: Bearer ${PRE_TOKEN}"
# → { "data": [{ "tenant_id": "...", "tenant_slug": "acme", "tenant_name": "Acme Corp", "roles": ["admin"] }] }

# Step 3: Exchange for tenant-scoped session
curl -X POST https://orbit.example.com/auth/session/exchange \
  -H "Authorization: Bearer ${PRE_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{ "tenant_slug": "acme" }'
# → { "token": "tenant-scoped-token", "expires_at": "...", "next": "complete" }

Tenants

All routes require a tenant-scoped session. Tenant isolation is absolute — you can only access your own tenant's data.

MethodPathPermissionDescription
GET/tenantstenants.read (system)List all root tenants. System tenant only.
POST/tenantstenants.createCreate child tenant. Body: { name }. Caller mirrored as owner.
GET/tenants/:idtenants.readGet own tenant.
PATCH/tenants/:idtenants.updateUpdate tenant. Body: { name?, plan?, metadata? }. Slug is immutable.
DELETE/tenants/:idtenants.deleteSoft-deactivate. Returns 204.
GET/tenants/:id/childrentenants.readList direct children. Paginated.

Users

MethodPathPermissionDescription
GET/tenants/:id/usersusers.readList users in tenant.
POST/tenants/:id/usersusers.createCreate user. Body: { email, name }, optional password.
POST/tenants/:id/users/inviteusers.createInvite by email. Body: { email, role_id }. Creates invite token.
GET/tenants/:id/users/:uidusers.readGet user.
PATCH/tenants/:id/users/:uidusers.updateUpdate user. Body: { name?, email?, metadata?, password? }.
DELETE/tenants/:id/users/:uidusers.deleteSoft-deactivate. Returns 204.

User Roles

MethodPathPermissionDescription
GET/tenants/:id/users/:uid/rolesusers.readList roles assigned to user.
POST/tenants/:id/users/:uid/roles/:roleIdroles.updateAssign role. Returns 204.
DELETE/tenants/:id/users/:uid/roles/:roleIdroles.updateRevoke role. Returns 204.

User Direct Permissions

MethodPathPermissionDescription
GET/tenants/:id/users/:uid/permissionsusers.readEffective permission names (role + direct, minus explicit deny).
POST/tenants/:id/users/:uid/permissionspermissions.attachGrant direct permission. Body: { permission_id, granted?, expires_at? }.
PATCH/tenants/:id/users/:uid/permissions/:permIdpermissions.attachUpdate granted/expires_at.
DELETE/tenants/:id/users/:uid/permissions/:permIdpermissions.detachRemove direct permission. Returns 204.

Roles

MethodPathPermissionDescription
GET/tenants/:id/rolesroles.readList all roles in tenant.
POST/tenants/:id/rolesroles.createCreate role. Body: { name, description? }.
GET/tenants/:id/roles/:roleIdroles.readGet role.
PATCH/tenants/:id/roles/:roleIdroles.updateUpdate. Body: { name?, description? }.
DELETE/tenants/:id/roles/:roleIdroles.deleteDelete (cascades). Returns 204.

Role Permissions

MethodPathPermissionDescription
GET/tenants/:id/roles/:roleId/permissionsroles.readList permissions assigned to role.
POST/tenants/:id/roles/:roleId/permissions/:permIdroles.updateAssign permission. Returns 204.
DELETE/tenants/:id/roles/:roleId/permissions/:permIdroles.updateRemove permission. Returns 204.

Permissions

Permissions are global (not tenant-scoped). Creating permissions requires the system tenant.

MethodPathPermissionDescription
GET/permissionspermissions.readList all. Optional query: module_id filter.
POST/permissionspermissions.create (system)Create permission. Body: { module_id, name, description? }.
GET/permissions/:idpermissions.readGet permission.

Modules

Modules group permissions (e.g. users, roles, oauth2).

MethodPathPermissionDescription
GET/modulesmodules.readList all modules.
POST/modulesmodules.create (system)Create module. Body: { name, slug, description? }.
GET/modules/:idmodules.readGet module.
PATCH/modules/:idmodules.update (system)Update. Slug is immutable.

Policies

Policies add runtime conditions to permission checks. When a permission has attached policies, the introspect endpoint evaluates each policy handler. AND logic: all must pass.

MethodPathPermissionDescription
GET/policiespolicies.readList all policies.
POST/policiespolicies.create (system)Create. Body: { name, type, parameters?, description? }.
GET/policies/:idpolicies.readGet policy.
PATCH/policies/:idpolicies.update (system)Update. Type is immutable.
DELETE/policies/:idpolicies.delete (system)Delete (cascades). Returns 204.

Policy Attachment

MethodPathPermissionDescription
GET/permissions/:id/policiespermissions.readList policies attached to a permission.
POST/permissions/:id/policies/:policyIdpolicies.attach (system)Attach policy to permission. Returns 204.
DELETE/permissions/:id/policies/:policyIdpolicies.detach (system)Detach. Returns 204.

Built-in Policy Types

TypeDescriptionParameters
ownershipChecks if the user owns the resource{ owner_field: "owner_id" }
department_matchChecks if user's department matches the resource{ field: "department_id" }
resource_stateChecks resource state against allowed values{ field: "status", allowed: ["draft"] }
business_hoursTime-of-day restriction{ start: "09:00", end: "17:00", tz: "America/Toronto" }

API Keys

API keys authenticate consuming applications against the introspect endpoint. The raw key is returned once at creation and stored as a SHA-256 hash.

MethodPathPermissionDescription
GET/tenants/:id/api-keysapi-keys.readList API keys (never exposes key hash).
POST/tenants/:id/api-keysapi-keys.createCreate. Body: { name, expires_at? }. Returns raw key once.
DELETE/tenants/:id/api-keys/:keyIdapi-keys.revokeSoft-revoke. Returns 204.

Audit Logs

Every mutation is recorded. Logs capture old/new values, the acting user, IP address, and timestamp.

MethodPathPermissionDescription
GET /tenants/:id/audit-logs audit.read List logs. Filters: action, user_id, from, to (ISO timestamps), model_type. Default limit 50, max 100.

Feature Flags

Flags are defined globally and can be overridden per-tenant. Effective value = tenant override > global default.

Global Flags (System Tenant)

MethodPathPermissionDescription
GET/feature-flagsfeature-flags.read (system)List all global flags.
POST/feature-flagsfeature-flags.create (system)Create. Body: { name, enabled?, description? }.
GET/feature-flags/:namefeature-flags.read (system)Get global flag.
PATCH/feature-flags/:namefeature-flags.update (system)Update global default.

Tenant Flags

MethodPathPermissionDescription
GET/tenants/:id/feature-flagsfeature-flags.readEffective flags (override + global merged).
GET/tenants/:id/feature-flags/:namefeature-flags.readSingle effective flag value.
PUT/tenants/:id/feature-flags/:namefeature-flags.updateSet tenant override. Body: { enabled }.

OAuth2 Clients

OAuth2 client management is proxied through orbit-service to orbit-oauth2's internal API. All mutations are audit-logged. Clients created here are tenant-owned: only your tenant can see or mutate them, and POST accepts an optional environment string that gets stamped into the client's issued tokens as the env claim. Clients with a third-party consent_uri/consent_jwk aren't creatable here yet — see POST /oauth2/register.

MethodPathPermissionDescription
GET/tenants/:id/oauth2-clientsoauth2.readList clients.
POST/tenants/:id/oauth2-clientsoauth2.createCreate client. Secret returned once for confidential clients.
GET/tenants/:id/oauth2-clients/:clientIdoauth2.readGet client.
PATCH/tenants/:id/oauth2-clients/:clientIdoauth2.updateUpdate client.
DELETE/tenants/:id/oauth2-clients/:clientIdoauth2.deleteDelete (cascades). Returns 204.
POST/tenants/:id/oauth2-clients/:clientId/rotate-secretoauth2.updateRotate secret (confidential only).
POST/tenants/:id/oauth2-clients/:clientId/revokeoauth2.updateRevoke all tokens for client.
GET/tenants/:id/oauth2-clients/:clientId/grantsoauth2.readList grants (subject + scopes).
DELETE/tenants/:id/oauth2-clients/:clientId/grants/:subjectoauth2.updateRevoke specific grant. Returns 204.

Signing Keys (System Tenant)

MethodPathPermissionDescription
GET/tenants/system/oauth2-keysoauth2.readList signing keys.
POST/tenants/system/oauth2-keys/rotateoauth2.createGenerate new EC P-521 keypair.
POST/tenants/system/oauth2-keys/:kid/retireoauth2.updateRetire key (stays in JWKS for validation).

Domains

Domain inventory, CNAME ownership verification, and TLS expiry monitoring. A domain must be verified before a certificate can be requested for it.

MethodPathPermissionDescription
GET/tenants/:id/domainsdomains.readList domains.
POST/tenants/:id/domainsdomains.createRegister a domain. Body: { hostname }. Enqueues an initial verify.
GET/tenants/:id/domains/:hostnamedomains.readGet domain, including the CNAME target to verify ownership.
DELETE/tenants/:id/domains/:hostnamedomains.deleteRemove domain. Returns 204.
POST/tenants/:id/domains/:hostname/verifydomains.updateRe-check CNAME ownership now. Returns { verified, error }.
POST/tenants/:id/domains/:hostname/probedomains.updateRe-check live TLS cert expiry now. Returns { expires_at, error }.

Issuers

An issuer is either an acme directory (Let's Encrypt or any RFC 8555 CA) or an internal-ca self-signed root for private/internal domains. Secret material (ACME account key, EAB HMAC) is referenced by secret_id, never accepted or returned inline. An internal-ca issuer may bring its own root (ca_certificate_pem + ca_private_key_pem, P-256 ECDSA only) or self-generate one.

MethodPathPermissionDescription
GET/tenants/:id/issuersissuers.readList issuers. Lazily seeds a default internal-ca issuer if none exists.
POST/tenants/:id/issuersissuers.createCreate issuer. Body: { name, type, directory_url?, ca_certificate_pem?, ca_private_key_pem? }.
GET/tenants/:id/issuers/:nameissuers.readGet issuer, including ca_certificate_pem for internal-ca issuers.
DELETE/tenants/:id/issuers/:nameissuers.deleteDelete issuer. Returns 204. The default issuer cannot be deleted.

Certificates

Certificate orders are created and read through their owning domain. Issuance runs asynchronously on the job worker: POST enqueues certificate.issue and returns 202 immediately; poll the order or list endpoint for status to reach valid or failed. certificate_pem/chain_pem are public once issued; the leaf private key is never returned, only a reference to its secret id. A separate tenant-wide roll-up endpoint lists orders across every domain at once — order-level history (which issuer signed what, failed orders anywhere in the fleet), complementing the per-domain expiry snapshot at GET /domains.

MethodPathPermissionDescription
GET/tenants/:id/certificatescertificates.readTenant-wide order roll-up across every domain, newest first. Optional ?status= filter (issuing/valid/failed).
GET/tenants/:id/domains/:hostname/certificatescertificates.readList orders for the domain, newest first.
POST/tenants/:id/domains/:hostname/certificatescertificates.createRequest issuance. Domain must be verified. Returns 202 { job_id }.
GET/tenants/:id/domains/:hostname/certificates/:orderIdcertificates.readGet order, including certificate_pem/chain_pem.

Jobs

Read-only visibility into the cert-module job queue: domain.verify, domain.probe-expiry, and certificate.issue (renewals reuse the same type — there is no separate renewal job type). This is the only place a job that failed before a certificate_orders row existed, or one sitting in retry backoff, is visible at all.

MethodPathPermissionDescription
GET/tenants/:id/jobsjobs.readList cert-module jobs, most recently updated first. Optional ?status= filter (pending/running/succeeded/failed).
GET/tenants/:id/jobs/healthjobs.readWorker fleet liveness (last heartbeat, last renewal/probe sweep) plus this tenant's own overdue-pending/stuck-running job counts. No worker ids/hostnames/pids — that system-wide detail is operator-only, via scripts/diagnose-workers.ts.

Secrets

Encrypted key/value storage, scoped by tenant and optionally by environment. Session (dashboard) endpoints never return plaintext, only ciphertext metadata (version, key version, active flag) — the only place plaintext is ever decrypted is POST /tenants/:id/secrets/lease, a separate JWT-authed endpoint for machine clients (no session token, no dashboard route). A secret only becomes leasable once escrow_enabled is set via the escrow endpoint; cert-module private keys (leaf keys, ACME account keys, internal-CA root keys) are created with escrow off. POST .../rotate and PUT .../values both write a new version, but differ in whether a value must already exist: PUT requires the environment to be new (422 otherwise, meaning "use rotate"); POST rotate works whether or not one exists, deactivating the prior active version for that environment.

MethodPathPermissionDescription
GET/tenants/:id/secretssecrets.readList secrets (metadata + active environments only, no values).
POST/tenants/:id/secretssecrets.createCreate a secret. Body: { name, description?, value?, environment? }. Name must start with a letter (alphanumeric, _, -, . only).
GET/tenants/:id/secrets/:namesecrets.readGet secret metadata plus its full version history per environment (still no plaintext).
PATCH/tenants/:id/secrets/:namesecrets.writeUpdate description.
DELETE/tenants/:id/secrets/:namesecrets.deleteDelete the secret and all its versions (cascade). Returns 204.
PUT/tenants/:id/secrets/:name/valuessecrets.writeSet the first value for an environment. Body: { value, environment? }. 422 if one already exists.
POST/tenants/:id/secrets/:name/rotatesecrets.rotateWrite a new version for an environment, deactivating the prior active one. Body: { value, environment? }.
PATCH/tenants/:id/secrets/:name/escrowsecrets.escrowOpt the secret in or out of the machine lease endpoint. Body: { enabled }.
POST/tenants/:id/secrets/leaseJWT scope secrets:read (not a session permission)Machine-only: decrypt and return plaintext for one or more escrowed secrets, scoped to the JWT's env claim. Body: { name } or { names: string[] }.