Documentation
¶
Overview ¶
Package api talks to the krowk artifact registry.
Uploading is three calls, because the bytes never pass through the registry:
- POST /v1/artifacts declare the file, get a presigned PUT
- PUT <presigned url> bytes go straight to object storage
- PUT /v1/artifacts/{slug}/finalization the registry verifies what landed
Only the first and third are ours; the second is object storage, signed for exactly the size, content type and checksum declared in step one. That is why the size and digest are computed up front rather than discovered while streaming: they are part of what gets signed, so they have to be known before the first call is made.
A fourth call recovers step two rather than repeating step one:
POST /v1/artifacts/{slug}/upload mint the presigned PUT again
A signature is good for 15 minutes and the artifact waits far longer, so an upload can reach storage with one that has already lapsed. Declaring the file again would work and is the wrong answer — a second declare is a second slug, and the first link is already pasted somewhere.
The registry's API is resourceful all the way down, so what would be a verb hanging off an artifact is a nested resource instead — the finalization of an artifact, the claim on one, the run it belongs to, the completion of a run. The verb follows from whether the call can be repeated: finalizing, completing and setting an artifact's run are idempotent, so they are PUTs; claiming spends a one-shot token, so it is a POST. Checking a key is the same rule read the other way — GET /v1/key names the key this request is made with, because asking what it may do changes nothing about it.
Index ¶
- Constants
- func BaseURLFor(dev bool, env func(string) string) string
- func ContentType(path string) string
- func CredentialsPath() string
- func KeyRejected(err error) bool
- func ReadToken(env func(string) string) string
- func RetryAfterFor(err error, now time.Time) (time.Duration, bool)
- func SaveCredentials(token string, id Identity) (string, error)
- func TokenSource(env func(string) string) string
- func Truthy(v string) bool
- type Artifact
- type ArtifactRun
- type CLIAuthorization
- type Client
- func (c *Client) AttachRun(ctx context.Context, artifactSlug, runSlug string) (*Artifact, error)
- func (c *Client) Authenticated() bool
- func (c *Client) ClaimArtifact(ctx context.Context, slug, claimToken string) (*Artifact, error)
- func (c *Client) CreateRun(ctx context.Context, metadata any) (*Run, error)
- func (c *Client) FinalizeArtifact(ctx context.Context, slug string) (*Artifact, error)
- func (c *Client) FinishRun(ctx context.Context, slug string) (*Run, error)
- func (c *Client) Insecure() bool
- func (c *Client) ListArtifacts(ctx context.Context, before string, limit int) (*Page, error)
- func (c *Client) ListRunArtifacts(ctx context.Context, runSlug, before string, limit int) (*Page, error)
- func (c *Client) ListRuns(ctx context.Context, before string, limit int) (*RunPage, error)
- func (c *Client) PrepareArtifact(ctx context.Context, spec Spec) (*Artifact, error)
- func (c *Client) PresignUpload(ctx context.Context, slug, claimToken string) (*Artifact, error)
- func (c *Client) Push(ctx context.Context, spec Spec) (*Artifact, error)
- func (c *Client) PutBytes(ctx context.Context, prepared *Artifact, spec Spec) error
- func (c *Client) ReadCLIAuthorization(ctx context.Context, slug string) (*CLIAuthorization, error)
- func (c *Client) Root(ctx context.Context) (*Service, error)
- func (c *Client) ShowArtifact(ctx context.Context, slug string) (*Artifact, error)
- func (c *Client) ShowRun(ctx context.Context, slug string) (*Run, error)
- func (c *Client) StartCLIAuthorization(ctx context.Context) (*CLIAuthorization, error)
- func (c *Client) TakeDownArtifact(ctx context.Context, slug, claimToken string) error
- func (c *Client) VerifyKey(ctx context.Context) (*Key, error)
- type Error
- type Identity
- type Key
- type Page
- type Run
- type RunPage
- type Service
- type Spec
- type Upload
Constants ¶
const ( // DefaultBaseURL is overridden by KROWK_API_URL. DefaultBaseURL = "https://api.krowk.com/v1" // DevBaseURL is where `krowk registry serve` listens, and what --dev points // at, so testing against a local registry needs no environment plumbing. DevBaseURL = "http://localhost:8787/v1" )
const ( AuthorizationPending = "pending" AuthorizationApproved = "approved" AuthorizationDenied = "denied" )
The states an authorization reports while it still exists. Spent and expired are not among them: both are gone, and the registry says so with a 410 rather than with a body, the way it does for an artifact that has lapsed.
const ( TokenSourceEnv = "KROWK_TOKEN" TokenSourceFile = "credentials file" TokenSourceNone = "none" )
Where ReadToken would take its token from, as reported by TokenSource.
Variables ¶
This section is empty.
Functions ¶
func BaseURLFor ¶
BaseURLFor picks which registry to talk to. dev is an explicit request — a command-line flag — so it wins over an ambient environment variable; KROWK_API_URL then beats KROWK_DEV because it names a specific target.
func ContentType ¶
ContentType guesses from the extension, without the charset parameter: the type is signed into the upload URL and stored on the artifact, so the shortest accurate string is the one worth committing to.
func CredentialsPath ¶
func CredentialsPath() string
CredentialsPath is ~/.config/krowk/credentials.json, XDG_CONFIG_HOME honoured. Not os.UserConfigDir: that is ~/Library/Application Support on macOS, and the CLI documents one path on every platform.
func KeyRejected ¶
KeyRejected reports whether an error from VerifyKey is the registry saying it will not accept this key, as opposed to not managing to answer at all.
The distinction is the whole of `auth login`'s judgement. A 401 is a verdict on the key itself and there is nothing to retry — the token is wrong now and will be wrong later. Anything else, from no network to a 503 to something that is not a registry answering, is a verdict on the moment, and the key may well be fine. Only the first is grounds for refusing to store it.
func RetryAfterFor ¶
RetryAfterFor reads the wait a failure asked for, when it asked for one.
It exists for a caller pacing a loop of its own — `auth login` polling an authorization — which would otherwise come straight back after its own interval having just been told to slow down. Parsed and capped exactly as the retry loop inside a single call parses it, so there is one policy about that header rather than two.
func SaveCredentials ¶
SaveCredentials writes the token and what the registry said about it, owner-only, and returns where it landed.
The write is atomic — a temporary file in the same directory, then a rename — so a crash or a full disk partway through leaves the previous credentials intact. A half-written file reads as "not logged in", and losing a working key to a failed write of that same key would be its own bug.
The identity is written exactly as given, including empty. Storing a key the registry could not confirm has to clear whatever the last key recorded, or the file would keep naming a workspace that belongs to a token no longer in it.
func TokenSource ¶
TokenSource names where ReadToken just got its token, so diagnostics can say which of the two a surprising key came from. Answering "no key" is a source too — it is the difference between anonymous by choice and a login that never landed.
Types ¶
type Artifact ¶
type Artifact struct {
Slug string `json:"slug"`
State string `json:"state"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ByteSize int64 `json:"byte_size"`
Checksum string `json:"checksum,omitempty"`
Region string `json:"region,omitempty"`
Run *ArtifactRun `json:"run,omitempty"`
// URL is the card page, krowk.com/a/{slug}: the link to paste. It is what
// unfurls into a preview card, and it stays a link to the artifact rather
// than to whatever object storage happens to be serving the bytes.
URL string `json:"url"`
// FileURL is the public byte URL on the CDN — what `url` used to be. Only
// an image embed should be built from it, because a paste destination
// renders an image only where the link resolves to image bytes; everything
// a person or an agent is handed points at the card instead.
FileURL string `json:"file_url,omitempty"`
Markdown string `json:"markdown,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
// Upload and NextStep only ever appear on the create response.
Upload *Upload `json:"upload,omitempty"`
NextStep string `json:"next_step,omitempty"`
// ClaimToken is shown exactly once, by the call that created an anonymous
// artifact. It is the only way to keep that artifact past its expiry, so it
// is carried through to the output rather than dropped here.
ClaimToken string `json:"claim_token,omitempty"`
}
Artifact is one stored file, as the registry reports it.
type ArtifactRun ¶
type ArtifactRun struct {
Slug string `json:"slug"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
ArtifactRun is the run an artifact belongs to, as the artifact reports it: a nested object, not the bare slug it used to be. The run's metadata comes along, so a read that wants to know what produced the file needs no second call.
A pointer on Artifact, because belonging to no run is a real state — a keyless upload never has one — and null is how the registry says it.
type CLIAuthorization ¶
type CLIAuthorization struct {
Slug string `json:"slug"`
State string `json:"state"`
// Code is the short pair a person matches against the terminal, so an
// authorization opened by something else cannot be approved by mistake. Its
// alphabet leaves out 0/O and 1/I, which is the pair a read-aloud code gets
// wrong.
Code string `json:"code,omitempty"`
// VerificationURL is the page that shows the code and mints the key. It
// arrives in a response body and is about to be handed to the desktop's URL
// handler, so the CLI judges it before opening it.
VerificationURL string `json:"verification_url,omitempty"`
// Interval is how many seconds to wait between polls. The registry sets the
// pace, because it is the side that knows what its rate limit allows.
Interval int `json:"interval,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
// Token, KeyID and Workspace are the key itself, and appear on the one read
// that finds the authorization approved. The plaintext is gone from the
// registry after that read, so a second one answers 410 `spent`.
Token string `json:"token,omitempty"`
KeyID string `json:"key_id,omitempty"`
Workspace string `json:"workspace,omitempty"`
}
CLIAuthorization is one browser login in progress: the CLI asks the registry for one, a person approves it on the app surface, and the key that approval mints is collected from here.
Two capabilities, deliberately split. Slug is what collects the key and is never shown in the browser; Code is what a person reads and can only approve or deny. Knowing the code must therefore never yield the key — which is why the poll is by slug and the page is by code.
type Client ¶
type Client struct {
BaseURL string
Token string
HTTP *http.Client
// Sleep is swapped out in tests so backoff does not cost wall clock.
Sleep func(time.Duration)
}
Client is safe for a single CLI invocation; it holds no state between calls.
func (*Client) AttachRun ¶
AttachRun puts an artifact under a run after it was uploaded, which is the only way an upload that started out anonymous ever gets one: a keyless upload cannot name a run at create time, and claiming it does not give it one.
A PUT for the same reason finalizing is one: the artifact ends up under the same run however many times it is asked for, so a retry is a success rather than an error. An artifact belongs to one run, so naming a different one moves it. Both slugs resolve in the key's workspace, so an artifact has to be claimed before it can be attached.
func (*Client) Authenticated ¶
Authenticated reports whether calls will carry a key. Runs — and so all run metadata — are only available to a keyed client.
func (*Client) ClaimArtifact ¶
ClaimArtifact spends a claim token to move an anonymous artifact into the key's workspace, where it stops expiring.
func (*Client) CreateRun ¶
CreateRun opens a run to hang artifacts off. Needs a key: a run belongs to a workspace, and a keyless upload has none.
Retried, now that an Idempotency-Key makes it safe to. It used to be sent once and only once: a run committed under a lost response would be duplicated by the retry, an orphan whose slug never surfaces anywhere, and that was worse than failing the push outright. The key removes the choice — the retry is answered with the run the first attempt opened — so a transient 502 on the way to opening a run no longer costs the whole upload.
func (*Client) FinalizeArtifact ¶
FinalizeArtifact confirms the upload landed. A PUT because it is idempotent: the artifact ends up in the same state however many times it is asked for, so a retry is a success rather than an error.
func (*Client) FinishRun ¶
FinishRun closes a run. A PUT for the same reason finalizing is one: a CI cleanup step that runs twice should get the same success both times, and the run keeps the moment it first finished.
func (*Client) Insecure ¶
Insecure reports whether this registry is reached over plaintext http, which is something the caller chose: a local registry, or a self-hosted one inside a private network.
It lives here rather than wherever the question gets asked, because every other judgement about the base URL — whether it is local, whether a redirect stayed on its origin, which schemes a storage host may use — is made here too, against this same field. A second parse somewhere else is a second home for the same policy.
func (*Client) ListArtifacts ¶
ListArtifacts reads a page of the key's workspace. Unlike the rest of the artifact endpoints this one needs a key: keyless requests all share the anonymous workspace, so listing it would show everyone's uploads.
before is the cursor from a previous page's Next; limit is clamped by the registry rather than here, so asking for more than it serves gets the most it serves.
func (*Client) ListRunArtifacts ¶
func (c *Client) ListRunArtifacts(ctx context.Context, runSlug, before string, limit int) (*Page, error)
ListRunArtifacts reads a page of what one run produced.
A collection of the run rather than a filter on the workspace listing, which is the registry's shape and worth keeping: the run is looked up first, so an unknown slug is a 404. A filter would answer an empty page instead, and a caller cannot tell that apart from a run that genuinely produced nothing.
func (*Client) ListRuns ¶
ListRuns reads a page of the key's runs. Needs a key: a run belongs to a workspace, and a keyless caller has none of its own.
func (*Client) PrepareArtifact ¶
PrepareArtifact records the artifact and returns the presigned upload.
Named with an Idempotency-Key, because this is the call a lost response makes expensive: without one a retry declares a second artifact, the first is left to expire, and both count as uploads. With one, the retry is answered with the artifact the first attempt already made.
func (*Client) PresignUpload ¶
PresignUpload mints the upload of an artifact again — a fresh presigned PUT over the same slug, the same storage key and the same declared size and digest, so the link that may already be pasted somewhere is the one the bytes land behind.
It exists because two clocks disagree. A presigned URL is good for 15 minutes, while an artifact waits for its bytes with no deadline shorter than a day, so a client that digests a large file, meets a slow network or retries after a crash can arrive at the PUT with a signature that has already lapsed. Declaring the file again would also produce a working URL, and it is the wrong answer: a second declare is a second slug, and the first one is dead in whatever it was pasted into.
A POST though nothing is recorded — the one place the verb does not follow the record. What comes back is a new capability, permission to write these bytes for another 15 minutes, and asking for it means presenting a credential.
Which credential depends on the artifact. A key's authority is the workspace it acts in, so a keyed caller needs nothing else. A keyless caller's is the claim token, and the slug will not do: a slug travels in whatever the link was pasted into, and a reader of a link must not be able to decide what they are reading. It costs the honest caller nothing — the token came back in the same response as the slug and the URL being replaced.
func (*Client) Push ¶
Push runs the whole upload for one file: declare, send the bytes, finalize.
The finalized artifact is what comes back, but the claim token only ever appears on the create response — so it is carried across, because losing it means an anonymous upload can never be kept.
func (*Client) PutBytes ¶
PutBytes streams the file to object storage using exactly the headers the URL was signed for. Anything else — an unsigned header, a different length — and the signature no longer matches what arrives.
The presign is treated as perishable rather than as a fixed address. It is good for 15 minutes and the artifact it belongs to waits far longer, so an upload can meet a URL whose window has already closed — and retrying a dead URL is three attempts spent proving it is dead, ending in advice to push again, which mints a second slug and kills the first link. Where a failure looks like that, a fresh presign is fetched between attempts and the bytes are sent again: same artifact, same slug, same storage key, so nothing about where the link points moves.
func (*Client) ReadCLIAuthorization ¶
ReadCLIAuthorization reads one back: still pending, approved — and carrying the key, exactly once — or denied. A 410 means it is gone, spent or expired.
Keyless for the same reason the create is: the authority for this call is the slug, which is a capability the caller already holds, and a stale key in the environment has no business deciding whether a login can finish.
func (*Client) Root ¶
Root reads the service descriptor. It is the one endpoint needing neither a key nor a payload, so it is what a reachability check should ask for.
func (*Client) ShowArtifact ¶
ShowArtifact reads one artifact back. Works without a key: for a keyless request the slug is the capability, and it resolves within the anonymous workspace.
func (*Client) ShowRun ¶
ShowRun reads one run back — its status, when it started and finished, and the metadata recorded on it, which is where everything about an upload's origin lives since the registry keeps none on the artifact itself.
func (*Client) StartCLIAuthorization ¶
func (c *Client) StartCLIAuthorization(ctx context.Context) (*CLIAuthorization, error)
StartCLIAuthorization opens a browser login and returns the code to show, the page to open, and the slug to collect the key from.
Retried like any other call, and without an Idempotency-Key — the one create in this API that needs none. A lost response means the caller never saw the code, so the authorization it belongs to can never be approved: it costs no row worth deduplicating, reserves no storage, charges nothing, and lapses within the quarter hour on its own.
Call it on a keyless client. The endpoint exists for a machine that has no key and takes none, and sending one would have the registry meter the request as that key's rather than as this address's — so logging in again somewhere already logged in would be counted against the key being replaced.
func (*Client) TakeDownArtifact ¶
TakeDownArtifact removes an artifact's bytes and leaves a tombstone behind, so the link answers 410 rather than 404 — it is already pasted somewhere, and its reader deserves to be told the artifact was removed rather than sent hunting for a typo.
Immediate and unrecoverable, which is the point rather than a limitation. This is what someone reaches for when a secret was uploaded by accident, and a secret that can be restored is still leaked — so nothing here routes through a window that could hand the bytes back.
Two authorities take an artifact down, and which one is used decides how the request is made. A key's authority is the workspace it acts in. A claim token's is the one artifact it was issued for, and it is needed at all because a slug travels in whatever the link was pasted into: authorising a takedown by slug alone would let every reader of the paste destroy what they read.
Nothing comes back but a 204. There is no artifact left to report, and a url and markdown naming bytes that are gone would be a lie.
Retried like any other call: taking down what is already down is a success on both sides, so a lost response costs nothing to ask again.
func (*Client) VerifyKey ¶
VerifyKey reads back the key the client is holding. There is no "valid" field to consult: a key the registry will not accept gets the same 401 here as anywhere else, so a success is the answer. What is checked is that a key came back at all — a 200 with no key_id is some other service answering, not a registry saying yes.
type Error ¶
Error carries a failure flattened into one map, so everything downstream reads it the same way regardless of whether it came from the registry, from object storage, or from this client.
type Identity ¶
type Identity struct {
KeyID string `json:"key_id,omitempty"`
Workspace string `json:"workspace,omitempty"`
}
Identity is what the registry said the stored key was, recorded once at login. It is a record of an answer, not a claim about the key now: a key can be revoked between logging in and using it, and only the registry can say so.
func ReadIdentity ¶
ReadIdentity returns the identity recorded at login, and whether there is one worth reporting.
It is deliberately silent when KROWK_TOKEN is set. ReadToken prefers the environment, so the key doing the work is not the key the file describes, and the file's workspace would name somewhere uploads are not going — a wrong answer given confidently, which is worse than no answer. An identity is also withheld when the file holds no token: a workspace with nothing to reach it with is left over from a login that has since been replaced.
type Key ¶
type Key struct {
KeyID string `json:"key_id,omitempty"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
LastUsedAt string `json:"last_used_at,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
// Status is the HTTP status the read answered with. Diagnostics print what
// actually arrived rather than assuming 200. Transport detail, not part of
// the key itself.
Status int `json:"-"`
}
Key is the key a request is made with, as the registry reports it. Asking beats guessing: a token string says nothing about itself — it can be revoked, expired, or for a workspace other than the one the caller expects, and all three look identical until an upload fails.
type Page ¶
Page is one page of a workspace's artifacts, newest first. Next carries the slug to pass back as before for the following page, and is empty on the last.
type Run ¶
type Run struct {
Slug string `json:"slug"`
Status string `json:"status"`
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
Run groups the artifacts one agent run produced, and is where run metadata lives — the registry keeps none on an artifact.
type RunPage ¶
RunPage is one page of a workspace's runs, newest first. Next carries the slug to pass back as before for the following page, and is empty on the last.
type Service ¶
Service is the descriptor at the API root, which is how doctor tells a reachable registry from a reachable something-else.
type Spec ¶
type Spec struct {
Path string `json:"-"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ByteSize int64 `json:"byte_size"`
Checksum string `json:"checksum"`
Run string `json:"run,omitempty"`
}
Spec is a file the client is about to upload, measured and digested so the registry can sign an upload URL that only these exact bytes fit.
Path is local and deliberately not serialized: the registry is told the basename, never where the file sat on someone's laptop.