API Reference
Almost everything in Stone-Age.io is stock PocketBase REST against a collection —
GET /api/collections/things/records, PATCH /api/collections/locations/records/:id,
and so on — governed entirely by the API rules in schema.json
(Authorization & Roles). The
PocketBase API docs cover that surface,
and this page does not repeat it.
This page covers the ten endpoints the platform adds on top of that: nine
under /api/, plus /metrics. Each of the application routes exists because a
PocketBase API rule could not express what it needed to.
1. The whole surface
| Route | Method | Who may call it | Does |
|---|---|---|---|
/api/client-config |
GET |
any users session |
Deployment facts the SPA cannot be compiled with |
/api/me/leaf-config |
GET |
any things session |
Ten fields an agent needs to stand up a NATS leaf server |
/api/me/nats-creds/rotate |
POST |
any users or things session |
Rotate the caller's own NATS credential |
/api/org/invites/accept |
POST |
any authenticated caller | Redeem an invitation token |
/api/org/things |
POST |
member+ for inventory, owner/admin for identities |
Create a Thing and, optionally, its NATS and Nebula identities, in one transaction |
/api/org/nats-account/keys |
POST |
owner / admin |
Manage the organization's NATS account signing keys |
/api/org/nebula-ca/rotate |
POST |
owner / admin |
Roll the organization's Nebula CA, in three steps |
/api/org/nebula/cert-audit |
GET |
owner / admin |
Hosts whose certificate no longer matches their network |
/api/ready |
GET |
unauthenticated | Readiness probe, 200 or 503 |
/metrics |
GET |
unauthenticated by default | Prometheus exposition |
Roles are per-organization memberships and resolve against the caller's active
organization (users.current_organization). See
Authorization §2.
Two things are deliberately left out of the table, because neither is a route the platform adds to answer something a rule cannot:
GET /branding/{path}— unauthenticated static files for the operator's theme overlay (theme.css,logo.svg,branding.json). It serves a host directory, not data. See Configuration §branding.POST /api/files/token— stock PocketBase, but you cannot fetch an uploaded file without it. Every file field on the platform is protected, so/api/files/...accepts only a short-lived file token (?token=), minted by this endpoint, and checks it against the collection's view rule. An auth token is not a file token: a URL built with one answers404. See Platform Entities §5.
2. Three rules that apply to all of them
No route takes a record id. Every /api/me/* route targets the caller's own
authenticated record; every /api/org/* route targets the caller's active
organization. The target is derived from the session, never named in the request,
so none of these can be aimed at another tenant. When you are reading a route here
and looking for the id parameter, its absence is the security property.
A route that writes with app.Save() bypasses every API rule. So each check
the rules would have made is restated inside the route. POST /api/org/things is
the worked example: the organization comes from the caller's own record, and a
linked nats_user or nebula_host is verified to belong to that organization —
without that second check the route would be a cross-tenant credential-theft path.
Rules protect the CRUD endpoints, not these.
Each one exists for one of three reasons, and it is worth knowing which, because it tells you what a proposed new route has to justify:
| Reason | Routes |
|---|---|
A rule cannot express a single-field allowlist (the alternative is :isset = false on every other field, a deny-list that opens up silently when a field is added) |
nats-creds/rotate, nats-account/keys, nebula-ca/rotate |
| One operation needs two authority levels | org/things |
| The answer needs something the caller cannot compute or must not read whole | leaf-config, nebula/cert-audit, client-config |
3. Identity routes (/api/me/*)
GET /api/client-config
Deployment facts the single-page console needs at runtime but cannot be compiled
with. Requires a users session.
{ "natsWebsocketUrls": ["wss://bus.acme.io:9222"] }
The value is nats.websocket_urls from config.yaml. It is not derived from
nats.server_url: the first is a TCP address this process dials to publish
account claims, the second is a WebSocket listener a browser dials, on a
different port and often a different hostname. An empty list is a valid answer and
means "not configured" — the console then falls back to its compiled-in
ws://localhost:9222.
The response is deployment-wide rather than per-organization, and that follows
from the NATS hierarchy: every organization is an account under one operator, so
they all live on the same cluster. The axis that genuinely varies is location —
hub versus a specific leaf — which is a property of the box the browser runs on,
so it is a device-level override in localStorage rather than anything the server
knows. See Configuration §nats.
Why it requires auth when the value is not a secret
Nothing needs it before login — the console will not dial the bus without a session and a linked NATS identity anyway. Since there is no pre-login need, there is no reason to hand an unauthenticated scanner the address of the bus.
GET /api/me/leaf-config
Everything an Agent needs to stand up a NATS leaf server. Bound to
the things collection; the target is the caller's own record.
{
"code": "s01",
"domain": "s01",
"creds": "-----BEGIN NATS USER JWT-----\n…",
"account_jwt": "eyJ0…",
"account_pub": "AD3…",
"operator_jwt": "eyJ0…",
"sys_account_jwt": "eyJ0…",
"sys_account_pub": "ACY…",
"hub_leaf_url": "nats-leaf://hub.acme.io:7422",
"hub_domain": "hub"
}
domain is the same string as code — the JetStream domain is computed from the
Thing's code and never stored, because a stored column could disagree with the
code it was derived from, and when it did the symptom was a site that silently
stopped appearing. The agent writes it into both server_name and
jetstream { domain }.
The route gates on nothing beyond being an authenticated Thing, and that is
deliberate. Everything it serves is either public trust material — the operator,
account and $SYS account JWTs, which every server in the network validates
anyway — or the caller's own credential, which it must already hold to connect at
all. A Thing that will never run a leaf node can call it and learns nothing it
could not already read.
What is not served: account seeds, signing keys, and any $SYS user
credential. nats_system_operator stays superuser-only. The server reads the
secret-bearing collections with its own privileges and returns ten named fields,
never whole records — so the blast radius of a leaked edge credential is those ten
values regardless of how those collections' rules later evolve.
Do not add a device read branch to nats_* or nebula_*
Extend this route instead. The point of it is that the edge's blast radius is a fixed list of named fields rather than a consequence of rules that change for unrelated reasons. See Leaf Nodes §3.
The field names are a cross-repo contract — the agent decodes them by name, in a different module — so a rename here is a breaking change for every deployed site.
POST /api/me/nats-creds/rotate
Re-mint the caller's own NATS credential. Available to every role, including
dashboard, for callers in both users and things.
POST /api/me/nats-creds/rotate
(no body)
{ "rotated": true, "nats_user": "a1b2c3d4e5f6g7h" }
It writes exactly one field, regenerate, on the caller's linked nats_users
row. pb-nats watches that field, re-mints the JWT and creds_file, then clears
it — so re-read your own record afterwards to pick up the new credential.
For a users caller the identity is the nats_user on the membership for the
active organization; for a things caller it is the relation on the Thing itself.
Either way nothing is read from the request. (Which identity a membership links to
is itself owner/admin-only — every role may keep or clear its own link but not
re-point it, because the credential read follows the link. See
Authorization §4.)
| Response | When |
|---|---|
200 |
Re-minted |
400 |
A users caller with no active organization |
403 |
The linked identity is suspended (active = false) |
404 |
No membership in the active organization, or no identity linked |
A suspended identity cannot rotate itself back to life
pb-nats treats active = false as "revoked, reissue nothing". But a
regenerate mints a JWT issued after the account's revocation cutoff, which
NATS accepts — so without the 403 this route would be a self-service
un-suspend. Suspending and reactivating stay owner/admin actions through the
normal update rule on nats_users, or a consequence of
deactivating the device
that holds the identity. Rotation is also not how you retire a leaked
file: regenerate re-signs for the same seed, so the leaked .creds keeps
working. That is revoke, which moves to a new key pair (owner/admin).
4. Organization routes (/api/org/*)
POST /api/org/invites/accept
Redeem an invitation token. Any authenticated caller; the invitation is matched to the caller by email address, case-insensitively.
{ "token": "8f3a…" }
{ "message": "Successfully joined organization.", "organization": "9k2j…" }
| Response | When |
|---|---|
200 with organization |
Membership created with the role the invitation carried |
200 with "alreadyMember": true |
You were already in; the invitation is deleted as cleanup. Usually a double-clicked link |
400 |
No token in the body |
403 |
The invitation was issued to a different email address |
404 |
No invitation with that token |
410 |
Expired. The invitation is deleted on the way out |
Creating the membership, setting current_organization when it was blank, and
deleting the invitation all happen in one transaction. current_organization is
set only when blank — accepting an invitation to a second organization should
not move you out of the one you are working in.
Renamed in 0.6.0
This was POST /api/tenancy/accept-invite until pb-tenancy was absorbed into
the platform. The old path named a library that no longer exists. Invitation
links already in delivered mail are unaffected — they point at the console
route /accept-invite, which posts here, not at the API directly. Anything
driving invitations outside the console needs updating.
From the CLI this is stone invite accept <token>, where the token is the
?token= value from the invitation link and not the invite record's id.
Redeeming sets current_organization only when it was blank, so follow it with
stone org switch — which is also what writes the nats-cli context the new
membership has no creds for yet.
POST /api/org/things
Create a Thing and, optionally, mint its NATS identity and Nebula host in one
transaction. Requires a users session with member, admin or owner in the
active organization.
{
"name": "Lobby Camera",
"code": "cam-lobby",
"description": "",
"type": "<thing_types id>",
"location": "<locations id>",
"metadata": {},
"nats": { "mode": "auto", "role_id": "<nats_roles id>" },
"nebula": { "mode": "none" }
}
Each identity block takes a mode:
| Mode | Effect | Extra fields |
|---|---|---|
auto |
Mint a new identity | nats.role_id (optional — defaults to the organization's is_default role); nebula.network_id, nebula.overlay_ip |
link |
Attach an existing one, verified to belong to this organization | nats.user_id; nebula.host_id |
none |
Leave it unbound — a pure inventory row | — |
name is required (400 without it). code is optional: left blank, the route
generates one under the Thing Type's prefix (CA-9KD-4PX), before anything is
saved, because the email and the NATS username are both built from it. The
response carries the code either way. A supplied code is refused if another
Thing in the organization already holds it in any case (cam-1 against
CAM-1). See Generated codes.
An absent identity block is none, and an unrecognised mode is rejected rather
than treated as none.
nats.mode: "auto" needs an active NATS account to sign under, so while the
organization is suspended it
answers 400 ("no active NATS account for this organization"). link and none
still work.
{
"id": "7h8i9j0k1l2m3n4",
"code": "cam-lobby",
"email": "cam-lobby@acme.thing.local",
"password": "…"
}
The email is generated as <thing code>@<organization code>.thing.local — the
organization's code, never its name, because a name is not unique and can
change.
The password is returned exactly once — PocketBase stores only its hash, so this response is the only chance to record it.
Two authority levels in one operation, which is why this is a route. Creating
inventory is a member action; attaching an identity is not. things.createRule
approximates the split by freezing nats_user / nebula_host in the member
branch, but a provisioning endpoint that mints those records cannot be expressed
as a create rule at all. Here it is a role check per section: a member calling
with anything other than none on both blocks gets 403.
Why one transaction, and why the atomicity is real
This replaced three unguarded client calls whose partial failure orphaned a
signed NATS credential and an allocated overlay IP, and which never sent
active, so every Thing the console created was locked out by
things.authRule. PocketBase defers *AfterCreateSuccess hooks to commit, and
pb-nats mints and publishes on that hook — so a rollback means pb-nats never
signed anything and never published. The failure mode is "nothing happened",
not "NATS knows about a user PocketBase forgot".
POST /api/org/nats-account/keys
Manage the signing keys on the active organization's NATS account. Owner/admin only.
{ "action": "add_signing" }
| Action | Effect |
|---|---|
add_signing |
Graceful rotation: appends a new signing key. Existing user JWTs stay valid |
remove_signing |
Removes one key by public_key (required in the body). pb-nats refuses to remove the last remaining key |
rotate |
Emergency replacement: purges every signing key and generates one. Every user JWT in the account stops validating and must be re-minted |
{ "applied": "add_signing", "nats_account": "3c4d…" }
Reach for add_signing for routine rotation; rotate is for suspected key
compromise.
The switch is the allowlist — each action sets exactly one field, and an
unrecognised action is rejected rather than ignored. nats_accounts.updateRule is
Platform-Operator-only, because the record mixes fields a tenant may legitimately
trigger with the account limits it was sold and the signed account jwt. See
Authorization §4.1.
POST /api/org/nebula-ca/rotate
Roll the active organization's Nebula CA. Owner/admin only, and the console presents it as a three-step panel on the CA detail view.
{ "step": "prepare" }
| Step | What it does | Reversible |
|---|---|---|
prepare |
Publishes the new CA as trusted without moving issuance | Yes — fully |
commit |
Swaps issuance to the new CA and re-signs every active host. Idempotent, so re-running recovers a partial sweep | The outgoing CA is still trusted |
finish |
Drops the outgoing CA. Refused while any active host still holds a certificate signed by it, and the error names the host | No |
{ "applied": "prepare", "nebula_ca": "5e6f…" }
The route allowlists the three verbs; pb-nebula validates the transition and its
message is surfaced verbatim on a 400, because that message is the whole reason
the interlock is usable.
Three steps because the wait between them is the feature. Nebula verification
is mutual and config distribution is pull-based, so a single write carrying both
new trust and new certificates splits the mesh: a host that has fetched presents a
new-CA certificate to one that has not, and the handshake fails in both
directions. prepare exists to land the trust half first, everywhere.
The tenant owns this lever deliberately — a Platform Operator cannot judge when a fleet has caught up. Requires pb-nebula v0.3.2. See Authorization §4.3.
GET /api/org/nebula/cert-audit
Which of the organization's Nebula hosts hold a certificate whose network no longer matches the network the host belongs to. Owner/admin only.
{ "stale": ["4f5g6h7i8j9k0l1", "2m3n4o5p6q7r8s9"] }
stale is never null — an empty array means "no host needs attention", where a
null would read as "the audit did not run".
It exists because pb-nebula signed host certificates at /32 until v0.3.0. Nebula
puts a certificate's network straight onto the tun device and installs a link
route for it, so the mask is the host's route to the overlay: a /32 verifies,
renders, handshakes — and moves no packet. Nothing errors anywhere.
A route because answering it means parsing a Nebula certificate, which no browser can do.
Read-only, and nothing is re-signed automatically
Re-signing moves a certificate's fingerprint, and a fingerprint is what
pki.blocklist revokes — so a sweep would rewrite every peer config in the
mesh on the strength of a dependency bump. The audit names the hosts; the fix
is renew on one host at a time, then redeploy that host's config.
Inactive hosts are excluded, and not as an optimization: an inactive host is revoked, so re-signing it would publish a new fingerprint while the old certificate stayed valid and un-blocklisted — silently un-revoking it. A host whose certificate or network cannot be read is omitted rather than reported.
5. Observability
Both are served by the Control Plane itself and need no session and no NATS connection. Full detail, including every check and metric, is on Health & Metrics.
GET /api/ready
Unauthenticated, and always served — a probe endpoint that can be disabled is one some deployment will disable and then be unable to explain.
{
"ready": true,
"state": "warn",
"version": "0.8.0",
"uptime": "4h12m",
"took": "3ms",
"checked": "2026-09-18T09:14:02Z",
"checks": [
{ "name": "nats_reachable", "state": "ok", "took": "2ms" },
{ "name": "nebula_cert_expiry", "state": "warn",
"detail": "expiring: 1 host certificate within 30 days (soonest 2026-10-11)",
"fix": "Re-issue before the date above. Nebula certificates fail all at once and silently." }
]
}
Ten checks: database, schema, schema_version, bootstrap, nats_operator,
nats_reachable, nats_trust, nebula_cert_expiry, nats_websocket_urls,
encryption_at_rest.
Four states, and only fail is unready — so the endpoint answers 503 only
when something is genuinely broken, and 200 for warnings. That is what makes it
safe in front of a load balancer.
| State | Means |
|---|---|
ok |
Checked and healthy |
warn |
Running, but misconfigured. Still 200 |
skipped |
The check could not look. Ranks below ok |
fail |
Unready. 503 |
Every non-OK check carries remediation guidance in fix — a command where there
is one, prose where the fix is a judgement. Probing runs in the
background on readiness.interval, off the startup path — so during startup the
endpoint correctly answers "503, not probed yet" rather than blocking the listener
on a NATS dial timeout.
GET /metrics
Prometheus exposition. On by default (metrics.enabled), unauthenticated by
default, and closed with metrics.token (Bearer or Basic) or a proxy.
Two constraints worth knowing before you add a series:
- No per-organization labels.
/metricsis open by default, and a tenant name beside a certificate inventory is free reconnaissance. stone_age_records{collection="things"}counts devices CONFIGURED, not online. An alert on it can never fire. Anything mistakable for a health signal says so in its HELP text.
Alert on certificate expiry relative to now, so the horizon lives in the alert:
stone_age_certificate_expiry_seconds - time() < 30 * 86400
Give the CA a wider horizon than a host — 90 days, not 30. A host certificate is reissued in a moment; a CA can only be rotated, which is a staged procedure with a wait in the middle of it.
6. What is deliberately not here
- No resolver service. A QR label carries a bare code and nothing fetches the decoded string as a destination. See ADR 0002.
- No bulk certificate re-issue. See
cert-auditabove. - No route granting Platform Operator status.
bootstrapand the embedded admin panel are the only two paths.users.updateRulerefusesis_operator, and so does every branch ofusers.createRule— including the one that lets a Platform Operator onboard users, so an operator cannot mint an operator. - No server-side twin push.
twin_desiredis a delivery mechanism — nothing in the platform applies a desired value to a device. See Architecture §4.3. - JetStream and KV management is not an HTTP API. Streams and buckets are
created over the browser's own NATS connection, so they are bounded by the
caller's NATS permissions rather than by PocketBase API rules — and, in
total, by the account's JetStream storage limits (
max_jetstream_disk_storage,max_jetstream_memory_storage), which are set when the account is provisioned.
7. Where to Go Next
- Who may call what, and the rules behind it: Authorization & Roles.
- The same operations from a terminal: Stone CLI.
- The edge identity model
leaf-configserves: Leaf Nodes. - Every check and metric in full: Health & Metrics.
- Config keys the routes read: Configuration Reference.