HTTP API
Every node exposes the API (default 0.0.0.0:8080, tunable with
--http <addr>, disabled with --no-http). Any node is a complete
entry point — upload, download and listing give the same result
everywhere.
The API is authenticated by the multi-tenant layer: every upload is signed for a space (Ed25519, no shared secrets server-side), owned files are served through signed links or public-read spaces, and a node’s own loopback reads everything (operator tooling). No reverse proxy required for access control any more. Content confidentiality is a separate, already-solved problem: end-to-end encryption keeps the nodes blind to what they store.
POST /api/upload?name=<name>&ttl=<seconds>&ack=<encoded|local>
Section titled “POST /api/upload?name=<name>&ttl=<seconds>&ack=<encoded|local>”Body: the file’s raw bytes. With curl use -T <file>:
curl -T video.mp4 "http://node1:8080/api/upload?name=video.mp4"-T streams from disk. --data-binary @file also works but buffers the
whole file in the client’s RAM before sending a byte — a 1 GiB upload
kills the client on a small machine before the server sees anything. The
server side is streaming either way: the node encodes stripe by stripe as
the body arrives and pushes each shard to its owner (itself included),
memory bounded to a few stripes whatever the file’s size.
ack=local is available to delegated v2 grants that bind both BLAKE3 and
content length. The node writes the raw body once, verifies it, fsyncs it,
commits the space reference and answers with "dispersing": true.
Reed-Solomon dispersion continues in the background; GET and HEAD already
serve the durable staged copy, and restart recovery resumes an interrupted
drain. Without ack=local, the historical encoded acknowledgement remains:
the response waits for shard placement and reports degraded_shards.
Uploads REQUIRE the four signature headers of a space
(X-Nauka-Space, X-Nauka-Key, X-Nauka-Timestamp,
X-Nauka-Signature, plus X-Nauka-Content-Hash to bind the exact
bytes) — nauka space sign prints them, and the
multi-tenant page specifies the exact
canonical string for implementing it in your own backend. An admin
key is required; 401/403 answers carry the reason and the remedy.
A signed upload records the space’s reference
on the file (the response then carries "space"), and GET /api/files
lists each file’s spaces — same bytes uploaded by two spaces = one
set of shards, two references. Past the space’s (or org’s)
storage quota,
the upload is refused 403 with the numbers; past the space’s monthly
egress quota, reads slow to a crawl and carry
X-Nauka-Throttled: egress-quota.
200 response:
{ "hash": "988f6e61…", "size": 30000000, "name": "video.mp4", "stripes": 8, "data_shards": 4, "parity_shards": 2, "link": "/f/988f6e61…", "degraded_shards": 0, "dispersing": false}hash is the BLAKE3 of the whole file — the file’s permanent address.
degraded_shards counts the shards that could not be delivered to their
owner node during the upload: 0 means the write is fully replicated;
anything above means a node was down or slow, the missing redundancy is
parked on the ingesting node, and the scrubber completes it in the
background. The upload is aborted, never silently under-protected, if a
stripe cannot reach at least k placed shards.
Errors:
415 Unsupported Media Type— the body was a multipart form. This endpoint takes raw bytes; accepting multipart would store the form framing, boundary and headers included, verbatim as the object.400 Bad Request— empty body (a typoed curl, a missing file).503 Service Unavailable— the registry cannot commit the write right now (no leader, no quorum). Transient: retry.500— this upload genuinely failed.
Name semantics. The name is a per-hash slot in the registry, not part
of the content’s identity. Re-uploading existing content with ?name=
sets the name; re-uploading it without ?name= preserves the existing
one — the second uploader rarely means “unname it”.
ttl=<seconds> gives the file an expiry; see
expiry below.
GET /f/{hash} — and who may call it
Section titled “GET /f/{hash} — and who may call it”Reads follow ownership.
A file referenced by an active public-read space is served bare. A
file referenced by private spaces only takes a signed link —
?space=<org/space>&exp=<unix>&sig=<hex> plus optional &rate=
(bytes/s), &conc= (max simultaneous connections per node) and &ct=
(serve inline as this content type), all signed and un-removable:
Ed25519 over
nauka-link-v1\n{hash}\n{space}\n{exp}\n{rate|-}[\n{conc|-}[\n{ct}]],
minted offline by the space’s backend (or nauka space link). Past
the conc cap the node answers 429 + Retry-After until a connection
ends — the cap is counted across the DNS neighborhood (the nodes
gossip their in-flight counts), not just per node.
Without ct a file downloads as an application/octet-stream
attachment, which is the default. With it the node answers with that
exact type, Content-Disposition: inline and nosniff, so a browser
plays the video or displays the PDF in place — useful for a preview
page that should not have to proxy the bytes. Only types a browser
cannot execute are accepted (image/jpeg, image/png, image/gif,
image/webp, image/avif, video/mp4, video/webm, audio/mpeg,
audio/mp4, audio/ogg, audio/wav, application/pdf,
text/plain); HTML, SVG and XML are refused with a 403, at signing
time by the CLI and at read time by the node.
Bare public reads obey the space’s rate_default. 403 otherwise,
with the remedy.
Unowned files (pre-0.6 leftovers) are served to nobody until adopted.
HEAD obeys the same gate; loopback bypasses it (operator reads).
Reconstructs the file and serves it, streaming (one stripe in memory at a time), from the whole cluster: local shards first, then fetched from the other members. k valid shards per stripe are enough — dead nodes and corrupted shards are compensated by Reed-Solomon, invisibly to the client.
curl -o video.mp4 http://node3:8080/f/988f6e61…Content-Length: the file’s exact size.Content-Disposition: attachment; filename="<name>"if the file has a name in the registry.- Integrity: the global hash is recomputed during the stream; an unreachable peer is written off for the duration of the request (3 s connection timeout, 20 s per shard) instead of being retried for every shard.
- The first stripe is reconstructed before the status line is sent:
a file that is currently unrecoverable answers an honest
503 Service Unavailablerather than a200followed by a truncated body. A stripe failing later in the stream still truncates — nothing better exists mid-stream.
Status codes:
| Code | Meaning |
|---|---|
200 / 206 | served (whole file / requested range) |
404 | hash unknown to the registry |
410 Gone + content removed: <reason> | banned (nauka ban) |
410 Gone + file expired | TTL elapsed |
410 Gone + file deleted | removed from the registry |
416 | requested range outside the file (Content-Range: bytes */<size> attached) |
503 | too many shards currently missing to reconstruct |
HEAD /f/{hash} answers the same headers (Content-Length,
Accept-Ranges: bytes) without a body, and the same 410s.
Partial requests (Range)
Section titled “Partial requests (Range)”GET /f/{hash} accepts Range: bytes=… and answers 206 Partial Content
with Content-Range. Suffix ranges work (bytes=-500 = the last 500
bytes); an end past the file is clamped; a range that starts past the end
is 416. Only the stripes intersecting the range are fetched from the
cluster and decoded — reading 64 bytes in the middle of an 81 MB file
costs a single round trip, not the file.
Useful for resuming downloads and for media playback.
DELETE /f/{hash}
Section titled “DELETE /f/{hash}”Deletion follows ownership.
A file referenced by spaces requires a signed DELETE from one of
them (the same X-Nauka-* headers, method DELETE, path /f/<hash> —
nauka space sign --method DELETE --path /f/<hash> prints them): it
releases that space’s reference, 204. The content itself only
disappears with its last reference — then the registry entry drops
and each node’s GC purges the orphaned shards on its following passes.
An unsigned DELETE on an owned file gets 403 naming the owners.
Unsigned DELETE of an unowned (pre-0.6) file is operator-only:
accepted from the node’s loopback, 401 from anywhere else.
GET /api/status
Section titled “GET /api/status”The cluster as this node sees it. This operator endpoint accepts the node’s
own loopback or a short request proof derived from the cluster identity.
nauka status creates that proof automatically:
{ "self_addr": "10.0.0.1:7311", "self_node_id": 13816319000459994208, "leader": "10.0.0.1:7311", "nodes": [ { "addr": "10.0.0.1:7311", "id": 13816319000459994208, "capacity_bytes": 197586380800, "is_leader": true, "is_self": true, "is_alive": true }, { "addr": "10.0.0.2:7311", "id": 4443749509604496789, "capacity_bytes": 98793190400, "is_leader": false, "is_self": false, "is_alive": false } ], "files": 12, "total_bytes": 3210987654}idis the member’s Raft id — the valuenauka node remove <id>takes. There is one row per member, not per address: two members can share an address (a replaced machine whose stale identity lingers), and rows keyed by address would collapse them into an indistinguishable duplicate.is_aliveis this node’s view from its own pinger, not a cluster-wide verdict:falseonce the peer has missed ~15 s of probes. A member reads as down for placement — it takes no new shards — but stays a full member; membership only changes throughnode add/node remove. The map is optimistic: a peer nobody has probed yet reads alive.files/total_bytescome from the replicated registry.
GET /api/location
Section titled “GET /api/location”The city and ISO country code of the node answering the request:
{ "city": "Helsinki", "country_code": "FI" }Call it through the cluster’s geo-DNS hostname to describe the storage
node that was actually selected for the client. The answer deliberately
contains no address or topology. It is derived from the same city
database as geo-DNS, carries Cache-Control: no-store, and returns 503
while that database is unavailable. CORS is enabled, so a static web
client can render it directly.
GET /api/files
Section titled “GET /api/files”The operator’s replicated registry (this node’s local copy, possibly a few
hundred ms behind the leader). It has the same loopback-or-proof gate as
/api/status. Expired files are filtered out:
[ { "hash": "988f6e61…", "size": 30000000, "name": "video.mp4", "link": "/f/988f6e61…" }]Backends that own a space must use GET /api/space-files?space=<name>
instead. That endpoint requires the space’s admin signature and returns
only its objects; it never reveals other spaces that reference deduplicated
content.
Expiry and banning
Section titled “Expiry and banning”TTL — POST /api/upload?ttl=<seconds>
Section titled “TTL — POST /api/upload?ttl=<seconds>”The manifest carries an expires_at. Past it the file disappears from
/api/files, GET answers 410 Gone with file expired, and the purge
reclaims the shards cluster-wide.
Banning — nauka ban <hash> --reason "…"
Section titled “Banning — nauka ban <hash> --reason "…"”To honor a takedown notice or a legal order without ever reading the
content: the hash is banned in the Raft state, the file leaves the
registry, GET answers 410 Gone with content removed: <reason>, the
shards are purged, and re-uploading the same content is refused (the
registry rejects the manifest, and the upload fails). nauka unban <hash>
lifts the measure.
Accepted structural limitation: a ban targets that content byte for byte only — a re-upload encrypted under a different key yields a different hash. See End-to-end encryption.
Purge safety
Section titled “Purge safety”A node purges only when its registry is trustworthy (member of the cluster, leader known, replication caught up): a freshly started node whose registry is still empty erases nothing — otherwise it would destroy the cluster. A shard referenced by another live file is never deleted.
POST /f/{hash}/refs?to=<org/space>
Section titled “POST /f/{hash}/refs?to=<org/space>”Adds a space’s reference
to an existing file — publish to a public-read space, or adopt an
unowned legacy file (to = the signing space). Signed (X-Nauka-*
headers, admin key; the canonical path includes the ?to= query).
Chain of custody enforced: the signer must already reference the file,
and the target must belong to the same organisation. Revocation is the
signed DELETE /f/{hash} of that reference.
GET /api/orgs
Section titled “GET /api/orgs”The operator-only replicated organisation/space registry: orgs,
spaces and their policies, and each space’s public keys (hex, with
role and name — private halves never exist server-side). This is what
nauka org list and nauka space key ls read.
What does not exist yet (v1)
Section titled “What does not exist yet (v1)”- Multipart uploads / resuming an interrupted upload.