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>.

PartValue
Header algEdDSA — anything else is refused outright
Header kidThe signer's id: a node id or an operator id (they share one keyspace)
Claim subMust equal kid
Claim audscafo-node
Claim iat, expSeconds; exp - iat must be in (0, 60]
Claim jtibase64url of 16 random bytes
RuleValue
Maximum lifetime60 s
Clock skew tolerated30 s at both ends
Public key source_scafo/operators/<kid>.json, else _scafo/nodes/<kid>.json
Key cache TTL60 s — the revocation bound
ReplaySingle 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.

RuleValue
AlgorithmsRS256/384/512, PS256/384/512, ES256/384/512, EdDSA, Ed25519. The HS* family is refused outright
Issuer selectionFrom the token's unsigned iss, matched against the application's trust policy before anything is fetched
JWKS cache300 s; a failed discovery is backed off 30 s; one request may take 5 s; at most 64 issuer entries
Clock skew30 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

MethodPathAuth
GET/.well-known/scafo/healthnone
GET/v1/statenode or operator token
POST/v1/applications/:name/deploysnode, operator or workload token
GET/v1/deploys/:idnode, operator or workload token, scoped to the deploy's application
GET/v1/operatorsoperator token
POST/v1/operatorsoperator token
DELETE/v1/operators/:idoperator token
GET/v1/applicationsoperator token
GET/v1/applications/:nameoperator token, application scope
POST/v1/applications/:name/blockadmin operator
POST/v1/applications/:name/unblockadmin operator
POST/v1/applications/:name/labelsadmin operator
POST/v1/applications/:name/retirenode or operator token, application scope
POST/v1/applications/:name/unretirenode or operator token, application scope
POST/v1/applications/:name/selectornode or operator token, application scope
POST/v1/applications/:name/replicasnode or operator token, application scope
POST/v1/applications/:name/domains/:hostoperator token, application scope
DELETE/v1/applications/:name/domains/:hostoperator token, application scope
DELETE/v1/applications/:namenode or operator token, application scope
POST/v1/fleet/freezeadmin operator
DELETE/v1/fleet/freezeadmin operator
GET/v1/fleet/freezeoperator 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/routesingress token
GET/v1/ingress/routes/:formatingress 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:

HostNamed when
A bare platform apexapi.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 domainThe 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:

FieldTypeMeaning
nodeNodeRecord | nullThis node's registry record; null until it has registered
leaseCoordinatorLease | nullHolder of the coordinator lease, whoever it is; null when nobody holds one
coordinatorCoordinatorView | nullEffective election result; an expired lease record produces null
processesProcessStatus[]What the supervisor reports running on this node
placementsPlacementView[]Placement views. A missing placement is pending, never implicit assignment
reconcileReconcileStatus?Last pass of the reconcile loop; absent on a node that runs no loop
scopedtrue?Present when operator scope filtered this document, even if the scope grants every application

CoordinatorView fields:

FieldTypeMeaning
nodestringId of the node holding the lease
fencenumberFencing token of that lease; it only ever increases
selfbooleantrue when the node answering is the one coordinating
unplacedarray?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.

RefusalMeaning
400 invalid-inputThe host is no host to claim, it is under a platform domain, or the application already holds 64 domains
403 auth-scopeThe key does not reach the application, or does not reach <name>-* for ?wildcard=1
404 not-foundNo 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-blockedClaiming for a blocked application. Releasing stays open
409 reference-heldThe 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.
AnswerMeaning
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 coordinatingNobody holds the lease and this node could not take it
503 draining Not the coordinatorThe request was already forwarded once and the lease moved again; retry against a settled lease
409 capability-missingThis 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:

HeaderValue
x-scafo-deployUnpadded 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:

FieldTypeRequiredDefaultNotes
replicasnumber ≥ 1 or "all"no—Omitted, or null, preserves the application's existing setting
nodeSelectorobject of string→stringno—Omitted, or null, preserves the application's existing constraint
sourceobjectno—{ provider, repo, ref, commit }, all four required non-empty strings when source is given

Three names are refused outright, each invalid-input, Malformed request:

NameWhy
applicationIt belongs in the request path
oidcOIDC provenance is recorded from the verified workload token, never from request metadata
source.oidcSubSame 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.

AnswerMeaning
202 + DeployStatus + Location: /v1/deploys/<id>Accepted
400 invalid-slugThe :name path segment is not an application name
401 auth-invalidThe token did not hold up, whatever the cause
403 auth-scopeAn operator key that does not reach this application
403 trust-mismatchA workload token verified, but the application's trust policy admits neither its issuer nor its subject
400 invalid-inputMalformed or non-canonical metadata, a refused name, or a missing request body
409 deploys-frozenAn 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-blockedAn admin has stopped the application
409 version-skewAnother 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-missingThis node has no deploy runner, or no file-backed artifact stager
429 deploy-overloaded + retry-after: 1The node's deploy queue or staged-byte capacity is full. scafo deploy waits and comes back unless --no-wait
408 deploy-timeoutA receive, queue or task deadline elapsed
413 resource-limitThe artifact exceeds artifactBytes, or the archive exceeds archiveEntries
503 store-unavailableA 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:

FieldTypeNotes
idstring
applicationstring
stateaccepted | uploading | deployed | faileddeployed and failed are terminal
deploymentstring?Deployment the deploy produced, by version; absent until it has been recorded
codestring?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
detailstring?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-*"]
}
FieldRequiredRules
idyesNon-empty string; validated as an operator id
publicKeyyesMust be { kty: "OKP", crv: "Ed25519", x: <non-empty string> }. Only public material is persisted; an accidental d is never copied
roleno"admin" or "operator". Defaults to the least-privilege tier for a fresh key, and to the caller's tier for a replacement
supersedesnoId of the key this one replaces. Must be the caller's own id — a key that supersedes another is authorized to revoke it
applicationsnoArray 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 supersedes naming 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>" }.

RefusalMeaning
400 invalid-inputAn operator cannot revoke its own key. Rotate instead
403 auth-scopeAn operator-tier key may revoke only the key it was registered to replace
409 reference-heldThis 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" }
RuleValue
BodyOptional. Present, it must be a JSON object, bounded by api.jsonBytes
reasonOptional string. Trimmed; an empty one reads as none
reason lengthAt most 200 characters — it is printed inside every refusal
reason shapeOne 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.

Read more in Reference > Registry#freezerecord Scafofreezejson.

#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.

MethodPathRequestSuccessNot found
PUT/v1/storage/objects/<key>raw value bytes204—
GET/v1/storage/objects/<key>—200 + raw bytes, content-type: application/octet-stream404
DELETE/v1/storage/objects/<key>—204; removes the key and everything beneath it404
GET/v1/storage/list?prefix=&recursive=true|1—200 + JSON string[]404
GET/v1/storage/stat?key=—200 + stat object404
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.

Read more in Reference > Ingress Protocol.

#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.

Read more in Reference > Ingress Protocol.