Remote Registry Protocol
Reads are public and stable; mutations are authenticated and experimental. Consuming hosted packages - the registry
config.json, index reads, and artifact downloads - is a normal client path that needs no account, login, or token (cabin login/cabin logoutexist for the authenticated surfaces and are stable too). The mutation surfaces (cabin publish --index-url,cabin yank, and the admin API) require a token, stay gated behind-Z remote-registry, and carry no compatibility promise: those routes, framings, and status codes may change or disappear between releases without a migration path.
Names are scoped. Registry packages are always
<scope>/<name>(e.g.fmtlib/fmt): every package route carries the<scope>/<name>pair, the artifact filename embeds the scope and the packaging revision (<scope>-<name>-<version>-<revision>.zip, so a downloaded archive stays self-identifying outside the directory tree), and publish/yank additionally require the token’s user to be a member of the target scope (seeregistry/docs/architecture.md, “Scopes”). Bare names exist only in local-only manifests and local file registries;cabin publishrejects them before any connection.
This is the authoritative contract for Cabin’s remote registry protocol: exactly what the Cabin
client (this repository) and a conforming registry server implement. The registry service
itself - accounts, token issuance, hosted storage - is not part of the Cabin crates; its hosted
implementation lives under registry/ in this repository, outside the OSS core boundary
described in registry-design.md.
Client status today: the config.json fields below and client-side
token handling - cabin login / cabin logout plus
token-optional reads - are stable, and the hosted registry is the
default index origin when no explicit override applies.
Publishing (cabin publish against an HTTP index source) and
yanking (cabin yank) are implemented behind -Z remote-registry.
The default registry
When a command needs an index and neither the CLI (--index-path / --index-url) nor the
config ([registry], see config.md) names one, Cabin uses its default
hosted-registry index origin, https://registry.cabinpkg.com. The default behaves exactly like
a config-supplied URL: [source-replacement] applies to it, --offline refuses it (pass
--index-path, e.g. a cabin vendor output), and --frozen refuses a URL terminal. It only
ever materializes when the selected closure actually has versioned dependencies, so dependency-free
projects never observe it - and it never applies to cabin publish, cabin yank, or
cabin vendor, which keep requiring an explicitly named source (cabin login / cabin logout do
fall back to it, since they manage credentials for exactly that hosted registry - reads are
public, and a token is what unlocks publishing).
Registry configuration
A remote registry serves the same registry-root layout as the sparse HTTP index documented in
package-index.md. Its config.json may carry two additional fields:
{
"schema": 1,
"kind": "file-registry",
"packages": "packages",
"artifacts": "artifacts",
"auth-required": false,
"api": "https://cabinpkg.com"
}
| Field | Type | Default | Description |
|---|---|---|---|
auth-required | bool | false | When true, every request to this registry except config.json itself - package metadata and artifact downloads included - must carry Authorization: Bearer <token>. config.json stays publicly readable so cabin login can discover the api origin before any credential exists: sessions are the only credential, so a registry that authenticated its own configuration could never be logged in to. The hosted registry serves false: its verified reads are public (registry/docs/architecture.md, “Origins and roles”). |
api | string | absent | Absolute base URL of the registry’s API origin - on the hosted registry the website origin "https://cabinpkg.com", following crates.io’s "api": "https://crates.io" discipline (see One role per hostname). Non-http(s) schemes and URLs with userinfo credentials are rejected, mirroring the index-URL hygiene of the sparse HTTP client. The read routes never consult it. When absent, cabin publish fails with an error naming the field: mutation requests are only ever sent to an explicitly declared API origin. |
Both index parsers (the local --index-path loader and the sparse HTTP client) recognize the
fields unconditionally: a vendored or mirrored copy of a hosted registry loads like any other
file registry, and the read routes never consult api - only the gated mutation commands do.
One role per hostname
The hosted registry splits its hostnames by role, exactly crates.io’s discipline
(index.crates.io serves the index, static.crates.io the downloads, and crates.io the web
UI plus the entire API, glued together by config.json’s dl/api fields):
| Hostname | Role |
|---|---|
registry.cabinpkg.com | The machine read plane only, public for verified content: config.json, package metadata, artifact downloads, and /healthz. Cabin keeps artifacts on the index host - the client’s same-origin artifact rule makes a separate download host pointless - so this one hostname covers what crates.io splits across index.crates.io and static.crates.io. |
cabinpkg.com | The website, plus everything else the registry serves: the browser sign-in flow, the session-cookie user API, the Bearer mutation routes, and the one unauthenticated JSON route - GET /api/v1/stats, aggregate download/package totals for the website’s own pages (service-local; not part of the client protocol). config.json’s api field names this origin. |
On the index host, every path outside the read plane - the mutation routes included - answers
the same uniform 401 as a missing token, whatever credential comes along: the mutation
surface is indistinguishable from any unknown path there.
Authentication
The authenticated surfaces - publish, yank, the admin API, and any registry declaring
auth-required - authenticate with a bearer token:
Authorization: Bearer cabin_ses_<base64url>
- Reads on the hosted registry need no token. When the client holds one for the origin it is
still attached; a presented token that fails to validate answers
the uniform
401rather than being ignored, so a rotated or revoked credential fails loudly instead of silently reading as anonymous. - Every token is short-lived and machine-minted; there are no standing API keys.
Login sessions mint
cabin_ses_<base64url>tokens from a GitHub access token (the human flow), and trusted publishing mintscabin_tp_<base64url>tokens from a workflow’s OIDC JWT; both ride the sameAuthorization: Bearerheader. - Token scopes:
publish,yank, andverify(the verification lifecycle’s verifier scope). Any valid token additionally opens the read plane’sverify-scope carve-outs (pending-artifact fetches). - How to get a token is documented, not negotiated:
cabin logindiscovers only theapiorigin (fromconfig.json) before running GitHub’s device flow, and the login-URL challenge below points humans at this documentation.
The login-URL challenge
Every unauthenticated (401) response from the Bearer plane carries a WWW-Authenticate
challenge naming where to read how to get a token, mirroring Cargo’s Cargo login_url
challenge:
WWW-Authenticate: Cabin login_url="https://cabinpkg.com/docs/remote-registry"
The grammar is the scheme token Cabin (ASCII case-insensitive, per RFC 7235) followed by a
quoted login_url parameter carrying an absolute http(s) URL. The header is byte-identical
on every path and failure reason - a missing token on the mutation surface, an invalid token,
and an unknown path all answer the same challenge - so the 401s the Bearer plane still emits
stay indistinguishable from one another. Because the read plane serves unauthenticated GETs,
reads themselves no longer challenge; the challenge survives on every non-read-plane path of
the index host.
Client-side token handling
Token handling is part of the stable read path: whenever a credential is available for the index
origin, the sparse HTTP client authenticates its reads with it, and cabin login /
cabin logout manage the stored credential.
cabin login and cabin logout
cabin login resolves the registry from --index-url (or the [registry] index-url setting in
config.md, else the default registry - a local
index-path is rejected, since sessions only apply to HTTP registries), names the resolved
origin, and mints a login session through GitHub’s OAuth device flow:
$ cabin login
logging in to `https://registry.cabinpkg.com`
To authorize this login, open https://github.com/login/device and enter the code:
ABCD-1234
waiting for the login to be approved on github.com ...
Login session for `https://registry.cabinpkg.com` saved (expires <RFC 3339>)
The client discovers the registry’s api origin from config.json
(public even on an auth-required registry, so a first login needs
no credential), requests a device code from github.com, and prints the verification URL and user code (to
stderr, so they show under --quiet). On an interactive terminal it offers to open the page
in the browser - only after Enter, never unprompted - and piped runs just print the URL and
code. It then polls GitHub at the server-set interval, honoring slow_down, until the login
is approved, denied, or the device code expires. The GitHub access token the grant answers is
presented to the registry’s mint route exactly once and then
dropped - never written to disk, never logged - and GitHub’s refresh-token fields are discarded
the same way: an expired session is replaced by running cabin login again, not refreshed.
What is stored is the minted cabin_ses_ token, its expiry, and the api origin it was minted
for.
The credential commands consult user-level config only: a checked-out project’s
.cabin/config.toml (registry selection or [source-replacement]) must not be able to steer
where a minted credential is stored. Login refuses a plain-http origin beyond loopback up
front, and refuses to run at all under offline mode. The confirmation
only ever names the origin and the expiry, never token bytes.
cabin logout best-effort revokes the stored session against the
api origin it was minted for, then removes it from storage and reports whether one existed.
A failed revocation (the session already expired or was revoked, or the registry is
unreachable) is tolerated, and offline mode skips the revocation outright; local storage or
configuration failures can still fail the command. When the platform keychain itself cannot be asked,
logout says so - a session stored there survives and would be used again once the keychain is
back, so the warning advises re-running cabin logout then.
Credential storage
Sessions are stored in the platform keychain when one is available - the macOS Keychain,
the Windows Credential Manager, or the Linux secret service - under the cabin-registry
service, one entry per normalized index origin. When no keychain is reachable (headless
Linux, typically), the session falls back to credentials.toml inside the user config home -
the same directory resolution as the user-level config.toml in
config.md: $CABIN_CONFIG_HOME verbatim when set, else the
platform user config home with the cabin suffix (Linux and macOS: $XDG_CONFIG_HOME/cabin /
$HOME/.config/cabin; Windows: %APPDATA%\cabin) - and cabin login says so with a one-line
notice. Setting $CABIN_CONFIG_HOME bypasses the keychain entirely, which is what keeps
tests and sandboxed runs off the real one.
[registries."https://registry.cabinpkg.com"]
token = "cabin_ses_..."
expires-at = "<RFC 3339, as the mint answered>"
api-url = "https://cabinpkg.com"
Keys are normalized index origins - scheme + host + port, no path, no trailing slash. All
three fields are required and unknown fields are rejected; api-url is the
api origin the session was minted for, so cabin logout can
revoke without re-reading config.json. A file this client cannot read - the pre-session
token-only shape included, whose long-lived keys no registry accepts any more - holds no
usable session: reads treat it as absent (with a warning naming the state) and the next
cabin login replaces it wholesale. On Unix the file is created with mode 0600, and
Cabin warns once per invocation when an existing file is group- or world-readable. Writes are
atomic (sibling temp file + rename). Credentials are deliberately not part of
config.toml: config.md rejects credential-shaped
tables so a secret can never ride along in a published archive.
Client-side, the stored expires-at is advisory: an expired session is withheld from requests
and surfaced with a warning naming the expiry and the fix (cabin login), because the
registry’s own uniform 401 could never name the cause. The
server remains authoritative - a session revoked early still answers 401 with the token
present.
Environment override
When CABIN_REGISTRY_TOKEN is set and non-empty, its value wins over
a stored session for the origins it applies to: the default registry’s
origin, and a loopback origin (local testing) that the user chose - named by --index-url, or
configured in the user-level config.toml (the file CABIN_CONFIG points at counts as
user-level). Any other registry always uses the stored session, and so does a loopback origin
that a workspace- or package-level .cabin/config.toml picked or that a [source-replacement]
hop rewrote the index onto. The override carries no origin key of its own, so without that
restriction a checked-out project could aim the index at a listener it started itself and collect
the token; session storage is origin-keyed by construction and has no such hole.
[source-replacement] disqualifies the override even when the user declared the replacement in
their own config.toml: a resolution records the hops it walked, not which file declared each of
them, so the safe answer is the only available one. Run cabin login --index-url <mirror> to
store a token for the replacement’s origin instead - that is origin-keyed, and it is already what
cabin login does for a replaced source.
Whenever the variable is set, an origin is refused it, and no stored credential answers either, Cabin says so before the registry’s own “authentication required” error - a withheld credential is otherwise indistinguishable from one that was never configured.
The override is useful for CI, where writing a credentials file is undesirable; it also works when
no user config home can be resolved at all. Cabin removes the variable from the environment of
every child it spawns - cabin run / cabin test executables, the Ninja build backend (and the
compile / wrapper commands it runs), the toolchain detection probes, clang-format,
run-clang-tidy, and pkg-config - so spawned code cannot read the credential.
Publishing from GitHub Actions
Inside a GitHub Actions run whose workflow (or job) grants permissions: id-token: write,
cabin publish needs no token at all against the default hosted registry: with no explicit
CABIN_REGISTRY_TOKEN set, the client fetches the run’s own OIDC token from the runner and
performs the trusted-publishing exchange automatically. The
credential-source precedence is fixed: the explicit CABIN_REGISTRY_TOKEN override first, then
the GitHub Actions auto-exchange, then the stored login session - so CI’s
explicit or ambient credential always wins over an ambient personal session. The override
keeps its documented
origin gate (above); the exchange is stricter, because the run’s OIDC
token is itself a credential the hosted registry accepts from any presenter: it serves the
default hosted registry always, a loopback registry only when named by an explicit --index-url
(the test-harness path), and a loopback index’s config.json may only declare a loopback api -
so an index or api origin steered by a checked-out project’s config never sees the JWT. Only cabin publish exchanges: the minted token carries exactly the
publish scope, so cabin yank keeps using the override or a stored session - an auto-exchange
there would trade a working yank credential for a token the route must refuse.
The exchange runs at most once per command invocation - the minted token is multi-use within its
30-minute lifetime, and GitHub’s OIDC endpoint is rate-limited; the publish flow owns its token
for exactly its own duration and best-effort revokes it on the way out (success and failure
alike), so concurrent or repeated invocations in one process each mint and revoke their own
token, never sharing or replaying one. Both the OIDC token and the minted
registry token are masked out of the workflow log (::add-mask::) the moment they exist, and
neither is ever written to disk. When the command finishes - success or failure - the client
best-effort revokes the minted token; a failed revocation is ignored, since the token expires
server-side anyway. A run under GitHub Actions without the OIDC endpoint (the workflow lacks
id-token: write) fails with an error naming that permission instead of a later, unexplained
401. Reads never trigger the exchange: consuming verified packages needs
no token, and a cabin build inside an unrelated workflow that granted
id-token: write for some other service must not mint publish-capable tokens as a side effect.
When the token is sent
With a credential available, every request to the registry - config.json, package metadata, and
artifact downloads - carries Authorization: Bearer <token>. A stored token is sent to exactly
two destinations: (a) the index origin it is stored under, and (b) the
api origin declared by that index’s authenticated config.json,
where the mutation routes live - and nowhere else. This mirrors Cargo, whose tokens go to the
api host named in config.json. Neither destination ever sees the token over plain http
except loopback hosts (127.0.0.0/8, ::1, localhost), which keeps local testing possible.
The trusted-publishing leg is the one variation: there the
client discovers api from an unauthenticated config.json read (it has no token yet) and
presents the run’s OIDC token to that origin’s exchange route to obtain one.
Client-side error mapping: a 401 without a stored credential advises
cabin login --index-url <origin> (the actionable path to a working read); a 401 despite one
reports the token as rejected (revoked or expired); a 403 reports a missing scope. On an
interactive terminal, cabin publish and cabin yank with no usable stored credential offer
to run the login flow inline before contacting the registry - only for a registry the user
chose (--index-url or user-level config), never one steered by a checked-out project’s
config, the same user-level-only rule cabin login itself enforces; non-interactive runs with
an expired session fail with the expiry as the cause, since the registry’s uniform 401
cannot name it. The token never appears in logs, error messages,
or debug output; the GitHub Actions flow emits each secret exactly once as an ::add-mask::
workflow command on stderr, which is what keeps it out of the rendered log.
Read routes
The read routes are the same shapes as the sparse HTTP index in
package-index.md, served from the index origin:
| Route | Purpose |
|---|---|
GET /config.json | Registry configuration (this document’s fields included). |
GET /packages/<scope>/<name>.json | Per-package index document. |
GET /artifacts/<scope>/<name>/<scope>-<name>-<version>-<revision>.zip | Source archive download, one route per packaging revision. The <revision> segment must be exactly 16 lowercase hex characters and the <version> segment must not carry SemVer build metadata; anything else is a 404 before storage is consulted. |
On the hosted registry all three serve unauthenticated GETs: no account, login, or token is
needed to consume verified packages, and non-GET methods answer 405. A request that does
present a token gets it validated - an invalid one is the uniform 401 with the
error envelope body
{"errors":[{"detail":"authentication required"}]} and the
login-URL challenge, never a silent downgrade to anonymous. On a
registry declaring auth-required, the package and artifact routes
instead return that same 401 whenever the request carries no valid token, identical whether
or not the requested package exists - only config.json stays public, the discovery
cabin login bootstraps from. Either way the 401 bytes match every
non-read-plane path of the index host, so the mutation surface stays
indistinguishable from unknown paths.
On a registry with the verification lifecycle, the composed
/packages/<scope>/<name>.json document contains verified revisions only - each version’s
revision pointer names its current one and its revisions map lists every verified revision of
it - and the artifact route serves verified revisions to ordinary tokens; a package with no verified
revisions is indistinguishable from an unknown one. A superseded verified revision stays
downloadable, which is what keeps lockfiles that pin it building.
Publish
PUT /api/v1/packages/<scope>/<name>/<version>[?new-revision=true]
Requires a token with the publish scope. The route lives on the API origin - the
api base URL (the website origin, on the hosted registry) the
registry’s config.json must declare for mutations.
The request body is a length-prefixed frame (crates.io-style):
[u32 LE metadata_len][canonical per-version metadata JSON]
[u32 LE archive_len][zip bytes]
The metadata JSON is exactly the canonical document cabin package emits - one
packaging revision of a version, carrying that revision’s
checksum and revision-qualified source path.
A new-revision query parameter accepts only the value true (any other value is a 400, so a
typo can never silently drop the opt-in); other query parameters are ignored. It is the opt-in
described under Revisions below.
Server-side behavior is part of the contract:
- Validation. The server validates the framing, parses the metadata under the index schema
(
schemavalues other than1are refused - a document the verifier cannot judge must never enter the pending queue), requires the URL’s<scope>/<name>/<version>segments to match the metadata (the metadata’snamefield carries the full<scope>/<name>string), requires every key of the metadata’sdependenciesanddev-dependenciesmaps to be a canonical<scope>/<name>name (system-dependenciesis exempt - its keys name system packages, not registry packages), requires a declaredupstreamprovenance block to pass a lexical mirror of the manifest’s provenance rules (credential-free HTTPS URL,sha256:-prefixed 64-hexchecksum,"tar.gz"/"zip"format, single-componentstrip-prefix, non-escaping copy paths, non-escapingpatchesentries distinct from copy paths and the root manifest - the server never fetches the URL), requires a declaredlinksmap to pass a lexical mirror of the manifest’s rules (valid target-name keys, identities of ASCII letters, digits,.,_,+, and-, each identity claimed at most once), and verifies the archive bytes against the metadata’ssha256:<hex>checksum. Failures are400. Two name-level rules join the same400family (registry/docs/architecture.md, “Name fidelity”): a reserved package name (package name is reserved- the DOS device stems plus a short project vocabulary), and, for a publish that would create a new package, a name that collides with an existing same-scope package under-/_folding (package name conflicts with an existing package in this scope (differs only in '-' vs '_')). A version must be a plain upstream version: a<version>segment carrying SemVer build metadata is a400(package version must not carry build metadata; packaging corrections are published as revisions of the same version). - Idempotency. Re-publishing byte-identical metadata and archive succeeds with
200and body{"ok":true,"no_op":true,"revision":"<16 hex>","verification":"<status>"}, reporting the recorded revision and its current verification status. The stored metadata is preserved, not rewritten. - Revisions. Different bytes for a version that already has a live (pending or verified)
revision require the
?new-revision=trueopt-in. Without it the request is a409explaining the mechanism: the version is already published with different bytes; published revisions are immutable - pass--new-revisionto publish the changed bytes as a new packaging revision of this version, or bump the version. With it, a new revision is created inpending. Two different archives whose digests share the same 16-hex prefix are a loud409(a packaging revision with this id already exists with different bytes), never a silent overwrite. - The revision contract. A revision must not change what resolution consumes:
dependencies,features, andstandardsare identical across every revision of a version, so a respin can never alter a decision the resolver already made. A change to any of them is a new version, not a revision.linksjoins the contract with a one-way rule: a revision may add a claim table to a version published without one (identities are stamped onto already-published versions as packages adopt the key - note this retroactively applies to every packaging revision of the version, older pinned revisions included, whose archived manifests may predate the declaration), but an existing table can never be changed or removed by a respin.cabin publish --registry-dirrejects a violating respin outright, naming the field that changed; it is a protocol obligation on any registry implementing this contract. - Recovery. A version whose revisions are all rejected never became part of the registry,
so fresh bytes are accepted without the opt-in. Byte-identical bytes revive the rejected revision
in place - new metadata, timestamp, and publisher - back to
pendingwith a201. - A publish that creates or revives a revision succeeds with
201and body{"ok":true,"name":...,"version":...,"checksum":...,"revision":"<16 hex>","verification":"pending"}: the revision is accepted but becomes resolvable only once verified. Clients read theverificationandrevisionfields tolerantly - a registry without the lifecycle simply omits them.
Publishing from the client
cabin publish targets a remote registry when the effective index source is an HTTP URL
(--index-url, or the [registry] index-url setting in config.md) and no
--registry-dir is given. Without -Z remote-registry, the --index-url flag (even combined
with --dry-run) and publishing against a config-supplied HTTP index both fail with the standard
experimental-feature error. The flow is log in once, publish, then resolve like any consumer:
$ cabin login --index-url https://registry.cabinpkg.com
logging in to `https://registry.cabinpkg.com`
To authorize this login, open https://github.com/login/device and enter the code:
ABCD-1234
waiting for the login to be approved on github.com ...
Login session for `https://registry.cabinpkg.com` saved (expires <RFC 3339>)
$ cabin -Z remote-registry publish --manifest-path fmt/cabin.toml \
--index-url https://registry.cabinpkg.com
Published fmt 10.2.1 to https://registry.cabinpkg.com
checksum: sha256:...
revision: 9a93b2b7dfdac77c
$ cabin resolve --manifest-path app/cabin.toml
Client-side behavior:
- The staging pipeline is the same one
cabin packageand the localcabin publish --registry-dirrun - same validation, same publish lints (package-format.md), same deterministic archive and canonical per-version metadata document. The uploaded bytes are byte-identical to whatcabin packagewrites intodist/for the same source tree. config.json(which supplies theapiorigin) and the lint baseline ride the read path with the publisher’s token attached; the upload carries the same bearer token to the API origin, under the same https-or-loopback cleartext rule.- On
201the client reports the published name, version, checksum, and revision. On200it reports that byte-identical bytes were already published and exits successfully - the same “re-running with identical input succeeds” semantics as the local flows. On409it explains that the version exists with different bytes, that published revisions are immutable, and that the remedies are--new-revisionor a version bump. cabin publish --new-revisionsends the?new-revision=trueopt-in. The same flag drives the local--registry-dirflow, so the rule is identical whichever registry is targeted: changed bytes for a published version are a deliberate act, never an accident of a forgotten version bump.- Repeating
--manifest-pathpublishes a batch in one invocation: every package stages, validates, and lint-checks before the first upload - a failure in any of that publishes nothing - and the uploads then run in exactly the order the flags were given, each package’s report emitted as its receipt arrives. An upload failure mid-batch stops the run there, naming the failing package; the members before it are already live (the registry has no cross-package transaction), and their reports were already printed. One credential serves the whole batch, which is what lets a trusted-publishing run exchange exactly one OIDC token however many packages it publishes. What makes that consolidation safe: each member’s registry resolves through its own effective config (including any[source-replacement]hop), and unless every member resolves the same remote index URL - byte-for-byte; spelling variants of one origin do not agree - the batch refuses before staging, credentials, or any connection, naming the first two members that disagree. A batch mixing a remote member with one that resolves no remote index (a local or absent registry source) is refused the same way, and the consolidated registry counts as user-chosen only when it would for every member alone. An explicit--index-urlremains a whole-batch override: member configs are never consulted. In a multi-package batch a registry429between uploads is waited out (the advertisedRetry-After, capped, a few attempts) rather than failing the batch - a serial batch can outrun the user’s publish bucket, and every attempt charges it; a single-package publish keeps its fail-fast429unless--retry-rate-limitsopts it into the same pacing (automation whose reruns hit the same drained bucket). Later batch members see the versions published earlier in the same invocation as part of their lint baseline, exactly as sequential publishes would. With--format jsoneach package’s report is one compact JSON object on its own line (JSON Lines), in publish order - the per-receipt streaming that keeps a mid-batch failure from swallowing earlier reports rules out one enclosing document. Repeated paths are incompatible with the workspace selection flags, which answer the different question “which member of this workspace”. - When the response’s optional
"verification"field says"pending", the report adds that the version was accepted and becomes resolvable after verification (typically within a few minutes). The field is read tolerantly: a registry that omits it changes nothing. --dry-runstays entirely local: it stages into--output-dir(defaultdist/) and never opens a connection. In a batch, every dry-run mode rehearses each member against the registry as it stands - members of one invocation are invisible to each other (a dry run writes nothing), so the in-batch lint baseline above and an in-batch bytes conflict surface only on the real publish. A staging batch shares its one--output-dir: distinct names can flatten to the same artifact stem (a-b/canda/b-c), and such a same-version collision fails closed on the second member - nothing is clobbered - so stem-colliding packages need separate invocations with separate output directories.
Verification lifecycle
Verification is per packaging revision, not per version:
publish (201) --> pending --verdict: verified--> verified (resolvable, immutable)
^ |
| +--verdict: rejected--> rejected (blob reclaimed, quota refunded)
| |
+--republish, identical bytes--+
(201)
- pending - accepted and stored, but not part of the registry yet: excluded from composed
/packages/<scope>/<name>.jsondocuments and not downloadable with ordinary tokens. An external verifier inspects pending revisions and renders a verdict through the admin API. - verified - part of the registry: composed, resolvable, downloadable, and covered by the immutability guarantee, which applies to verified revisions only.
- rejected - the revision never became part of the registry: its archive blob is reclaimed
(unless another live revision stores the same bytes) and the publisher’s storage quota is
refunded. Republishing the identical bytes revives that revision to
pendingfor a fresh verdict; different bytes create a new revision, needing the opt-in only while some other revision of the version is still live.
A version’s current revision - the one the composed document’s revision field names - is the
verified revision with the newest published_at, breaking ties on the greater revision id. Its
revisions map carries every verified revision of the version, so superseded ones stay resolvable
by pin. Pending and rejected revisions appear in neither: a respin under review never disturbs
what consumers are being served, and it becomes current only when it is verified.
Fail-safe direction. If the verifier never runs, nothing new ever becomes resolvable. Broken verification infrastructure can only keep content unexposed; it must never expose unverified content.
The verifier may also abstain - render no verdict at all, because an advisory name check
wants an operator’s eyes first (registry/docs/architecture.md, “Name fidelity”). Abstain is
not a wire state: the version simply stays pending, and clients see exactly what they would
see while awaiting any verdict.
Admin API (scope verify)
The verify scope belongs to the operator: it may list pending revisions and download their
artifacts (ordinary tokens cannot; rejected revisions are downloadable by no one), and it gates
the two admin listings, which authenticate with the same Authorization: Bearer mechanism on
the same API origin. The verdict route is different: no registry token authenticates it. Its
credential is a GitHub Actions OIDC JWT - presented as the bearer - minted by the one workflow
the registry deployment pins (repository owner and repository by their immutable numeric ids,
workflow filename, and git ref), with the dedicated audience cabinpkg.com/verifier, so a token
minted for the trusted-publishing exchange is dead here and vice versa. Each JWT’s jti is
accepted once; replaying one - like any other authentication failure on this route - answers the
registry’s uniform 401.
GET /api/v1/admin/versions?status=pending
Lists revisions by status (pending, verified, or rejected; anything else is 400) as a
single JSON object, {"versions":[...]}. Each entry carries name, version, revision,
checksum (the canonical sha256:<64 lowercase hex> value, exactly as stored), the
publisher’s registry-native user id as published_by,
published_at, and the stored canonical metadata document as metadata. Deterministic:
ordered by name, then version, then revision.
GET /api/v1/admin/packages
The package corpus for the verifier’s name advisories:
{"packages":[{"scope":...,"name":...,"vetted":<bool>}]}, every package ordered by scope
then name, vetted reporting whether any of its revisions is verified - the advisories skip a
name that was accepted once. Deliberately not “has any verdict”: a rejection never vets a
name, so rejecting an abstained squat cannot exempt that same name’s next version.
PATCH /api/v1/admin/versions/<scope>/<name>/<version>
{ "verdict": "verified" | "rejected", "reason": "...", "checksum": "...", "published_at": "..." }
Renders a verdict on a pending revision; reason is required for rejections and recorded on the
revision. The route names a version, so checksum and published_at are required for both
verdicts (400 without them): checksum - the canonical sha256:<64 lowercase hex> value
echoed from the listing; any other spelling is 400 - selects which revision of that version the
verdict applies to, and published_at - which changes whenever a revision is revived - binds it to
exactly the row generation the listing reported. A rejected revision can be revived at any
moment under the same checksum, so a stale verdict of either direction must never land on a
generation it never judged. A binding that does not match the stored row
conflicts (409), and the same guard is enforced transactionally: a verdict racing a conflicting
verdict or a revival answers 409 rather than applying.
verified stamps verified_at and makes the revision resolvable; rejected reclaims the blob
(when no live revision references its bytes) and refunds the publisher’s storage quota. The
response reports the resulting state and whether the request changed it, mirroring yank:
{"ok":true,"name":...,"version":...,"revision":"...","verification":"...","changed":<bool>}.
Verdicts are idempotent for the same value: repeating the verdict a revision already carries -
verified on a verified revision, rejected on a rejected one - with a matching binding is a
200 no-op, so a verifier retrying after a lost response converges (a repeat rejection also
re-drives the blob reclaim in case the first attempt failed after recording the row).
Conflicts are 409: a
rejecting verdict on a verified revision hits the immutability wall, and a verifying verdict on
a rejected revision is refused - republishing is the recovery path. A late duplicate verdict
still cannot race a revival: the revival changes published_at, so the stale binding answers
409 before the transition is consulted. An unknown (name, version, revision) triple is an
authenticated 404.
The verifier’s checks
The hosted registry’s verifier is cabin-registry-verify, run by a GitHub
Actions workflow (operations live in the service runbook). It inspects each pending archive
against the canonical metadata the listing reported - parsing the zip container by hand,
metering every entry’s decompressed output against one archive-global budget, never extracting
the registry archive to disk, and assuming the archive is hostile. The archive must conform to the strict zip profile whose normative
definition is registry/docs/archive-format.md; this section is the user-facing summary. The
checks, in order:
- Size discipline: the sum of the entries’ declared uncompressed sizes is a cheap up-front
cap, and the running decompressed total is capped again as each entry is inflated, at
min(max(ratio x compressed size, floor), absolute cap)- the floor covers the container framing the entry cap permits, since framing alone “expands” small archives beyond any sane ratio. The entry count and per-entry path length are capped too, and crossing any cap aborts the inspection: a decompression bomb is rejected, never inflated. - Structure: the container must be a well-formed zip in the strict profile - the
end-of-central-directory record at the fixed
len - 22offset (single disk, zero comment, no zip64), the local records and the central directory tiling the file contiguously with no gaps, overlaps, or bytes outside the tiled regions, each entry stored or deflated with no data descriptors, extra fields, or comments and every general-purpose flag clear except the UTF-8 bit on non-ASCII names, and every local header agreeing with its central header. Each deflated entry must decompress to a clean stream end that consumes exactly its compressed span and yields exactly its declared uncompressed size, and its declared CRC-32 must match the bytes produced. Entries are regular files only (directory entries or attributes are rejected; directories are implied), with safe relative paths - no absolute paths, no.., no duplicates, no\, no empty or.component, and none of the Windows-hostile shapes the shared path predicate forbids (:, a control character,< > " | ? *, a leading or trailing space, a trailing dot, or a reserved device name) - no name colliding with another under case-insensitive folding, no regular file used as another entry’s parent directory, andcabin.tomlat the archive root. - Consistency: the embedded manifest is parsed with Cabin’s real manifest parser, must
pass the same publishability rules
cabin packageenforces (no[patch]table, no path dependencies, no escaping source paths, no standard contradictions), must have every target source it declares present in the archive (a package missing a declared source would extract but fail to build), and must reproduce the entire stored canonical metadata document through the same derivation publish used - name, version, the three dependency tables, language-standard fields and the per-target standards table, the per-target links claims, features, profiles, toolchain, build settings, the upstream provenance block, and the source block - and the archive bytes must hash to the recorded checksum (defense in depth; the server already checked at publish). - Upstream provenance, when the metadata declares a
[package.upstream]block (manifest.md). The workflow downloads the pinned upstream archive itself - the binary still performs no HTTP, and the privileged token is never sent to the publisher-controlled URL - and passes it to the verifier, which requires the bytes to hash to the pinned SHA-256, interprets the archive with the same hardened extraction foundation ports use (bomb caps, lexical path safety,strip-prefixmatching, symlinks skipped) in a scratch directory, applies the declared copy steps in order, then applies the declaredpatchesin order - byte-exact unified diffs, using the patch bytes shipped inside the published archive itself, so verification needs exactly two inputs: the pinned upstream archive and the published package - collects the resulting tree undercabin package’s include / exclude policy, and requires the published archive’s entries to match the expected tree byte for byte - except the rootcabin.toml, which is the publisher’s manifest, never upstream’s, and the declared patch files themselves, which are publisher-authored transformation inputs rather than products of the upstream tree (and are for that reason held to a strict diff-only grammar: free text around the diff would let one file be both a valid patch and valid source code, laundering unverified bytes past the comparison). When the upstream download fails - including a pinned archive over the 256 MiB download cap, which is part of the provenance contract (manifest.md), or when the run’s bounded upstream-download time budget is exhausted (versions are processed in a shuffled order, so slow upstream hosts cannot pin the same versions to the front of every pass) - the verifier still runs the structure and consistency passes (which can reject on their own); a version whose provenance could then not be checked stays pending, because a transport failure must not terminally reject a package (the stuck-pending alert summons an operator instead). Deterministic disagreements - a digest mismatch, an uninterpretable archive, an inapplicable copy step, a malformed or inapplicable patch, a diverging tree - are rejections with the stable codes below.
A rejection records machine-readable reason codes in the revision’s verification_reason:
| Code | Check |
|---|---|
decompressed_too_large | decompression cap crossed |
too_many_entries | entry-count cap crossed |
path_too_long | path-length cap crossed |
unsupported_zip_feature | a banned zip feature: a compression method other than store/deflate, a general-purpose flag outside the profile (any bit but the UTF-8 bit, which must be set exactly on non-ASCII names), a nonzero extra field, a comment, zip64, or a data descriptor |
header_mismatch | a local header disagrees with its central header, or a declared uncompressed size or CRC-32 disagrees with the decompressed bytes |
forbidden_entry_type | a non-regular entry: a symlink, a directory entry or attribute, or any other non-file type |
absolute_path | absolute entry path (POSIX or Windows-drive form) |
path_traversal | .. path component |
invalid_path | empty, non-UTF-8, \-bearing, or an empty/. component; a trailing-slash directory marker; or a Windows-hostile shape (:, a control character, `< > ” |
duplicate_path | the same path (byte for byte) twice |
case_conflict | two paths that fold to the same string under Unicode default lowercasing, including a file used as a case-folded parent directory |
path_conflict | a regular file used as another entry’s parent directory |
missing_source | the manifest declares a target source absent from the archive |
manifest_missing | no cabin.toml at the archive root |
manifest_invalid | the manifest does not parse as a publishable package |
name_mismatch | manifest name disagrees with metadata or the listing |
version_mismatch | manifest version disagrees with metadata or the listing |
dependency_mismatch | manifest dependency tables disagree with metadata |
language_standard_mismatch | manifest standard fields or the derived standards table disagree with metadata |
checksum_mismatch | archive bytes do not hash to the recorded checksum |
metadata_mismatch | any other canonical-metadata field disagrees with what the manifest derives |
archive_invalid | not a well-formed zip container: a bad or misplaced EOCD, a non-contiguous layout, or bytes outside the tiled regions |
upstream_mismatch | the stored upstream block disagrees with the manifest’s [package.upstream] declaration |
upstream_checksum_mismatch | the downloaded upstream archive does not hash to the pinned SHA-256 |
upstream_archive_invalid | the pinned upstream archive cannot be interpreted as declared: the hardened extractor refused it, its compressed stream would not decode (stream), an entry name cannot be materialized on the verifier’s filesystem (file name), the declared strip prefix is absent (strip prefix), or the extracted file set violates packaging rules (file set) |
upstream_copy_invalid | a declared copy step cannot be applied to the extracted upstream tree: its from file is absent (missing source), or its to cannot name a regular file there (destination) |
upstream_patch_invalid | a declared patch file cannot be applied to the assembled upstream tree: the published archive lacks the declared entry (missing file), the patch exceeds the 1 MiB per-file cap (too large), the patch path also names a file the upstream transformation produces (shadows tree), it is not strictly a unified diff - free text around the diff or inside git’s preamble lines is rejected along with structural corruption (malformed), it carries binary content (binary), a diff header path cannot address the tree (unsafe path), the patched file is absent (missing target), the patched or created path cannot name a regular file (target conflict), the patched file exceeds the 16 MiB per-target cap (target too large), the patches list carries more than 1024 file entries (too many file entries), applying the whole list would read or rewrite more than 128 MiB of the tree (work budget exceeded), or the tree’s bytes do not match the patch context exactly (context mismatch) |
upstream_tree_mismatch | the published tree is not the declared transformation of the upstream archive; the detail names the first divergence in sorted-path order, reporting missing and diverging files before unexplained extras: missing file, file contents, or extra file |
A recorded reason is the code above, optionally followed by one parenthesized detail that
narrows the cause - unsupported_zip_feature (zip64), header_mismatch (crc),
invalid_path (trailing dot). The machine-readable code is always the first token; the detail is
fixed text and never echoes archive bytes.
The cap mechanism is public contract; the cap values are configuration (VERIFY_RATIO_CAP,
VERIFY_ABS_CAP_BYTES, VERIFY_MAX_ENTRIES, VERIFY_MAX_PATH_LEN, defaulting to 10x,
256 MiB, 10000 entries, and 256 bytes). Verifier failures leave revisions pending - fail-safe:
broken verification infrastructure keeps content unexposed, never exposes it.
Name advisories. Before downloading anything, the workflow checks each version that would
introduce a new package name against the package corpus:
confusability under a skeleton fold (-/_ fold away, 1/i to l, 0 to o) against
every existing package and scope, edit distance 1 on the folded full name against other
scopes’ packages, and a short unambiguous-profanity list matched as folded substrings. A
finding never rejects - the workflow abstains (no verdict; the version stays pending)
and an operator reviews it, so a false positive costs a delay, never a rejection. Once any
version of a package is verified, later versions skip the advisories; a rejection never
vets a name. The rationale and
rules live in registry/docs/architecture.md, “Name fidelity”.
Server checks versus client extraction
These server-side checks agree in spirit with the client’s
extraction safety contract, and the two are
deliberately independent. The client’s rules are the ones that must hold: cabin extracts
archives from third-party registries and local file registries too, and must stay safe against a
hosted registry that has itself been compromised. Nothing on the client trusts this verifier.
The verifier is at least as strict as the client on every axis they share. It inspects only
archives cabin package produced, so it enforces the whole strict zip profile - rejecting the
zip64, extra-field, data-descriptor, and non-contiguous-layout constructions the client’s extractor
merely tolerates. It shares the client’s lexical path-portability predicate through cabin-fs, so
the two reject the same Windows-hostile shapes (\, :, control characters, < > " | ? *,
leading or trailing spaces, trailing dots, reserved device names) by construction rather than by
parallel maintenance.
It additionally rejects case-folded name collisions the client deliberately tolerates (they would
refuse archives legitimate on case-sensitive Linux). Its default caps (10x ratio, 256 MiB, 10000
entries, 256-byte paths) sit at or below the client’s (32x ratio, 1 GiB, 10000 entries, 256-byte
paths). A version that passes verification therefore clears the client’s extraction rules.
That the verifier is stricter does not fold the two into one. The caps above are configurable, so a registry operator cannot widen the client’s limits by widening their own - only the client’s constants do that. “Verified” therefore means the archive is safe to extract by the client’s own rules; it is never a promise the client may delegate its safety to a registry.
Yank
PATCH /api/v1/packages/<scope>/<name>/<version>/yank
Requires a token with the yank scope, on the same API origin as publish. The JSON body sets the
version’s yanked state in the per-package index document:
{ "yanked": true }
{"yanked": false} un-yanks. The route is idempotent: setting the state a version already has
succeeds with 200 and body {"ok":true}. Yank is version-level - it covers every packaging
revision of the version at once, and there is no per-revision yank. It applies to versions with at
least one verified revision - a version whose revisions are all
pending or rejected was never part of the registry’s resolvable surface, so there is nothing to
retract and it answers an authenticated 404.
Yanking from the client
cabin yank takes a strict <scope>/<name>@<version> spec - an exact scoped package name and an exact SemVer
version, no ranges - and resolves the registry exactly like remote publish: --index-url, else the
[registry] index-url setting in config.md - never the
default registry; a mutation must not target a registry the user did not
name. A local index-path is rejected, since yanked state lives in the remote registry’s index. The registry’s config.json
must declare the api origin the request is sent to.
$ cabin -Z remote-registry yank fmtlib/fmt@10.2.1 --index-url https://registry.cabinpkg.com
fmtlib/fmt@10.2.1 is now yanked
$ cabin -Z remote-registry yank --undo fmtlib/fmt@10.2.1 --index-url https://registry.cabinpkg.com
fmtlib/fmt@10.2.1 is no longer yanked
The report states the resulting state. Because the route is idempotent, that wording also
covers the no-op: yanking an already-yanked version succeeds and prints the same line. A 404
reports that the version is not published on this registry; 401 / 403 follow the
token conventions of the read path.
What yanking means - matching the resolver behavior in
package-index.md:
- A yanked version is excluded from new resolution:
cabin resolveskips it when picking candidates, and if every matching version is yanked, resolution fails. - Every revision’s artifact stays downloadable: existing lockfiles that already pin the yanked version keep building. Yanking never mutates or deletes an archive - published bytes stay immutable.
- Unpublish / delete is deliberately not offered: removing bytes other projects may already depend on breaks reproducible builds, so the strongest retraction is the yank flag.
Trusted publishing
Trusted publishing (the crates.io RFC 3691 model) lets a registered GitHub Actions workflow
publish without holding any long-lived secret: the workflow’s own OIDC token is exchanged for a
short-lived registry token. Which workflows may exchange is operator-side registry data - a
config binds a scope to a repository and workflow by their immutable numeric GitHub ids,
optionally pinning a git ref and an environment - not part of this protocol. The protocol
surface is the two routes below, on the api origin. The cabin
client runs this exchange automatically under GitHub
Actions; nothing below needs a manual caller.
Exchanging an Actions OIDC token
PUT /api/v1/trusted_publishing/tokens
One of the two Bearer-plane routes that take no Authorization header (the other is the
login-session mint): the credential is the GitHub Actions OIDC JWT itself,
requested by the workflow with audience cabinpkg.com and sent as the body:
{ "jwt": "<github actions oidc jwt>" }
The registry verifies the JWT statelessly first - RS256 signature against GitHub’s published
keys, issuer, audience, time claims with a small leeway, and the required claim set - then
routes the verified claims through one of two arms. Claims matching the registry’s
deployment-pinned verifier identity (the same pins the verdict route of the verification
lifecycle enforces) mint a verify-scoped token for the verification
pipeline itself. Every other claim set is matched against the registered configs: the workflow
filename extracted from workflow_ref must equal the config’s, and where the config pins a git
ref or an environment the corresponding claim must equal it. The JWT’s jti is accepted
exactly once, whichever arm it takes; replaying a token whose exchange already succeeded
answers 401 however valid it still is.
Success is 200:
{ "token": "cabin_tp_<base64url>", "expires_at": "<RFC 3339>" }
The plaintext is rendered exactly once, in this response. The minted token is a bearer token
like any other, multi-use within its 30-minute lifetime (one ports run publishes its whole
batch on one token). The config arm’s token is publish-scoped, confined to the config’s
scope, and carries the config’s quota class; the verifier arm’s is verify-scoped with
neither confinement nor a class of its own. Every exchange failure - a malformed body, a bad
signature, an unknown repository, a mismatched workflow, ref, or environment, a replayed
jti - answers the byte-identical uniform 401, the
challenge included, so the route is no oracle for what is
registered. The config arm is gated by the registry’s budget breaker like every other write
(503 + Retry-After when tripped, once the JWT has verified); the verifier arm is
deliberately exempt, like the verdict route - the verification pipeline must be able to drain
the pending queue whatever the service mode.
Revoking the exchanged token
DELETE /api/v1/trusted_publishing/tokens
Authorized by the exchanged token itself (Authorization: Bearer cabin_tp_...): the token row
is deleted and the answer is 204. Anything else - a token of any other kind, an unknown,
expired, or already-revoked one - answers the uniform 401, which also makes a repeated
DELETE idempotent. Workflows should revoke when their run completes rather than letting the
lifetime lapse.
Login sessions
A login session is the human counterpart of a trusted-publishing
exchange: a GitHub access token is traded for a short-lived registry token. The two routes below live on the
api origin and dispatch exactly like the exchange routes.
Minting a session token
PUT /api/v1/sessions/tokens
Like the exchange, the route takes no Authorization header: the credential is the GitHub
access token in the body:
{ "github_token": "<github access token>" }
The registry proves the identity with one call to GitHub’s check-token endpoint, authenticated with its own OAuth client credentials, and resolves the numeric GitHub id exactly as the web sign-in does - the allowlist gate included - except that no account is ever created: minting is for accounts that already exist, so an unknown or unadmitted identity refuses. Check-token also pins the credential to the registry’s own OAuth app: a token issued by any other OAuth app - even one the same account authorized - or a personal access token proves nothing and refuses, so a third party’s GitHub grant can never become a registry login. The presented GitHub access token is used for that single call and dropped: never stored, never logged.
The same pinning bounds cabin login itself: the CLI’s device flow requests its grants under
the hosted registry’s OAuth app, so a mint pinned to a different app refuses them. The CLI
enforces the boundary up front, because the grant is itself a credential: cabin login sends
it only to the hosted registry’s API, or to a loopback API declared by a loopback registry,
and refuses any other index before the device flow starts - a malicious registry could
otherwise relay the grant to the hosted mint and trade it for the user’s session.
Registry-supplied client-id discovery is deliberately left to a future revision of
config.json.
Success is 200:
{ "token": "cabin_ses_<base64url>", "expires_at": "<RFC 3339, UTC>" }
expires_at is UTC RFC 3339 (the hosted registry mints the millisecond Z form); the client
refuses a non-UTC stamp - or any non-timestamp - before storing or printing it, so a registry
cannot smuggle arbitrary text through the field. The plaintext is rendered exactly once, in
this response. The minted token is a bearer token
like any other, multi-use within its 12-hour lifetime, carrying publish and yank - plus
verify when the account is the operator’s own - at the account’s own quota class. Every mint failure - a
malformed body, a rejected or unreadable GitHub lookup, an unknown or unadmitted identity -
answers the byte-identical uniform 401, the challenge included.
The mint sits behind the same per-client admission control as the trusted-publishing exchange
(429 with code rate_limited and Retry-After, decided before the body is read) and behind the
registry’s budget breaker like every other write (503 + Retry-After when tripped); both answer
first, before the GitHub lookup spends anything.
Revoking a session token
DELETE /api/v1/sessions/tokens
Authorized by the session token itself (Authorization: Bearer cabin_ses_...): the token row
is deleted and the answer is 204. Anything else answers the uniform 401, which also makes
a repeated DELETE idempotent - the same contract as the
trusted-publishing revocation.
Status codes
| Code | Meaning |
|---|---|
200 | Success that created no registry content: an idempotent no-op (byte-identical re-publish, or a yank set to the state the version already has), a granted trusted-publishing exchange (which consumes the JWT’s jti and mints its token, but publishes nothing), or a granted login-session mint. |
201 | Publish that created a packaging revision - a first publish of the version, an opted-in respin, or the revival of a rejected revision. |
204 | A trusted-publishing or session revocation that deleted the presented token. |
400 | Malformed request: bad framing, invalid metadata, a version carrying build metadata, an invalid JSON body, or a new-revision query value other than true. |
401 | An invalid token, or a missing token on a surface that requires one - the mutation routes, and every non-read-plane path of the index host (never reveals whether anything exists there) - and every trusted-publishing exchange, login-session mint, or revocation failure. Carries the login-URL challenge. |
403 | Valid token, but the scope the route requires is missing - or a per-user quota refusal, distinguished by the envelope’s code field. |
404 | Request for an unknown package, version, or revision - including revisions that are not verified, which are indistinguishable from unknown ones without the verify scope. |
409 | Publish of different bytes for a version with a live revision and no new-revision opt-in; a revision-id collision between two different archives; or a conflicting verdict. |
413 | The uploaded archive exceeds the per-archive size limit (envelope code archive_too_large). |
429 | A rate limit: the publish token bucket is empty, or the per-client admission budget of the trusted-publishing exchange, the verdict route, and the login-session mint is spent (both code rate_limited); or the caller’s daily allowance of registry-side archive reads is spent (code read_rate_limited). Carries Retry-After (seconds) saying when the limit resets. |
503 | The registry is protecting its own infrastructure budget (the hosted service blocks itself before provider limits or real spend are reached): its service-wide breaker tripped, or its per-request cost governor refused - or could not be reached - for the specific resource the request needed. Writes refuse first; archive downloads refuse when the registry’s read allowance for fresh storage reads is exhausted (recently downloaded archives can keep serving from the registry’s edge cache through that), or service-wide when the registry’s operator has paused reads outright. Carries Retry-After (seconds) and the envelope code registry_over_budget. The refusal is operator-side and temporary, so it is a 503 rather than a 402: nothing the caller can pay clears it, and 503 has explicit Retry-After semantics where 402 has none. |
Error envelope
Every non-2xx response carries the same JSON envelope:
{ "errors": [ { "detail": "authentication required" } ] }
Quota, rate-limit, and budget refusals additionally carry a machine-readable code field:
{ "errors": [ { "detail": "total package quota exhausted", "code": "quota_packages_total" } ] }
Clients must ignore unknown fields in the envelope; errors without a code stay exactly as
before. The defined codes:
code | Status | Meaning |
|---|---|---|
rate_limited | 429 | The publish token bucket is empty, or the per-client admission budget of the exchange, verdict, and login-session mint routes is spent; Retry-After says when it refills. |
read_rate_limited | 429 | The caller’s daily allowance of registry-side archive reads is spent; Retry-After reaches the next UTC day. Cached downloads are unaffected. |
archive_too_large | 413 | The archive exceeds the per-archive size limit. |
quota_storage | 403 | The publish would exceed the total stored-bytes quota. |
quota_packages_daily | 403 | The daily new-package quota is exhausted. |
quota_packages_total | 403 | The total package quota is exhausted. |
quota_versions_daily | 403 | The daily per-package version quota is exhausted. |
registry_over_budget | 503 | The registry’s budget protection refused the request: the service-wide breaker has the plane paused, or the per-request cost governor refused (or could not answer for) the specific resource the request needed; Retry-After covers the next re-evaluation. |
Cabin maps these refusals to actionable messages. For the breaker it keys on the
registry_over_budget code, not on the 503 alone - a 503 can also come from the hosting
platform, and an outage must not be reported as a budget refusal; a 503 without the code stays
a plain server error. A coded 503 on the mutation routes reports the registry as temporarily
not accepting publishes (over its free budget), a coded 503 on the read
routes reports package downloads and index reads as temporarily disabled to stay within the
registry’s infrastructure budget, and the 429 reports the rate limit - each echoing
Retry-After as a “try again in N seconds” hint when the header is usable; the 413 reports the
archive as too large, appending the server’s detail; and a 403 whose code starts with
quota_ surfaces the server’s detail verbatim - the detail itself embeds the registry’s
usage-dashboard URL (https://cabinpkg.com/dashboard on the hosted registry), so the client
never derives a web URL from the index origin. Unknown codes fall back to the plain detail
string.