0005: Serve Xet-native clients from the LFS-scoped token route
- Status: accepted
- Date: 2026-10-23
- Scope:
hub,git,auth,client - Amended by: ADR 0003 (item 10: downloads answer
basic; capabilityhrefdetails), from spike S7
Context
Design §6.1 serves git-xet through the Git LFS Batch API. A third-party
Xet-native client, jj-xet (Jujutsu with Xet-backed large files), chunks and
uploads before it knows any LFS oid, so it cannot start from a batch request.
It was implemented against the contract below, which openxet's maintainers
committed to on 2026-10-23. This ADR makes the contract binding, reviewable
and testable.
The token response shape is identical to Hugging Face's Hub token route.
That was checked against the Xet spec's "auth" page, the huggingface.co
token exchanges captured by spike S3 (compat/fixtures/hub/), and
hf_xet's CasJWTInfo (xet_client/src/hub_client/types.rs at xet-core
v1.7.0). The batch header names X-Xet-Cas-Url, X-Xet-Access-Token,
X-Xet-Token-Expiration and X-Xet-Session-Id were read from
git_xet/src/constants.rs at xet-core v1.7.0. X-Xet-Hash is not in
that file; it is the resolve response header, observed in the S1 and S3
captures.
Decision
-
Route. Xet-native clients get CAS credentials from the LFS-scoped token route:
GET {lfs-url}/xet-read-tokenGET {lfs-url}/xet-write-token
{lfs-url}is exactly whatlfs.urlpoints at, for examplehttps://host/{ns}/{repo}.git/info/lfs. -
Request.
- Header
Accept: application/json. - Auth is HTTP Basic: the username is ignored and the password is an
openxet access token, which is git-credential-helper compatible.
Authorization: Bearer <access token>is also accepted. - A batch capability
href(see 10) needs no auth header.
- Header
-
200 response body:
{"accessToken": "<opaque>", "exp": <unix SECONDS>, "casUrl": "<absolute https URL>"}. The same values are also returned as headersX-Xet-Cas-Url,X-Xet-Access-TokenandX-Xet-Token-Expiration(seconds). -
Refresh. Repeat the same GET; the response is the same. Clients refresh about 30 s before
exp, as xet-core does. -
Errors. 401 for a bad or missing credential, 403 for no read/write on this scope, 404 for an unknown repo. The body is
{"schema":"openxet.error.v1","code","message","hint"}. -
Token scope. CAS tokens are scoped to (scope, read|write), never to objects. Clients may chunk and upload before they know oids.
-
Registration. A successful shard upload registers each file's xet hash to the scope. The sha256 in the shard's file-metadata extension registers sha256 → xet-hash for that scope, with no extra call (no LFS verify). Files without the sha256 extension register but can't be served to plain git-lfs by oid.
-
Downloads. A read token allows
/v2/reconstructions/{xet-hash}(and v1) for any file registered to the scope, until GC collects it. That includes files only force-pushed-away commits referenced (ruling R25, design §5.4). For Xet-native clients (token route) there is no per-object handshake. git-lfs/git-xet clients get the hash from the batch response (item 10). Spike S7 found thatgit-xet0.2.1 does not download at all, and huggingface.co sends noX-Xet-Hash(ADR 0003). -
Pointers. Pointers are the canonical 3-key git-lfs pointer (
version/oid/size). openxet tolerates and preserves unknown keys, per the LFS spec, but never writes them. The LFS spec requires exactly one valid encoding; extra keys change Git blob ids and break mixed-tool repos. openxet's own client uses the same format. -
Batch details (
POST {lfs-url}/objects/batch, transfers["xet","basic"]; amended by ADR 0003: uploads answerxet, downloads always answerbasic).- JSON keys in
actions.*.headeruse exactly this casing:X-Xet-Hash,X-Xet-Cas-Url,X-Xet-Access-Token,X-Xet-Token-Expiration,X-Xet-Session-Id. - The
basicdownload action'sheadercarries onlyX-Xet-Hash, which is not a secret. It has no CAS access token, CAS URL or expiration. Download agents get those fromGET {lfs-url}/xet-read-token(item 1). This keeps the token out ofGIT_TRACEoutput and out of reach of an off-host redirect of the downloadhref. - The upload action's
hrefis absolute: a signed capability URL for one scope and one operation. A GET on it returns the token-route response with no Authorization header, until its own expiry, which is at least as long as the CAS token it returns. Authorization headers sent to it are ignored and never logged. - The download action's
hrefis the HTTP read gateway URL for the object (absolute), not a token route. - No action
headeror response ever carries the client's own credential (§5.9 req 6).
- JSON keys in
-
Dev mode.
openxet servewith no config runs SQLite plus local disk, prints a static access token and the env/URL lines, and supports--jsonstartup info. Port and flags are not final.
Consequences
- Chunk-first clients work without knowing oids. Implication: they need no per-object round trips.
- Token scope is coarse (scope + mode). Implication: per-object restriction is not available, so the authorizer decision happens at token issue.
- Shard upload registers files (item 7). Implication: it changes index state, so it must be verified before registering (S6) and idempotent on retry.
- Header-key casing and the pointer encoding are compatibility commitments. Implication: changing either needs a superseding ADR.
- Tests, built in M1/M2, enforce this:
- A compatibility-suite "third-party Xet client" leg, whose expectations
come from jj-xet's
tests/openxet.rsmock. - Exact header-key casing assertions on batch responses.
- The pointer canonical-form test.
- Capability
hrefrefresh without an auth header.
- A compatibility-suite "third-party Xet client" leg, whose expectations
come from jj-xet's
Alternatives considered
- Batch endpoint only. Rejected: it requires oids before upload, which doesn't work for chunk-first clients.
- Hub token route only. Rejected: it doesn't exist for repos hosted on other Git hosts, which design §6.1 and §10.2 target.