The Control Plane for Stone-Age.io: one HTTP API for the things and places you manage, which also mints the credentials that let them talk. Multi-tenant inventory, NATS account and credential management, and Nebula overlay networks, in one Go binary with the console compiled into it.
Used at its shallowest it is a REST inventory of Things and Locations — a device needs no NATS user and no Nebula host to exist as a record. Used at its fullest it provisions and keeps in sync the identities an entire event-driven fabric runs on.
docker run -d --name stone-age \
-p 8090:8090 -p 4222:4222 -p 9222:9222 \
-v stone-age-data:/data \
-e STONE_AGE_BOOTSTRAP_PASSWORD='change-me-8-chars-min' \
-e STONE_AGE_NATS_WEBSOCKET_URLS='ws://localhost:9222' \
ghcr.io/stone-age-io/platform:latestConsole at http://localhost:8090, admin panel at http://localhost:8090/_/,
log in as admin@example.com with the password you set. One container: the same
process serves the API, the console, and the NATS server the console talks to.
STONE_AGE_NATS_WEBSOCKET_URLS is the address a browser dials, which the
container cannot work out for itself — put the host's real name there rather than
localhost if anyone else will use it.
Needs Go 1.26+ and Node 20.19+ (or 22.12+, whichever your distribution has).
cd ui && npm install && npm run build && cd ..
go build -o stone-age .
./stone-age superuser upsert admin@example.com 'change-me-8-chars-min'
./stone-age migrate up
./stone-age bootstrap --email admin@example.com --org "System" --operator-org "your-company"
./stone-age nats export --output ./nats-config/
./stone-age serve --natsThe first line builds the console into pb_public/; the second embeds it.
The order of the middle three is load-bearing. bootstrap writes
is_operator, is_system_org and is_operator_org, which only exist after
schema.json has been imported by migrate up. PocketBase silently discards a
write to a field that does not exist, so running bootstrap first would print
success while leaving you with no platform operator at all. It now refuses to run
in that state and says so. superuser upsert has to come first as well: it seeds
the NATS operator and $SYS records the other two link up.
Everything except the last line is once per deployment. After the first run,
./stone-age serve --nats is the whole thing.
Measured on a clean clone: about two minutes of machine time from git clone to
a console serving with the bus up, of which roughly ninety seconds is npm install and the Vite build. The Go build is nine seconds and the four seeding
commands are eleven.
Granting operator status is deliberately impossible through the API. This command or the admin panel, nothing else.
A freshly bootstrapped deployment is empty, and most of what this thing does is only visible once there is something in it — the maps, the floor plans, the contract screens, the KV browser. One command fills it:
./stone-age demo-seed --confirmThree fictional tenants — a cold-chain distributor, a manufacturer, and a wind
operator — each with locations on a real map, a type taxonomy with JSON Schemas,
things spanning devices, gateways, applications and unattended screens, NATS
roles and signed identities, a Nebula network with a lighthouse, edge sites, and
members holding all five console roles. Every login is demo1234; start with
dana@northwind.example, or casey@msp.example to see the organization switcher
with three tenants behind it.
It runs offline — account and user JWTs are signed locally and the claim
publishes queue until a server is reachable — and it is idempotent, so re-running
tops up rather than duplicating. --things 400 if you want a bigger fleet.
Two of the three organizations are managed, so if you bootstrapped with an
--operator-org you also get the cross-account wiring: their helpdesk.> subtree
exported into the operator hub and remapped to helpdesk.northwind.> /
helpdesk.galewind.>. Those three organization codes are the same ones the
helpdesk's own seed-demo uses, so
the two demos name the same tenants.
Northwind's inventory also carries the stone-access estate — four access
controllers and ten doors and gates, with the contract layer to match
(acc.{location}.door.{thing}, decision and alarm schemas, the operator-grant
command). Every one of those codes is also a record in
access-control, whose own
accessd demo-seed --confirm seeds the same three sites by the same codes. Run
both and a door in one app and a Thing in the other are the same door — which is
what organizations.code and the per-org code namespace are for.
The same tenant carries the tool crib estate: three self-service checkout
kiosks, a virtual timeclock terminal, and the controller that aggregates them,
with their own contract layer (kiosk.{thing}, sixteen operations across the
event / command / heartbeat / sighting families). These codes are also kiosks
rows in kiosk, whose kiosk-controller demo-seed --confirm builds the matching estate at the same three sites. Note the
prefix has no {location} segment where the access one does: a kiosk's own code
is the routing token, because the controller's event stream binds kiosk.*.event.>
and its commands address kiosk.<code>.command.<name>.
--confirm is required and is the whole safety mechanism: this ships in the
binary you run in production, and the command writes real signed credentials.
The seed writes records; a dashboard needs traffic. demo/rules/
holds rule-router scheduler rules — one
directory per organization, because the NATS account is the tenant boundary —
that publish on the exact subjects each seeded thing type declares. Charts,
stats, the console widget and the KV browser all have something to draw within a
minute. Run the bus with serve --nats and the platform is its own NATS server.
Multi-tenancy. Organizations, memberships, invitations, and five roles.
Isolation is enforced entirely by the PocketBase API rules in schema.json —
the NATS and Nebula libraries contain no tenancy logic and never reference
organization. The console's capability map is navigation convenience, not a
boundary.
NATS in operator mode. Creating an organization provisions its account. Roles are permission templates; a user JWT is signed from one. Credentials rotate, revocation actually disconnects, and account signing keys are manageable. Nothing about a device's console session gives it bus access — the signed credential is the capability.
Nebula overlay networks. A certificate authority per organization, plus networks, lighthouses and host certificates. This is the out-of-band path: SSH and management traffic that has to work when NATS does not.
Inventory as identity. A Thing is one record that is simultaneously an inventory entry, a login, a NATS identity and a mesh node. Deactivating it takes all four away in one operation — the API rule blocks new logins, outstanding tokens are invalidated immediately, and the NATS credential is revoked.
One name for a tenant, everywhere. organizations.code is the ecosystem's
single globally unique identifier; everything under it is unique only within its
organization. The rule is ids for storage, codes for addressing — relation
columns stay PocketBase ids, while anything that has to survive leaving this
database (a NATS subject, a URL, a sticker on a wall) travels by code. The
managed-org subject rewrite roots at it, so a sibling app reading
helpdesk.{code}.> and a person scanning a label are naming the same tenant.
Codes are optional but immutable: mutability, not optionality, was what
disqualified the alternative. See ADR 0002 in
platform-docs.
QR labels. Print an operator-branded label for any thing or location that
has a code, sized in millimetres to real stock (2″ × 1″ and 4″ × 2″, both
reserving the centred RFID inlay keep-out so one layout prints on plain or RFID
media). The payload is the bare code — no host, no organization, no kind
token — because a sticker in a public hallway is something a stranger can
replace, and a URL payload would let a forged label send a person to arbitrary
content. Scanning happens inside an app (the scanner widget here, /staff/scan
in the helpdesk); nothing ever fetches the decoded string as a destination.
The contract layer. Declarative device contracts describing where a participant speaks, so a consumer can resolve its subjects from data alone:
- Thing Types (
thing_types) define a subject prefix and a set of operations. - Operations (
thing_type_operations) declare a capability (publish/subscribe/request/reply) and a subject suffix.
Payload shape is deliberately not described. A message_schemas collection
held a JSON Schema per operation and was dropped: nothing validated against it,
so an invalid schema document saved cleanly and rendered zero fields.
Edge sites. Each is a leaf_nodes record — a special kind of thing, with one
server-provisioned NATS user. The separate leaf-sync
agent runs on the edge, authenticates as the leaf node, and mirrors its
organization's configuration into a NATS leaf node's local JetStream KV. It can
host that leaf node itself (leaf-sync run --nats), so an edge site is one
service rather than two.
Digital twin. Two KV buckets per organization with one writer each: twin
for reported state flowing edge-to-hub, twin_desired for desired state flowing
hub-to-edge. Drift is shown as the values themselves, not a status word.
Dashboards. Grid layout, 17 widget types, three data-source kinds (subscription, consumer, KV), variable substitution, and a live NATS WebSocket connection straight from the browser.
Audit logging across every create, update, delete and auth event, with a searchable viewer.
This component is one binary — Go backend, embedded Vue console, SQLite, no
runtime dependencies, and with --nats the message bus too. The platform is
not one binary: it is a small set of independent single-binary components (this
Control Plane, nebula, rule-router, the Agent, leaf-sync) that find each
other over NATS. Deploy each where it belongs.
A config.yaml in the working directory or /etc/stone-age/, or environment
variables prefixed STONE_AGE_ (STONE_AGE_NATS_SERVER_URL,
STONE_AGE_TENANCY_LOG_TO_CONSOLE). Environment variables win. Every key has a
default, so the file is optional.
Two keys are worth understanding before a production deployment.
nats.encryption_key and nebula.encryption_key encrypt the secret columns
at rest — NATS account and user seeds, Nebula CA and host private keys. They are
empty by default, which means plaintext in SQLite. Set them before creating
anything real: a row cannot be read back without the key it was written with, so
losing the key loses the material.
nats.server_url and nats.websocket_urls are not the same address. The
first is what this process dials to publish account claims. The second is what
a browser dials — a different port, often a different host — and it is served
to the console at runtime by GET /api/client-config, so changing it needs no
frontend rebuild:
nats:
server_url: "nats://localhost:4222"
websocket_urls: ["ws://localhost:9222"]Behind HTTPS use wss://; browsers refuse a plaintext socket from a secure page.
Multiple entries are peers of one cluster, not a fallback order. A device that
should talk to a local leaf node instead sets a per-device override in the
console's settings, which replaces this list rather than adding to it.
Full reference: the platform docs.
serve --nats is off by default. It is an ordinary nats-server reading the
ordinary config file nats export writes, so moving to a separate process is a
config change rather than a migration:
./stone-age nats export --output ./nats-config/
nats-server -c ./nats-config/nats.conf
./stone-age serveThat sameness is not a slogan: the parsed config goes straight to
nats-server, so a cluster block works like any other directive and the
Control Plane can be one node of a cluster whose other nodes are plain
nats-server processes. stone_age_nats_cluster_routes reports the peers.
The trade-off to weigh before choosing that: while they share a process, restarting the Control Plane restarts the bus — and in a cluster that means taking a node down with it. See ADR 0001 in the platform docs.
GET /api/ready answers whether the deployment actually works, which
PocketBase's /api/health does not — that one reports the HTTP server is
listening, and it is true of every failure worth catching here:
curl -s localhost:8090/api/ready| Check | Catches |
|---|---|
database |
SQLite unreadable — pb_data/ not writable, or held by another running instance |
schema |
schema.json was never imported, so the platform's own fields do not exist — and PocketBase drops writes to missing fields silently, which is how bootstrap "succeeds" while writing nothing |
schema_version |
This pb_data was written by a newer build. Migrations do not roll back, so serve cannot repair it |
bootstrap |
No user has is_operator, or no organization has is_system_org — nobody can create an organization |
nats_operator |
No NATS operator record, or no JWT on it. Nothing can sign an account or user credential |
nats_reachable |
Nothing listening at nats.server_url. Warns if it answers but JetStream is off — KV buckets need it |
nats_trust |
The NATS server rejects this platform's $SYS credential: its nats.conf carries a different operator, so every account claim fails, no organization reaches the bus, and the console looks fine |
nats_websocket_urls |
Warns — unset, so the console falls back to ws://localhost:9222 and no browser on another machine can reach the bus |
encryption_at_rest |
Warns — NATS seeds and Nebula private keys are stored in plaintext |
Only a failure returns 503. Warnings answer 200: a probe's only lever is to
restart or de-register, and neither fixes "you have not set an encryption key".
A check that could not look at all (no $SYS credential to test with) reports
skipped rather than passing.
Every check carries a detail and, when unhappy, a suggested fix; the same
text goes to the log. The endpoint is unauthenticated, because the callers are
orchestrators and uptime checkers that hold no session — put it behind your
proxy if the deployment needs it closed. The container image wires it to
HEALTHCHECK.
GET /metrics is Prometheus text, open by default:
scrape_configs:
- job_name: stone-age
static_configs: [{ targets: ["platform.example.com:8090"] }]Set metrics.token to close it; the same value works as Authorization: Bearer
or as HTTP Basic with any username, covering every scraper in common use.
| Metric | What it says |
|---|---|
stone_age_ready |
1 when the last probe passed. Warnings do not clear it |
stone_age_check_state{name,state} |
Each check's current state, as a complete state set |
stone_age_check_timestamp_seconds |
Last probe time — alert on this going stale, it means the prober itself is wedged |
stone_age_build_info{version} |
Always 1; read the label |
stone_age_http_requests_total{route,method,status} |
Traffic by matched route pattern, status by class |
stone_age_http_request_duration_seconds |
Request latency histogram, same route label |
stone_age_records{collection} |
Rows configured — inventory, not availability |
stone_age_inactive_records{collection} |
Decommissioned things and leaf nodes (active = false) |
stone_age_nats_users_revoked |
Credentials on their account's revocation list |
stone_age_database_size_bytes |
SQLite plus its WAL, for disk growth |
stone_age_collector_errors |
Collectors that failed this scrape — the affected series are missing, not zero |
stone_age_nats_* |
The embedded bus: _embedded_up, connections, cluster routes, leaf-node connections, slow consumers, msgs/bytes, JetStream bytes. Only with serve --nats — absent entirely with an external server, rather than reported as zero |
The standard go_* and process_* collectors are included too.
Nothing here is per-organization, and that is structural rather than a
simplification. This process holds the NATS operator and the $SYS account
and has no user credential inside any organization's account, so it cannot read
what is in one — not the twin buckets, and not the leaf_status heartbeats
leaf-sync writes. stone_age_records{collection="leaf_nodes"} therefore
counts leaf nodes configured; an alert on it can never fire.
Per-site health is exported by leaf-sync on the edge box, which is inside the
account and keeps answering when the WAN is down — see
cmd/leaf-sync/README.md. The console shows the same
thing for humans, because a browser connects with the logged-in user's own
in-account credential.
With an external nats-server, scrape it with
prometheus-nats-exporter:
it reads the server's own monitoring port and reports far more than this could.
Operators can re-skin the console without rebuilding the binary. Point
branding.dir at a host directory containing any of:
| File | Purpose |
|---|---|
branding.json |
{ "appName": "...", "logo": "logo.svg" } |
logo.svg |
Brand mark on login, sidebar and mobile header |
theme.css |
Override the daisyUI v4 custom properties for light and dark |
cp -r branding.example /etc/stone-age/branding
# edit the three files, then in config.yaml:
# branding:
# dir: /etc/stone-age/brandingMissing files fall back to the embedded defaults. For a different logo per theme,
the example theme.css documents a CSS-only swap using the .brand-logo-img
class hook.
Backend and frontend separately, for hot module replacement:
go run main.go servecd ui && npm run devFrontend on :5173, proxying /api to the backend on :8090.
Two frontend dependencies are deliberately held back, and a routine "bump
everything" pass must not drag them along: Tailwind 3 / daisyUI 4, because
upgrading is a design-system migration rather than a version bump — 376
references use the v4 oklch(var(--b1)) form, which v5 breaks silently — and
TypeScript 6, because TS 7 drops the ./lib/tsc subpath vue-tsc resolves at
startup, so the build dies before it type-checks anything. The full reasoning is
at the top of ui/tailwind.config.js. CI asserts both majors.
Tenant isolation and privilege boundaries are enforced entirely by the API
rules in schema.json, which are plain strings in a JSON file with no compiler
behind them. One script exercises them against a real server:
./scripts/test-authz.shIt builds the binary, stands up a throwaway database, runs the checks and tears
everything down; pb_data/ is never touched. The check count lives in
EXPECTED_CHECKS at the top of the script, where it guards against a suite that
exits early — bump it when you add a check. Requires go, curl and node. CI
runs it on every pull request.
Two things to know when reading a failure. PocketBase answers 404, not 403, when an update rule rejects a request — deliberately, so it does not confirm the record exists. And every "cannot do X" check is paired with a "can still do Y" check on the same record, so a rule that denies everything fails the suite rather than passing it.
schema.json is the single source of truth for every collection, embedded in the
binary. It is applied by the files in migrations/, each of which calls
ImportCollectionsByMarshaledJSON(SchemaJSON, false) and runs exactly once per
database. The import is additive (deleteMissing=false), so collections created
by the libraries survive.
To change it:
- Make the change in the admin panel at
/_/. - Settings → Export collections, and replace
schema.json. - Add a new file to
migrations/that re-imports the schema — copy an existingschema_update_*.go, rename it, and describe the change in its doc comment. - Commit both, and run
./scripts/test-authz.shif you touched a rule.
Step 3 is not optional. Every existing deployment has already run the existing migrations, so editing
schema.jsonalone reaches fresh databases only and is a silent no-op everywhere else. A new non-null column with a live rule over it also needs its backfill in the same migration.
main.go registers the platform's own hooks and routes. The ones worth knowing:
- Organization created → provisions that organization's NATS account and Nebula CA.
- Leaf node created → mints the edge node's NATS user, so
leaf-synccan authenticate as the leaf node (hooks/leaf_node_provisioning.go). - Membership deleted → clears the departing member's organization context,
which is what the inventory read rules are scoped by
(
hooks/membership_lifecycle.go). activeflipped on a thing or leaf node → invalidates outstanding tokens and revokes the linked NATS identity (hooks/active_flag.go).GET /api/leaf/bootstrap→ everythingleaf-sync configneeds, in eight named fields, so a leaf-node identity needs no read grant on anynats_*ornebula_*collection (hooks/leaf_node_routes.go).GET /api/leaf/operator-jwtremains as a superseded alias.POST /api/org/things→ a Thing plus an optional NATS or Nebula identity in one transaction: member-level for the inventory half, owner/admin for the identity half (hooks/thing_routes.go).GET /api/client-config→ the deployment facts the console cannot be compiled with, chiefly the browser-facing WebSocket URLs (hooks/client_config_routes.go).OnServe→ serves the embeddedpb_publicfilesystem with SPA history fallback, plus the branding overlay.
cmd/leaf-sync/ |
The edge agent, and the edge deployment flow |
| platform-docs | Configuration reference, operations, architecture decisions |
CHANGELOG.md |
Releases |
SECURITY.md |
Reporting a vulnerability, and what counts as one |
MIT — see LICENSE.