Hub API reference¶
The hub is vibey serve (ADR-0068): one HTTP
API that every krypton client -- desktop, mobile, web, the VS Code extension -- reaches.
Version 1. The machine-readable contract is the OpenAPI 3.1 document, committed at
docs/reference/hub-api.json
and served by a running hub at /api/v1/openapi.json. A test fails when the two differ, so
the file is always the truth.
One contract with the CLI¶
Every document the hub returns is the one the matching command prints with --json, from
the same presenter, so a client reads the same keys whichever way it asks:
| Route | Same document as |
|---|---|
GET /api/v1/projects |
vibey projects --json |
GET /api/v1/projects/{project_id}/status |
vibey status PROJECT_ID --json |
GET /api/v1/gates[?project_id=…] |
vibey gates [PROJECT_ID] --json |
GET /api/v1/projects/{project_id}/budget |
vibey budget show PROJECT_ID --json (read only) |
GET /api/v1/projects/{project_id}/queue |
vibey queue list PROJECT_ID --json |
POST /api/v1/projects/{project_id}/queue/{job_id}/bump |
vibey queue bump JOB_ID --json |
GET /api/v1/projects/{project_id}/ledger?text=&kind=&actor=&limit= |
vibey ledger search … --json |
GET /api/v1/loops |
vibey loops --json |
Three routes have no single command behind them:
POST /api/v1/gates/{gate_id}/answertakes{"answer": {...}, "request_id": "…"}, whereansweris whatvibey answer --rawtakes. It answers through the same service asvibey answer: once, recorded under the caller's name. The samerequest_idwith the same answer is a no-op ("replayed": true); a gate another request answered first is 409.GET /api/v1/laneslists the lanes on this computer, each with its byteoffset.GET /api/v1/doctorruns the checks the hub can run itself (the database,hub-exposure) and says so;vibey doctoron the host is the full check.
GET /health/live and GET /health/ready need no credentials and say only whether the
process is up and whether the database answers. GET /api/metrics returns
vibey_bootstrap's metrics snapshot and needs view.
Live¶
A ledger feed resumes from a position, never a time (sub-doctrine 10.g): the last
seq a client has. Every ledger append announces itself on the database channel
vibey_ledger_appended (migration 0019), and the hub reads every event after the last
seq it sent, so an announcement that is missed costs latency, never an event.
| Route | What it does |
|---|---|
WS /api/v1/projects/{project_id}/live?after=N |
Sends {"project_id", "events", "last_seq"} pages of events with seq > N, up to 200 at a time until caught up, then one page per append. A quiet feed sends {"heartbeat": last_seq} every 25 s. |
GET /api/v1/projects/{project_id}/ledger/after?seq=N&limit=L |
The same page, for a client that polls. |
WS /api/v1/lanes/live?path=P&after=B |
A listed lane's complete lines after byte B, as {"events_path", "from", "offset", "lines"}, checked every second. Resume from offset. |
GET /api/v1/lanes/tail?path=P&after=B |
The same, once. |
path must be the events_path of a lane GET /api/v1/lanes lists; any other path is
404, and nothing else is ever opened. A WebSocket is refused (close code 1008) when its
Host or Origin is not one the hub answers, or it proves no principal; and the
principal is checked again before every page, so one revoked while a feed is open is cut
off at its next message. Events are carried exactly as vibey ledger search --json
carries them.
Who may do what¶
Every /api/v1 route and /api/metrics needs a principal. On the host, that is the host
token: Authorization: Bearer <content of <state_dir>/token>, which holds every scope.
A paired device instead signs every request (see Pairing) and holds only the
scopes the host granted it.
| Scope | Permits |
|---|---|
view |
Every read above. |
answer |
Answering a gate that does not spend. |
spend |
Answering a gate that does (budget_exhausted, the deploy_* gates), with answer. |
run |
Reserved for starting and stopping work; no route uses it yet. |
bump |
Bumping a job -- and only when the project's [queue.priority] sources admits vibey-hub. |
Nothing is permitted without a scope. And no scope, ever, can declare paid use, lift or change a cap, change the database DSN, run migrations or touch the canon: no route offers them.
Pairing¶
Only the host pairs, lists and revokes; a device never can, not even for itself.
| Route | Who | What |
|---|---|---|
POST /api/v1/pairing/offers {"scopes": [...]} |
host | A 6-digit code, valid 120 seconds and spent by the first claim, with the pairing URI vibey-pair://<host>:<port>?fp=<sha256>&code=<code>&v=1 the QR code carries. At least one scope. |
POST /api/v1/pairing/claim {"code", "name"} |
anyone | The device's id, its key (returned this once) and scopes. Rate-limited per address; five wrong codes withdraw every open offer. |
GET /api/v1/devices |
host | The paired devices, never their keys. |
DELETE /api/v1/devices/{id} |
host | Revokes the device at once: its next request is refused. |
A paired device signs each request with four headers: x-vibey-device (its id),
x-vibey-timestamp (seconds since the epoch, within 60 of the host's clock),
x-vibey-nonce (never reused) and x-vibey-signature, which is sha256= and the hex
HMAC-SHA256, under the device's key, of these lines joined by \n: device id, method
(upper case), path, query, timestamp, nonce, and the hex SHA-256 of the body. A replayed
nonce, a stale timestamp, a changed method, path, query or body, another device's key or
a revoked device proves nothing (401). There is no session and no cookie, so each action
-- a spend answer included -- is verified on its own, and there is no ambient credential
for a cross-site request to ride.
Every pairing and revocation is written to every project's ledger as HubDevicePaired /
HubDeviceRevoked (the device's id, name, scopes and who acted -- never its key).
Refusals¶
| Status | Meaning |
|---|---|
| 401 | The request proves no principal. Nothing else is said. |
| 403 | The principal's scopes do not permit it, a device asked to manage pairings, a pairing code was wrong, spent or expired, or the repository does not admit the hub's bump. |
| 404 | No such project, open gate or paired device. |
| 409 | The gate was already answered by another request, or the queue refused the reorder. |
| 421 | The Host header is not one this hub answers (the DNS-rebinding defence). |
| 422 | The body or a filter is malformed. |
| 429 | Too many requests (empty body). |
| 503 | Pairing is not enabled on this hub. |
Every response carries Content-Security-Policy: default-src 'none', X-Frame-Options:
DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and
Cache-Control: no-store, and none ever carries a CORS header.
Where it listens¶
Loopback, unless vibey.toml declares [hub] lan = true. With
the LAN declared, the hub also answers this computer's host name, its .local name, its
addresses and any [hub] names.