Config options
| Option | Default | Meaning |
|---|---|---|
basePath | /artifacts | Where the repository routes mount. Changing it changes every address this capability serves |
tokenTtlSeconds | 900 | How long a minted git token lives, 60 to 3600. This is the revocation window. Above 3600 is refused at boot, naming the ceiling |
managePower | none | The organization power that lets a member manage the organization’s repositories, e.g. repos:manage. Required when organization is composed, and refused at boot when your role catalog does not declare it. Ignored without organization |
acceptRepoName | false | Let a create request supply the physical name. Off, every name is a random UUID. On, uniqueness is yours |
binding | ARTIFACTS | The Worker env binding the namespace is bound as |
One seam rides beside them: repoName, a function of the owner and the display name that returns the physical name. With it, uniqueness is yours. A collision is artifacts/name_taken, and a name outside the rule is artifacts/invalid_name.
The floor is Cloudflare’s, the ceiling is the kit’s. Cloudflare refuses a token that lives under a minute. The hour is the kit’s judgment: removing a grant does not revoke a token already minted, so a long life is not a tuned timeout, it is revocation turned off.
Routes
Using Artifacts has the calls. Every route but the five under /admin/ is bearer · session, and denies without auth. The five are control-plane, and each takes the scope named in its row.
| Route | Who | Purpose |
|---|---|---|
POST /artifacts/repos | any signed-in person | Create a repository they own |
GET /artifacts/repos | any signed-in person | What they own and what they hold a grant on. Never a listing of the namespace |
GET /artifacts/repos/:name | owner or grant | The row, and what Cloudflare holds. No remote with remote access off |
DELETE /artifacts/repos/:name | owner | Delete the repository, every token on it and every grant |
POST /artifacts/repos/:name/grants | owner | Give a person read or write, or change what they hold |
DELETE /artifacts/repos/:name/grants/:userId | owner | Take a grant away |
POST /artifacts/repos/:name/credentials | owner or grant | Mint a token at the caller’s scope. 404 with remote access off |
DELETE /artifacts/repos/:name/credentials | owner | The break-glass. Revoke every live token, every recorded one by its id and then from the listing |
GET /artifacts/repos/:name/log | owner or grant | History, newest first. ref, limit (at most 1000, default 50), offset |
GET /artifacts/repos/:name/commits/:hash | owner or grant | One commit |
GET /artifacts/repos/:name/tree/:hash | owner or grant | One tree’s immediate children |
GET /artifacts/repos/:name/blob/:hash | owner or grant | One blob’s bytes |
GET /artifacts/repos/:name/file | owner or grant | One file at a ref, by path |
POST /artifacts/organization/repos | managePower in the acting organization | Create a repository the organization owns |
GET /artifacts/organization/repos | managePower in the acting organization | The organization’s repositories |
GET /artifacts/admin/repos | control-plane · artifacts:repos:read | Every repository, newest first, with its owner, remote access and last push. remoteAccess (true or false), ownerKind (org or user), ownerId, which needs ownerKind, cursor, limit |
GET /artifacts/admin/repos/:name | control-plane · artifacts:repos:read | One repository, its grants, and its commit log. limit, offset |
GET /artifacts/admin/repos/:name/tokens | control-plane · artifacts:repos:read | Cloudflare’s one page of live tokens — 30, newest first, with the total beside it — with scope, created, expiry, minted and the holder where the kit recorded one |
POST /artifacts/admin/repos/:name/tokens/:tokenId/revoke | control-plane · artifacts:tokens:revoke | Revoke one token. idempotencyKey and reason required |
POST /artifacts/admin/repos/:name/tokens/revoke | control-plane · artifacts:tokens:revoke | Revoke every live token, past the one page. idempotencyKey and reason required |
The organization routes mount only where organization is composed. Without it, every repository is a person’s.
The /admin/ routes are a management client’s, reached only through the control plane. minted is pithy for a token the kit recorded, with its holderId; outside for one it has no record of, never given a holder; and unknown for one created before recording began, which the kit may have minted. The listing holds live tokens only, so every token on it is active. A revoke replayed under a spent key answers what the first did, replayed: true, and revokes nothing. The same key for a different revocation is core/conflict, 409. A token the kit minted that is already revoked or expired answers revoked: 0; any other token that is not live is core/not_found, like one the repository does not hold. No scope creates, forks or deletes a repository.
An owner is the person who owns the repository, or a member acting for the owning organization whose role holds managePower. A grant is a row: read or write. An owner implies write. No ownership and no grant is no access at all.
The token’s scope is the caller’s access. Owner and write mint write. read mints read. Nothing else decides it, so a read grant can never obtain a write token.
The credential response
{
"token": "art_v1_…?expires=1792425600",
"scope": "write",
"expiresAt": "2026-10-19T17:00:00.000Z",
"remote": "https://<ACCOUNT_ID>.artifacts.cloudflare.net/git/acme-prod/<name>.git"
}It answers 201 with Cache-Control: no-store. The token is sent as Authorization: Bearer, or as the HTTP Basic password with any username. The kit never stores it, and no log line, error body or audit row carries it.
Error codes
| Code | Status | Meaning |
|---|---|---|
artifacts/not_found | 404 | No such repository, or one you neither own nor hold a grant on. Also the mint route for a repository with remote access off |
artifacts/forbidden | 403 | A repository you can see and may not do this to. Or no organization in force, or a role without managePower, on the organization routes |
artifacts/invalid_name | 400 | A supplied physical name outside the charset or the cap. Or a name sent to a Worker without acceptRepoName |
artifacts/name_taken | 409 | The owner already holds that display name, or a supplied physical name is already in the namespace |
artifacts/invalid_config | 400 | Raised at boot: tokenTtlSeconds out of range, or organization composed with no managePower, or one the catalog does not declare |
core/not_found | 404 | A commit, tree, blob or file that is not in a repository you can see |
The first one is byte-identical, whichever it was. The physical name is in the clone URL, and a refusal that told the two apart would hand it over. Which it was survives in detail, which never crosses the wire.
Response hardening
A repository’s bytes are whatever somebody pushed, served from your own origin. So the blob and file routes answer with:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
Content-Security-Policy | default-src 'none'; sandbox |
Content-Disposition | attachment |
A blob is application/octet-stream. A file carries the type Cloudflare resolves for it.
Bindings
| Binding | Type | Written by |
|---|---|---|
DB | d1 | pithy add |
ARTIFACTS | artifacts | pithy add at the top level, pithy provision in every other stanza |
The entry is { "binding": "ARTIFACTS", "namespace": "<project>-<env>" }. A feature’s namespace is <project>-f<issue>-<slug>--artifacts. Nothing creates it. Cloudflare makes a namespace on its first repository.
Tables
pithy_artifacts_repos · pithy_artifacts_grants · pithy_artifacts_tokens · pithy_artifacts_revocations
| Table | Key | Holds |
|---|---|---|
pithy_artifacts_repos | the physical name | Display name, owner kind (org or user), owner id, remote access, timestamps. UNIQUE (owner_kind, owner_id, display_name) |
pithy_artifacts_grants | (repo, user_id) | One grant per person per repository, read or write, timestamps |
pithy_artifacts_tokens | the token id Cloudflare assigns | One record per token the kit minted: the repository, the user it was minted for, scope, whether it came from a mint or a create, created and expiry. No column for the token itself |
pithy_artifacts_revocations | (connection_id, idempotency_key) | One spent revocation key per management connection: what it revoked, the reason, and how many |
A token record names who a token was minted for, which Cloudflare’s own token metadata never does. It is the kit’s note, never the credential: there is nowhere to write the plaintext, which Cloudflare returns once. A record outlives its token by thirty days, then is pruned. The migration that creates the table also writes one marker row, the moment recording began, so a token older than that reads as unknown rather than as minted outside Pithy.
A purge deletes every repository the person owns, with its contents and every grant on it, and every grant they hold on somebody else’s. Their token records are anonymized instead: the user id becomes an erased- stand-in, one per record, so a token still listed reads as the kit’s. The export hands back their repositories, grants, and token records under mints. A repository an organization owns is the organization’s.
A grant is decoded on read. A value that is not read or write is refused as no access, never asserted.
Audit actions
Recorded through audit when it is composed, and nowhere when it is not.
| Action | When |
|---|---|
artifacts/repo_created | A repository was created |
artifacts/repo_deleted | A repository was deleted, with every token and grant |
artifacts/grant_set | A grant was made or changed |
artifacts/grant_removed | A grant was taken away |
artifacts/token_minted | A token was minted. Its id, scope and expiry, never its value |
artifacts/tokens_revoked | Every live token was revoked: by the owner, or by a management client with a reason. Severity warning |
artifacts/token_revoked | A management client revoked one token, with a reason. Severity warning |
An organization’s repository records against that organization as the tenant.
Naming
A repository has two names. The physical name is in the namespace and the clone URL. It defaults to crypto.randomUUID(), which cannot collide and cannot be guessed. The display name is a column: free text, at most 100 characters, unique per owner and nowhere else.
A supplied physical name is a letter or digit, then letters, digits, ., _ and -, 2 to 63 characters. It is refused, never shortened. The naming rules have the namespace’s own row.
Peer capabilities
Required: nothing.
Optional: auth, effectively required, since every route denies without it. organization, so a repository may be an organization’s, and managePower becomes required. audit, which records the seven actions above.
What is not here
No fork() or import(). The binding offers both, and neither is needed to hand somebody a repository.
No mapping of your roles onto read and write. That is a grant row you write.
No storage metering. GET /artifacts/repos/:name reports what Cloudflare holds. The limits are 1 GB a repository and 1 TB an account.