Catalogue upload exchange
The plan, blob and completion requests that publish a catalogue and send only new files.
Boundary
The CLI and manifest contract owns repository metadata; the ownership contract owns the content address list. This document owns all exchange and receiver behavior.
Upload Exchange
Every request carries Authorization: Bearer TOKEN, times out after 120
seconds including the response-body read, uses manual redirect handling, and
follows no redirect. Plan, Blob and Complete URLs must be absolute http: or
https: URLs without userinfo. Their scheme, hostname and effective port must
equal the configured endpoint’s, so the bearer credential is never sent to a
different origin or protocol.
HTTPS verifies certificate chains and hostnames. Publish trusts the Mozilla root certificates bundled with Mokly as well as the operating system store. An empty operating system store still permits a chain to a bundled root. OS-only trust is insufficient for minimal containers without CA packages.
Plan
POST to the exact configured endpoint, preserving its path and query string without appending anything:
Authorization: Bearer TOKEN
Content-Type: application/gzip
Accept: application/json
Content-Length: <bytes>The body is one gzip-compressed POSIX tar archive, USTAR with PAX extended
headers when needed. It contains only regular root-relative files, with no
enclosing directory, in fixed order: mokly-upload.json,
.mokly-export-artifact, then the catalogue index mokly-viewer/index.json.
The three files are byte-identical to the finalized output. No other
entry is permitted. Success is 200 with Content-Type: application/json,
optionally with media-type parameters, and this response shape. These example
URLs are Mokly Cloud’s; receiver paths are otherwise opaque to the CLI:
{
"schemaVersion": 1,
"upload": {
"id": "<opaque string>",
"expiresAt": "2026-09-26T12:15:00.000Z"
},
"missing": ["<sha256>", "<sha256>"],
"blobEncodings": ["gzip"],
"blobUrl": "https://api.mokly.ai/v1/projects/<projectId>/publication-uploads/<id>/blobs/{sha256}",
"completeUrl": "https://api.mokly.ai/v1/projects/<projectId>/publication-uploads/<id>/complete"
}schemaVersionis the number1.upload.idis a nonempty opaque string of at most 255 UTF-8 bytes.upload.expiresAtuses the upload manifest’s exact timestamp format.missingis strictly ascending in JavaScript string order and unique. Each item is exactly 64 lowercase hexadecimal characters and equals asha256in the ownership marker. It may be empty.blobUrlcontains the literal{sha256}exactly once and before any?or#. The CLI counts and substitutes that literal in the raw string before URL parsing because WHATWG parsing percent-encodes braces. Encoded%7Bsha256%7Dis not a placeholder. After substitution, the digest must be in the URL pathname.blobUrlandcompleteUrlobey the request-boundary URL rule above.blob:,data:,file:and every other scheme are invalid even if URL parsing exposes the endpoint’s origin.blobEncodingsis optional. The CLI uses gzip for that Plan round only when this value is an array that contains the exact stringgzip. It ignores every other item, and any other value, includingnull, counts as absent.- The receiver lists
gziponly when it decodes gzip Blobs for the whole upload, until the upload expires. - Unknown fields are ignored. A missing or invalid required field, another 2xx
status, a body over 16 MiB, invalid JSON, or a non-JSON content type is
upload-failed. - Decision in force: the field is optional and the exchange stays v1 because earlier CLIs ignore unknown Plan fields. Rejected: a v2 Plan, because a v1 CLI refuses every v2 Plan.
At Plan time the receiver verifies and stores the manifest and the index
after matching their marker digests and sizes. It never lists those digests in
missing. If a publication already exists for this headSha and configPath,
it answers missing: []; Complete then resolves the keep-first result below.
The plan fixture has root fixture
schemaVersion: 1, endpoint, marker digests and independent Plan/Complete
cases. Cases have unique name, valid, step (plan by default), optional
status (default 200), optional contentType (default application/json; null
means absent), and document or raw body. Complete cases add outcome
(published, already-published, replan, retry, or an error category) and
normalized viewerUrl or null; invalid Plan cases are upload-failed. It
covers every field, content-type, status, URL, placeholder, and Blob encoding
boundary here. A valid Plan case can carry blobEncoding, the Blob encoding
the client chooses for that Plan: gzip or the default identity.
Blobs
For each distinct digest in missing, in parallel up to the configured
concurrency limit:
PUT <blobUrl with {sha256} replaced>
Authorization: Bearer TOKEN
Content-Type: application/octet-stream
Content-Length: <body length>
Content-Encoding: gzipContent-Encoding: gzip is present only for an encoded body. Content-Length
is the length of the request body.
When the round’s Plan lists gzip and one gzip member of the file is smaller
than the file, the body is that member and has Content-Encoding: gzip.
Otherwise the body is the exact file bytes without Content-Encoding.
The marker digest and size always describe the decoded file bytes. Marker
entries sharing a digest require one PUT. Every attempt of one Blob sends the
same headers and body. Any 2xx means stored; receivers return 204 with no body.
400 means invalid gzip data or decoded bytes that do not match the declared
digest or size. 404 means the digest is outside this Plan or the upload id is
unknown. 410 means the upload expired and consumes the command’s single
re-plan. The first failed Blob cancels in-flight requests and starts no new
ones.
Decision in force: the CLI compares the gzip size with the file instead of keeping a list of compressed media types. This needs no list and never sends a body larger than its file.
Complete
After every requested Blob succeeds, POST the receiver-provided completion URL with no body:
Authorization: Bearer TOKEN
Accept: application/json
Content-Length: 0Complete is idempotent per upload id. The first successful Complete for an
upload records both its status and exact response body. Repeating Complete for
that upload returns the same status and body and never creates another
publication. A lost 201 response can therefore be retried without changing
the publication or turning it into 200.
201 means this upload created the first publication for its headSha and
configPath. 200 means a different upload already completed that identity;
the receiver keeps and returns that first publication unchanged. For
overlapping uploads, the one that completes first receives 201; the other
receives 200. The receiver resolves this before checking stored digests, so
an upload of an already published identity completes with 200 even when it
sent no blobs. Any other 2xx is upload-failed.
Success does not depend on its optional
{ "id", "projectId", "state", "catalogueUrl", "viewerUrl" } body. Use
viewerUrl only from a JSON object no larger than 16 MiB when it parses as an
absolute HTTP(S) URL; print normalized href, never raw input. An unusable body
does not fail or retry. Omit a URL containing the token or any case variant of its percent encoding. Terminal redaction uses the same rule.
409 means the receiver still lacks a listed digest and 410 means the upload
expired. Either begins one fresh Plan for the whole command. A second 409,
410, or local expiry is upload-failed. 404 means an unknown or foreign
upload id and fails without re-planning.
Retries And Expiry
Plan, Blob and Complete requests are tried at most five times. Retry only 408,
429, 500, 502, 503 and 504, plus transport failures such as a connection failure,
attempt timeout, or an interrupted response before its result is known. Before
attempts 2, 3, 4 and 5, wait a uniformly random duration from zero through 1,
2, 4 and 8 seconds respectively. An integer Retry-After from 0 through 60
seconds, digits only, replaces that wait; every other value is ignored.
Blob and Complete attempts and waits may not reach or pass upload.expiresAt.
Do not start an attempt at or after expiry, and do not begin a wait that would
end at or after it. Local expiry consumes the same one re-plan as 409 and 410.
Receivers choose an expiry long enough for a complete upload; Mokly Cloud
allows sixty minutes. Command cancellation stops attempts and waits
immediately with the cancellation output. Exhausted retries use the
transport-failure output.
Accounting And Output
Accounting is by digest but reports marker entries. For the entire command,
form the union of SHA-256 digests of every Plan-archive file and every digest
sent through at least one Blob PUT attempt in any round. <uploaded> is the number of marker
entries whose digest is in that union; entries sharing one digest each count.
<unchanged> is every remaining marker entry. The ownership marker itself is
not a marker entry and is in neither count. A first publish to an empty
receiver therefore reports 0 unchanged, and a lost PUT response followed by
a re-plan still counts that digest as uploaded.
Rich progress is per Plan round. Hash each Plan-archive file and combine those
digests with that round’s missing set. <total> is the number of marker
entries whose digest is in that combined set. <n> begins at the number whose
digest belongs to any Plan-archive file, then advances by every marker
entry sharing a digest when that digest’s PUT completes. <size> counts file
bytes once per distinct combined digest, including the raw bytes of files in
the Plan archive; it does not count the compressed archive or gzip Blob body.
The marker’s own byte length participates even though it is not an entry. A
re-plan restarts at that round’s initial <n>, <total> and <size>. A round
with empty missing shows no progress label. In one round, its final <n>
equals the summary’s <uploaded>. The
terminal output contract owns the progress label.
Decision in force: the progress <size> counts decoded file bytes because the
Plan archive already travels compressed and counts its files’ raw bytes, and
the label exists before any Blob compresses. The summary line counts marker
entries, so the encoding does not affect it.
A 201 completion prints exactly one counted summary, followed by the viewer
URL on its own line when accepted. A 200 completion replaces the counted
line with the already-published line and may print the kept publication’s
viewer URL. The terminal compatibility contract
owns the exact lines.
This document owns cancellation classification and precedence for
mokly publish. A failure is a cancellation only when the command’s
cancellation aborted it, in a request or in the Git runner, or when the core
detected the cancellation itself. The only signal-based exception is the
pre-installation window
owned by the static build recovery contract. Outside that window, never infer
cancellation from a cause chain, a combined error’s members, error text or an
already-aborted command signal. An error that combines cancellation with a
rollback, backup or reservation-cleanup failure is not a cancellation.
mokly publish uses the cancellation output only for a cancellation. It
prints every other error unchanged with that error’s own category, whether or
not the command was also cancelled. This includes a build recovery error
that names the backup or reservation path required for recovery. Every failed
publish exits with status 1. The
static build recovery contract
owns output transaction outcomes and the mokly build output.
A cancellation keeps category upload-failed. The
terminal compatibility contract
gives the exact plain and rich output of a cancellation and of an exhausted
retry or transport failure. Raw response bodies, headers, URLs, credentials and transport exception text
never enter either mode.
Rejections, Limits And Receiver Validation
Status categories, upload ceilings, and independent receiver validation follow the separate upload validation contract.