API
Keys for programs. Issuers for people.
The API the portal calls, and a program calls with a key. The installer itself does not use a key. It fetches the label URL. The install routes are live. The service and platform routes are designed, not built.
Three names
The label host, the API host, and the update host are different names so a build URL cannot be mistaken for the API. The portal is https://build.blunix.io/.
- Installer. https://{label}.blnx.io/ returns the latest age ciphertext, not cached. Otherwise 404.
GETandHEADon/only. Another method is a 405. No query string. TLS required. Cap 256KiB. No key. The headerx-blunix-sha256is the ciphertext digest. The server checks the body against that digest before it sends it. blnx.io is a separate domain, so build files never share cookies with the site, the portal, or the API. The bare blnx.io sends you to this site. - Pinned versions, not on yet. https://v{n}.{label}.blnx.io/ will return version n, cached for a year. Those names have no certificates yet. Until they do, the API returns
pinnedUrl: nulland only the latest name is in use. - API. https://api.blunix.io/v1 is where accounts and publishers write. A Cloudflare Worker, with the same Worker serving the build names.
- Updates. https://updates.blunix.io/blunix will be the sysupdate manifest generated from the image log. Designed, not built. Releases are on GitHub today, and the log page reads them live.
API keys
A key is for a program, such as the build-proxy. A person keeps using the issuer. A person mints and revokes keys in the portal.
- Drawn from a CSPRNG. Shown once. Stored only as a SHA-256 hash, compared in constant time. A key is high entropy, so it is not stretched the way a password is.
- Prefix
blx_, then 43 base64url characters. The fingerprint is the SHA-256 of the key, as 64 hex characters. Logs may store the fingerprint. They must not store the key. - Scopes.
hosts:writeis the default when you name no scopes: reserve and delete labels, upload and delete builds. It can also read.hosts:readreads only. Keys are minted, listed, and revoked with a browser session, never with another key. Later scopes, such aslog:publish,machines:read, andtroubleshoot:request, belong to the service and platform designs. - Revocation is immediate. A revoked hash fails closed. Rotation is a new key, then revoke the old one.
- Header only.
Authorization: Bearer. Ablx_token is an account key. Ablx_join_token is kept for a later join token, and today it is a 401 everywhere. Anything else, including a JWT, is a 401. An unknown or revoked key is a 401, and the API does not fall back to the cookie. The key is never accepted in a query string. Machine calls, signed with Ed25519, belong to the service design.
Sign-in
People sign in with After Dark Systems SSO, our OpenID Connect issuer at adsas.id. It is the only issuer today. Google and GitHub are designed for later, under the same checks.
- One issuer. The API reads the issuer's discovery document. Its
issuermust match exactly, and its endpoints must be https. A missing or wrong document means sign-in is unavailable. - Browser. Authorization code with PKCE (S256), plus state and nonce. Scopes:
openid email profile. The redirect URL is fixed: https://api.blunix.io/v1/auth/callback. No wildcard redirect. The state is also bound to the browser by the cookie__Host-blx_oidc, which lasts 10 minutes. The session is the cookie__Host-blx_session: Secure, HttpOnly, SameSite=Lax, Path=/, 12 hours. - Cross-site requests. A state-changing call with a session must send
x-blunix-csrf: 1and come from the origin https://build.blunix.io. A call with a bearer key needs neither. - Token. The ID token is verified against the issuer's JWKS. Issuer, audience, expiry, and nonce are checked. The algorithm has to be RS256, ES256, or EdDSA.
noneis rejected. A failed check is a refusal. - What is kept. The account is the issuer and the subject from the ID token. A display name is kept for "Signed in as": the token's name, else its username, else its email. It is refreshed at each sign-in and is never used for access. No password is kept.
- No client secret in the tree. The client id, the secret, and the issuer URL are Worker secrets. They do not go in this website.
Routes
JSON in and out, except the ciphertext upload. A JSON body must be application/json, at most 8KiB. Errors are fixed strings, {"error":"..."}. The server does not echo a body back, and it does not decrypt.
| Call | Who | Result |
|---|---|---|
GET /v1/health |
anyone | GET or HEAD. {"ok":true}. No cookie, no auth. CORS allows https://blunix.io to read it, without credentials. This site uses it to say whether the portal is open. |
GET /v1/auth/login |
anyone | 302 to the issuer. The PKCE state is kept on the server for 10 minutes. 429 past the rate limit. 503 {"error":"sign-in unavailable"} when the issuer cannot be reached. |
GET /v1/auth/callback |
the issuer | Verify, create or find the account, set the session cookie, and 302 back to the portal. Any failure is 400 {"error":"sign-in failed"}. |
POST /v1/auth/logout |
session | Delete the session and clear the cookie. 204. A key is 403. |
GET /v1/me |
session or key | {"account":{"name","id","created"},"labels":[...]}. name is there only when sign-in gave one. No secrets. |
POST /v1/hosts |
session, or hosts:write |
Reserve with {"label":"ada"}. 201 with label, created, and url. 409 if taken or held. 400 if invalid or reserved. 403 {"error":"label limit"} past 20 labels per account. |
GET /v1/hosts |
session, hosts:read, or hosts:write |
{"hosts":[...]}: each label with created, url, and latest, which is null before the first upload. |
GET /v1/hosts/{label} |
owner: session, hosts:read, or hosts:write |
The label, when it was made, and its versions: version, sha256, size, created, pinnedUrl. Another account's label is 404, the same as one that does not exist. |
DELETE /v1/hosts/{label} |
owner: session, or hosts:write |
204. The label and every version stop serving. The name is held 30 days. Version numbers are never reused, even if the name is reserved again. |
POST /v1/hosts/{label}/builds |
owner: session, or hosts:write |
The body is raw age ciphertext, application/octet-stream, or 415. 201 with version, sha256, size, url, and pinnedUrl, which is null for now. |
DELETE /v1/hosts/{label}/builds/{n} |
owner: session, or hosts:write |
204. That version stops serving. The latest moves back to the highest remaining version. |
POST /v1/keys |
session only | Mint a key with {"name":"...","scopes":[...]}. The name is 1 to 64 printable ASCII characters. Scopes are optional. 201 with key, fingerprint, name, scopes, and created. The response is the only time the key appears. |
GET /v1/keys |
session only | {"keys":[...]}: fingerprint, name, scopes, and created, for keys not revoked. Never the key. |
DELETE /v1/keys/{fingerprint} |
session only | Revoke that key. Immediate. 204, or 404. |
- An upload is age, or it is refused. At most 256KiB. Age binary or age armor. Exactly one stanza in the header, and it is
scrypt. The scrypt work factor is at most 22, the mostageaccepts by default. A recipient-key stanza, plaintext, YAML, a shell script, an empty body, and a body over the cap are400 {"error":"refused ciphertext"}. Armor is unwrapped, so the stored and served file is always binary age. - Rate limits. 10 label reservation attempts and 30 uploads per account per hour. 60 login starts per address per hour. Windows are fixed hours. Past a limit the answer is
429 {"error":"rate limited"}. The address is kept only as a SHA-256 hash. - Other errors. 401
unauthorized. 403forbiddenfor a missing scope, or a key on a session-only route. 403csrf. 404not found. 405method not allowed, with anAllowheader. 413 and 415 for a JSON body. 500internal error. - Audit. A row for each reserve, upload, delete, and key event: time, account, label, version, sha256, key fingerprint. Never a body, a key, or a token.
| Call | Who | Result |
|---|---|---|
GET /v1/log |
public | Rows for a channel. Same record as the log page. |
POST /v1/log |
publisher | Append one version, or one withdrawal. An existing version id is refused. Ordinary keys are refused. |
PUT /v1/hosts/{label} |
account | Set visibility to public or enrolled. |
POST /v1/hosts/{label}/join-tokens |
account | Mint a blx_join_ token. Shown once. Expires. Use cap. |
DELETE /v1/join-tokens/{fingerprint} |
account | Revoke that token. Immediate. |
POST /v1/machines |
join token, once | Bind an Ed25519 public key to a hostname and a profile. |
POST /v1/machines/{hostname}/checkin |
machine signature | Report the document sha256 and the image digest. Return the desired generation. |
GET /v1/machines/{hostname}/desired |
machine signature | The same generation, for a machine that woke up. |
GET /v1/machines |
account | Inventory. No secrets. |
POST /v1/machines/{hostname}/troubleshoots |
troubleshoot:request |
Open a collect. A shell scope and a command field are refused. |
GET /v1/machines/{hostname}/troubleshoots/{id} |
troubleshoot:read, or the machine |
Status. No report body. |
DELETE /v1/machines/{hostname}/troubleshoots/{id} |
troubleshoot:request |
Cancel. A later result is refused. |
POST /v1/machines/{hostname}/troubleshoots/{id}/result |
machine signature | One report, cap 64KiB. Closes the job. |
GET /v1/troubleshoots |
troubleshoot:read |
Open jobs for the account. No report bodies. |
A stolen API key can replace your ciphertext. It cannot produce a document that decrypts under a build key it does not hold. Revoke the API key. The build key is never a field on any of these calls. Machine routes and join tokens are the service contract. Build, deploy, and the collect are the platform contract.