Skip to content
ReferenceCatalogue upload exchange

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:

http
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:

json
{
  "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"
}
  • schemaVersion is the number 1. upload.id is a nonempty opaque string of at most 255 UTF-8 bytes. upload.expiresAt uses the upload manifest’s exact timestamp format.
  • missing is strictly ascending in JavaScript string order and unique. Each item is exactly 64 lowercase hexadecimal characters and equals a sha256 in the ownership marker. It may be empty.
  • blobUrl contains 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%7D is not a placeholder. After substitution, the digest must be in the URL pathname.
  • blobUrl and completeUrl obey the request-boundary URL rule above. blob:, data:, file: and every other scheme are invalid even if URL parsing exposes the endpoint’s origin.
  • blobEncodings is optional. The CLI uses gzip for that Plan round only when this value is an array that contains the exact string gzip. It ignores every other item, and any other value, including null, counts as absent.
  • The receiver lists gzip only 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:

http
PUT <blobUrl with {sha256} replaced>
Authorization: Bearer TOKEN
Content-Type: application/octet-stream
Content-Length: <body length>
Content-Encoding: gzip

Content-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:

http
Authorization: Bearer TOKEN
Accept: application/json
Content-Length: 0

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