Node API
Every HTTP route a scafo node serves, with its authentication, request shape and answers.
Every node of a fleet serves the same API, because the registry is in the bucket and any node can answer any call. Failures are RFC 9457 problem documents with content-type: application/problem+json; see /reference/errors.
Every route but /.well-known/scafo/health requires a token: a node, operator or workload token, or — on the storage and routing surfaces — the ingress token.
#Authentication
#Node and operator tokens
One scheme covers both: a machine and an operator are the same caller to the verifier. The token is a compact JWS presented as Authorization: Bearer <token>.
| Part | Value |
|---|---|
Header alg | EdDSA — anything else is refused outright |
Header kid | The signer's id: a node id or an operator id (they share one keyspace) |
Claim sub | Must equal kid |
Claim aud | scafo-node |
Claim iat, exp | Seconds; exp - iat must be in (0, 60] |
Claim jti | base64url of 16 random bytes |
| Rule | Value |
|---|---|
| Maximum lifetime | 60 s |
| Clock skew tolerated | 30 s at both ends |
| Public key source | _scafo/operators/<kid>.json, else _scafo/nodes/<kid>.json |
| Key cache TTL | 60 s — the revocation bound |
| Replay | Single use. The jti is recorded on acceptance and refused thereafter |
Warning
Because tokens are single-use, one token cannot both deploy and then poll the deploy. Sign a fresh token per request.
Every rejection is auth-invalid (401), whatever the cause — a wrong signature, an expired token, a missing header and an unknown kid are indistinguishable to the caller.
Routes marked operator token additionally require kid to name an operator record, and re-read that record on every request, so a revoked or replaced key cannot administer durable authority from a cache. A node token on those routes is auth-scope (403). Routes marked admin operator require role: "admin".
Operator tokens carry an application scope: *, exact names, or prefix globs such as shop-*. A call naming an application outside it is auth-scope, with a detail that never discloses which applications the key does reach. Reads narrow instead of refusing and set scoped: true in the answer.
#Workload OIDC tokens
Accepted on the deploy routes only, and only for an application whose trust policy names the token's iss. Presented in the same Authorization: Bearer header.
| Rule | Value |
|---|---|
| Algorithms | RS256/384/512, PS256/384/512, ES256/384/512, EdDSA, Ed25519. The HS* family is refused outright |
| Issuer selection | From the token's unsigned iss, matched against the application's trust policy before anything is fetched |
| JWKS cache | 300 s; a failed discovery is backed off 30 s; one request may take 5 s; at most 64 issuer entries |
| Clock skew | 30 s |
A token that does not hold up is auth-invalid. A token that does hold up but whose issuer or subject the policy does not admit is trust-mismatch (403) — the one rejection that is authorization rather than authentication, and the one a CI job can act on.
#Ingress token
An opaque bearer token, compared against the node's admitted token list with a timing-safe comparison. It guards /v1/storage/** and /v1/ingress/routes* and nothing else. A node mints one at boot unless ingress.token names some — --ingress-token once per token, SCAFO_INGRESS_TOKEN comma-separated, or the config file — in which case all of them are admitted and the first is written to the state directory's ingress-token file.
Storage requests may also carry X-Scafo-Storage-Version: 1. An absent or empty header means version 1; any other value is invalid-input.
#Route table
| Method | Path | Auth |
|---|---|---|
GET | /.well-known/scafo/health | none |
GET | /v1/state | node or operator token |
POST | /v1/applications/:name/deploys | node, operator or workload token |
GET | /v1/deploys/:id | node, operator or workload token, scoped to the deploy's application |
GET | /v1/operators | operator token |
POST | /v1/operators | operator token |
DELETE | /v1/operators/:id | operator token |
GET | /v1/applications | operator token |
GET | /v1/applications/:name | operator token, application scope |
POST | /v1/applications/:name/block | admin operator |
POST | /v1/applications/:name/unblock | admin operator |
POST | /v1/applications/:name/labels | admin operator |
POST | /v1/applications/:name/retire | node or operator token, application scope |
POST | /v1/applications/:name/unretire | node or operator token, application scope |
POST | /v1/applications/:name/selector | node or operator token, application scope |
POST | /v1/applications/:name/replicas | node or operator token, application scope |
POST | /v1/applications/:name/domains/:host | operator token, application scope |
DELETE | /v1/applications/:name/domains/:host | operator token, application scope |
DELETE | /v1/applications/:name | node or operator token, application scope |
POST | /v1/fleet/freeze | admin operator |
DELETE | /v1/fleet/freeze | admin operator |
GET | /v1/fleet/freeze | operator token |
PUT | /v1/storage/objects/** | ingress token |
GET | /v1/storage/objects/** | ingress token |
DELETE | /v1/storage/objects/** | ingress token |
GET | /v1/storage/list?prefix=&recursive= | ingress token |
GET | /v1/storage/stat?key= | ingress token |
POST | /v1/storage/locks/** | ingress token |
DELETE | /v1/storage/locks/** | ingress token |
GET | /v1/ingress/routes | ingress token |
GET | /v1/ingress/routes/:format | ingress token |
The storage and routing routes exist only when the node has a storage service configured, which is always the case under scafo sail.
Every JSON control request body is bounded by api.jsonBytes (default 65 536). A content-length above it, or a stream that grows past it, is resource-limit (413) and the body is cancelled rather than aggregated.
#Health
#GET /.well-known/scafo/health
Public. No parameters.
{ "ok": true, "version": "0.1.0", "node": "vps-a" }An application's process answers its own health at /.well-known/celld/health, which is a different endpoint on a different port.
#Certificates
No route of the node decides which names get a certificate. The ingress decides from the routing document it already polls at /v1/ingress/routes: the hosts that document names exactly are the hosts that may get a certificate, and no other. Caddy's tls.permission.scafo approves an on-demand certificate only for a name some fleet's current document names as an exact route host or an API host; Traefik, told a certificate resolver, obtains what the rendering asks for on each router — its exact host, or the tls.domains wildcard certificate that covers it.
Which hosts the routing document names, and so which may get a certificate:
| Host | Named when |
|---|---|
| A bare platform apex | api.public is on, as an API host |
<name>.<platform domain> | That application is serving: a live node runs a ready process for it, which a retired or blocked application never has |
| A claimed custom domain | The application that claimed it is serving |
<env>.<base> under a custom domain claimed with its subdomains | <holder>-<env> is serving |
Important
The *.<base> entry of a claim that covers its subdomains exists for routing, not for issuance: it answers an arbitrary subdomain from the base holder's process, and no such name is ever approved on its strength. Whoever sends the first request chooses the name, so a certificate per unknown name would let anyone exhaust the certificate authority's weekly issuance limit for that domain. Those names are served over HTTPS only where the ingress already holds a certificate covering *.<base>.
#State
#GET /v1/state
Node and admin-operator callers receive the whole document. A non-admin operator receives it filtered to the applications its key reaches, with scoped: true set.
DaemonState fields:
| Field | Type | Meaning |
|---|---|---|
node | NodeRecord | null | This node's registry record; null until it has registered |
lease | CoordinatorLease | null | Holder of the coordinator lease, whoever it is; null when nobody holds one |
coordinator | CoordinatorView | null | Effective election result; an expired lease record produces null |
processes | ProcessStatus[] | What the supervisor reports running on this node |
placements | PlacementView[] | Placement views. A missing placement is pending, never implicit assignment |
reconcile | ReconcileStatus? | Last pass of the reconcile loop; absent on a node that runs no loop |
scoped | true? | Present when operator scope filtered this document, even if the scope grants every application |
CoordinatorView fields:
| Field | Type | Meaning |
|---|---|---|
node | string | Id of the node holding the lease |
fence | number | Fencing token of that lease; it only ever increases |
self | boolean | true when the node answering is the one coordinating |
unplaced | array? | Unplaced applications from this coordinator's last pass; absent otherwise, and only the current coordinator reports them |
#Applications
Every route takes a :name path parameter and enforces operator application scope before any I/O. There is no create route: the first deploy creates the application, and every setter here refuses a missing one with not-found.
Two boundaries stand in front of them. The reading, admin and domain routes answer operator tokens only; a node token on one is auth-scope. The four setters and the delete route answer operator and node tokens alike, because a node forwards a deletion to the coordinator under its own token — an operator's scope is still applied to the name.
Every route but the listing, the domain routes and the delete answers { "application": ApplicationRecord }.
#GET /v1/applications
{ "applications": [ /* ApplicationRecord */ ], "scoped": true }Applications are returned in name order. An admin sees full records. A non-admin operator sees only the applications its key reaches, with the fleet-owned labels field removed and scoped: true set; blocked remains visible.
#GET /v1/applications/:name
Operator token. Answers one record, with the same tenant redaction. An application the key cannot reach is auth-scope; one that does not exist is not-found.
#POST /v1/applications/:name/block, POST /v1/applications/:name/unblock
No body. Admin only — application scope is checked first, then the admin tier. Blocking is one CAS update; reconciliation stops serving the application on each node's next pass, its hosts leave ingress, it gets no certificates and it accepts no deploys. Nothing is deleted, and unblocking restores the prior desired state. Each emits one observability event naming the actor.
#POST /v1/applications/:name/labels
Admin only. Body:
{ "labels": { "account": "acme", "plan": "pro" } }labels must be a JSON object of string values; an empty object clears them. Labels are replaced wholesale and are never interpreted by scafo. A missing application is not-found.
#POST /v1/applications/:name/retire
No body. One CAS update; it does not require coordinator authority.
#POST /v1/applications/:name/unretire
No body. Retirement is reversible during its grace period.
#POST /v1/applications/:name/selector
Body (JSON, ≤ api.jsonBytes):
{ "selector": { "region": "eu-west-1", "tier": "fast" } }"selector": null clears the constraint. Omitting selector entirely is invalid-input. An unsatisfiable selector is valid and is reported by assignment, not rejected here.
#POST /v1/applications/:name/replicas
Body (JSON, ≤ api.jsonBytes):
{ "replicas": "all" }replicas must be given: "all", or a whole number of at least 1. Omitting it, or sending null, is invalid-input — there is no default. The coordinator acts on it on its next assignment pass.
#POST /v1/applications/:name/domains/:host[?wildcard=1], DELETE /v1/applications/:name/domains/:host
No body. Operator token only — a node token, and the workload token a deploy pipeline holds, are both auth-scope: a pipeline publishes an application, it does not decide what the fleet answers on.
:host is the host itself, lowercased and held to the claim rule: fully qualified, at most 253 characters, at least two labels of 1–63 characters from [a-z0-9-] not starting or ending with a hyphen, a last label that is not all digits, and no wildcard, port, scheme, path, trailing dot or underscore. Anything else is invalid-input naming the rule. A host equal to or under one of this node's platform domains is invalid-input too: every host there is rendered from an application's name.
?wildcard=1 on the POST claims the single label below the host as well. Only 1 counts. It additionally requires a key whose scope reaches <name>-*, since the claim also decides what <name>-<env> answers on; a key that does not is auth-scope.
Both answer:
{ "application": { /* ApplicationRecord */ }, "host": "shop.example", "platformHosts": ["shop.apps.example.com"] }platformHosts is where the application answers under this installation's own domains, and is empty when it serves no platform domain.
| Refusal | Meaning |
|---|---|
400 invalid-input | The host is no host to claim, it is under a platform domain, or the application already holds 64 domains |
403 auth-scope | The key does not reach the application, or does not reach <name>-* for ?wildcard=1 |
404 not-found | No such application; or, on the DELETE, the host is not a domain of this application — the same words either way, so a release can never take another application's claim |
409 application-blocked | Claiming for a blocked application. Releasing stays open |
409 reference-held | The host, its wildcard-claimed parent, or a host one label under the base being claimed is held by another application. The detail names the holder only when the caller's scope reaches it, and says another application of this installation otherwise |
Re-claiming a host the same application already holds is idempotent, and a claim that already covers its subdomains is never downgraded by a later POST that leaves wildcard out. Writes go referent-first — the application is checked, the claim is created, then the application lists the host — so a crash in between leaves a claim nothing routes to, which re-running the call repairs.
#DELETE /v1/applications/:name[?force=1]
Only force=1 and force=true count as forced; anything else reads as not forced. Forcing skips both the retirement and the grace requirement.
Deletion is coordinator-gated: S3 has no conditional delete, so the lease is the only thing keeping two deleters apart. Any node accepts the request:
- This node holds the lease → it does the work.
- Nobody holds it → this node offers itself once, then does the work or finds out who won.
- Another node holds it → the request is forwarded there once, under a fresh node token this node signs for itself, and the coordinator's answer is returned verbatim, problem document included.
| Answer | Meaning |
|---|---|
200 { "deleted": string[] } | Store keys removed: the record, its trust policy, its deployments, its deploys, its placement, its domain claims and the whole fleets/<name>/ prefix |
503 draining No node is coordinating | Nobody holds the lease and this node could not take it |
503 draining Not the coordinator | The request was already forwarded once and the lease moved again; retry against a settled lease |
409 capability-missing | This node holds no signing key and cannot reach the coordinator |
#Deploys
#POST /v1/applications/:name/deploys
The artifact is the raw request body, content-type: application/octet-stream, streamed to a file and bounded by deploy.artifactBytes in both compressed and expanded form.
Everything else travels in one header:
| Header | Value |
|---|---|
x-scafo-deploy | Unpadded base64url of the canonical JSON metadata document, at most 8192 characters |
The encoding is checked strictly: the header must match ^[A-Za-z0-9_-]+$, decode as valid UTF-8 JSON, and re-serialize to exactly the bytes given (stable key order, no padding). Anything else is invalid-input, Malformed request.
Metadata fields:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
replicas | number ≥ 1 or "all" | no | — | Omitted, or null, preserves the application's existing setting |
nodeSelector | object of string→string | no | — | Omitted, or null, preserves the application's existing constraint |
source | object | no | — | { provider, repo, ref, commit }, all four required non-empty strings when source is given |
Three names are refused outright, each invalid-input, Malformed request:
| Name | Why |
|---|---|
application | It belongs in the request path |
oidc | OIDC provenance is recorded from the verified workload token, never from request metadata |
source.oidcSub | Same reason |
Anything else the document carries is ignored: the header is read for the fields above and nothing else. The document itself must still be a JSON object, and so must source when given; anything else is invalid-input.
The artifact's own runtime configuration is not read here at all. The node locates wrangler.jsonc, then wrangler.json, at the archive root, refuses an artifact carrying neither with invalid-input, and hands that path to Celld untouched — which is why there is no metadata field for a script name, for bundling, or for a version to record the deployment under. scafo deploy --var and --no-bundle edit the config client-side, before the directory is packed.
The caller is authenticated before the metadata header or the body is read.
| Answer | Meaning |
|---|---|
202 + DeployStatus + Location: /v1/deploys/<id> | Accepted |
400 invalid-slug | The :name path segment is not an application name |
401 auth-invalid | The token did not hold up, whatever the cause |
403 auth-scope | An operator key that does not reach this application |
403 trust-mismatch | A workload token verified, but the application's trust policy admits neither its issuer nor its subject |
400 invalid-input | Malformed or non-canonical metadata, a refused name, or a missing request body |
409 deploys-frozen | An admin has frozen this fleet's deploys. Refused before anything is created, so no application is brought into being by a deploy that is about to be refused |
409 application-blocked | An admin has stopped the application |
409 version-skew | Another answering node serves this application, or is crash-looping on it, on a different Celld version, outside a rolling pair upstream declared. Refused at intake, before a byte of the artifact is read; the detail names every node that has to stop and the rolling pairs this build allows |
409 capability-missing | This node has no deploy runner, or no file-backed artifact stager |
429 deploy-overloaded + retry-after: 1 | The node's deploy queue or staged-byte capacity is full. scafo deploy waits and comes back unless --no-wait |
408 deploy-timeout | A receive, queue or task deadline elapsed |
413 resource-limit | The artifact exceeds artifactBytes, or the archive exceeds archiveEntries |
503 store-unavailable | A registry write met transient errors until the tries ran out |
The order matters: the freeze is read first, then the application is created — or, when it already exists, checked for a block and updated with any placement intent the metadata carried — and then the Celld versions are compared. Only after all three does the artifact body begin to be read.
Everything after the 202 — the artifact's configuration among it — is reported on the deploy's own record, not here.
#GET /v1/deploys/:id
Answers DeployStatus:
| Field | Type | Notes |
|---|---|---|
id | string | |
application | string | |
state | accepted | uploading | deployed | failed | deployed and failed are terminal |
deployment | string? | Deployment the deploy produced, by version; absent until it has been recorded |
code | string? | The error code that failed it. Present on every failed deploy: publish-failed when celld deploy ended badly, internal for a failure scafo has no name for, otherwise the code of the refusal itself |
detail | string? | Why it failed, when it did |
A deploy id is <name>.<16 hex>, and every state a deploy enters is a registry record under that application. Any node answers a poll, so it may land wherever an ingress sends it, and a restart of the accepting node loses nothing. The application in the id is what the caller is authenticated against, exactly as the upload was.
An id that is no deploy id names no application, so the caller is verified against a null application first and the answer is 404 not-found. A record filed under an id whose application half it disagrees with is 404 too.
A deploy nobody is carrying out any more is judged at read time. A record that is not terminal, has not moved for a whole 90-second liveness window, and whose accepting node is not answering is returned as failed with code: "deploy-timeout" and a detail naming that node — without waiting for a sweep and without writing anything, because a poll is a read. It costs one extra read, and only in that one case. The coordinator's reaper records the same judgement, with the same code and the same detail, on its next pass; it also deletes terminal records after seven days.
#Operators
#GET /v1/operators
{ "operators": [ /* TrustedOperator */ ], "scoped": true }Keys are returned in id order. An admin sees every key. A non-admin operator sees only its one-step rotation lineage — itself, the key it supersedes, and any key that supersedes it — and the answer carries scoped: true.
#POST /v1/operators
Body (≤ api.jsonBytes):
{
"id": "op-9f3c",
"publicKey": { "kty": "OKP", "crv": "Ed25519", "x": "..." },
"role": "operator",
"supersedes": "op-1a2b",
"applications": ["shop", "shop-*"]
}| Field | Required | Rules |
|---|---|---|
id | yes | Non-empty string; validated as an operator id |
publicKey | yes | Must be { kty: "OKP", crv: "Ed25519", x: <non-empty string> }. Only public material is persisted; an accidental d is never copied |
role | no | "admin" or "operator". Defaults to the least-privilege tier for a fresh key, and to the caller's tier for a replacement |
supersedes | no | Id of the key this one replaces. Must be the caller's own id — a key that supersedes another is authorized to revoke it |
applications | no | Array of entries — an exact application name, or a prefix glob such as shop-* — or ["*"] alone. Omitted inherits the caller's own scope. An empty array, a * beside names, or a * anywhere but as one trailing character is invalid-input |
Authorization rules, each answered auth-scope (403):
- A
supersedesnaming anything but the caller's own id. - Registering a tier the caller may not grant.
- Registering a scope reaching past the caller's own.
- Granting
*when the caller is not an admin. An operator-tier key may replace itself with a wildcard key, but may not hand one out beside itself.
A fresh grant additionally requires an explicit scope. Replacements are idempotent, so an interrupted rotation can be repeated.
Answers { "operator": TrustedOperator }.
#DELETE /v1/operators/:id
Answers { "removed": "<id>" }.
| Refusal | Meaning |
|---|---|
400 invalid-input | An operator cannot revoke its own key. Rotate instead |
403 auth-scope | An operator-tier key may revoke only the key it was registered to replace |
409 reference-held | This is the last trusted operator key, or the last admin key |
#Fleet
Statements an admin makes about the installation itself rather than about one application.
#POST /v1/fleet/freeze
Admin only. Stops deploy intake for the whole fleet; every node refuses POST /v1/applications/:name/deploys with deploys-frozen (409) until it is lifted. Nothing else changes — serving, reconciliation, retirement and application deletion all carry on.
The body is optional. A freeze with nothing to say is a POST with no body at all; a body, when there is one, is a JSON object:
{ "reason": "moving every node to a new Celld release" }| Rule | Value |
|---|---|
| Body | Optional. Present, it must be a JSON object, bounded by api.jsonBytes |
reason | Optional string. Trimmed; an empty one reads as none |
reason length | At most 200 characters — it is printed inside every refusal |
reason shape | One line: no newline, no control character |
Anything else is invalid-input. by is the caller the guard verified, never anything the body said.
Answers:
{ "freeze": { "v": 1, "frozenAt": "…", "by": "op-9f3c", "reason": "…" }, "created": true }created is false when deploys were already frozen, in which case the first freeze comes back unchanged: freezing twice is one freeze, and neither the stamp nor the reason moves.
#DELETE /v1/fleet/freeze
Admin only. No body. Answers { "lifted": boolean } — false when there was no freeze to lift. Nodes take deploys again on the next intake.
#GET /v1/fleet/freeze
Readable by any operator of the fleet, whatever its tier: whoever is refused a deploy is owed the reason. Answers { "freeze": FreezeRecord }, or 404 not-found, Deploys are not frozen, when this fleet holds no freeze record.
A non-admin key on the two writing routes is auth-scope (403), and the detail says freezing is a decision the fleet makes about itself rather than one taken inside an application.
#Storage
Behind the ingress token. A key is everything after /v1/storage/objects/, separators included, and is spelled back to the caller exactly as it was given.
| Method | Path | Request | Success | Not found |
|---|---|---|---|---|
PUT | /v1/storage/objects/<key> | raw value bytes | 204 | — |
GET | /v1/storage/objects/<key> | — | 200 + raw bytes, content-type: application/octet-stream | 404 |
DELETE | /v1/storage/objects/<key> | — | 204; removes the key and everything beneath it | 404 |
GET | /v1/storage/list?prefix=&recursive=true|1 | — | 200 + JSON string[] | 404 |
GET | /v1/storage/stat?key= | — | 200 + stat object | 404 |
POST | /v1/storage/locks/<name> | — | 204 acquired | — |
DELETE | /v1/storage/locks/<name> | — | 204, always | — |
Values are read and written as bytes, never as text. recursive is truthy only for true and 1; anything else lists the immediate children. An empty listing is a 404, not an empty array: the caller's storage contract requires a missing prefix and an existing empty one to be distinguishable.
Lock acquisition is try-acquire and never waits: a held name answers 409 cas-conflict. Release is idempotent. Keys and lock names must be relative, printable ASCII, with no empty, . or .. segment; anything else is invalid-input.
#Ingress routing
#GET /v1/ingress/routes[?prefer=<node>|self]
Answers the generic FleetRouting document as of the last reconcile pass. prefer is read here exactly as it is on the format route below.
#GET /v1/ingress/routes/:format[?prefer=<node>|self]
Answers the routing in a format the ingress reads natively. Only traefik is registered. An unknown format is 404 not-found, and the detail names the formats this node renders.
?prefer=<node-id> puts that node's upstreams first on every route. ?prefer=self means the node being asked, so a colocated ingress never has to know the node's id. A node that serves nothing is preferred to no effect. Omitting the parameter asks for the fleet's own order.
Both routes refuse with 503 draining until this node's first reconcile pass has completed — an empty document would read as every route in the fleet withdrawn, where a refusal is a pause the ingress rides out on the last document that parsed.