.. _reference-api: The management API ================== Everything a CLI or UI client addresses: jobs, artifacts, sources, accounts, projects, watches. Management routes mount under ``/api/v1``: ``https://saggar.dev/api/v1/jobs``, ``…/api/v1/projects``, ``…/api/v1/auth/me``. The root namespace stays free for the paths foreign clients hardcode: ``/v2`` (the OCI registry handshake), ``/apt``, ``/worker`` (the runner protocol), ``/hooks`` (forge webhooks), ``/s`` (share links) and ``/health``. The CLI adds the prefix itself; point ``--server https://saggar.dev`` and go. ``GET /api/v1/version`` answers ``{"version": "...", "api": 3}``. The API generation only moves on incompatible changes; additive ones stay within the generation. Authentication -------------- The management API authenticates subjects (see :doc:`../how-to/login` for how to get a token): - ``X-Auth-Token: ``, what the CLI sends; or - ``Authorization: Bearer ``, the standard spelling, equally accepted. Token scopes: a full token (the default) acts as its subject everywhere; a registry-scoped token (``POST /auth/tokens`` with ``{"scope": "registry"}``) authenticates on the registry front-ends only. The management API answers ``403`` by name, so a leaked pull credential cannot read jobs or artifacts. Authorization follows the project ladder (see :ref:`registries-authz` for the read rules): reading jobs, logs, artifacts and members needs *viewer*; submitting and cancelling needs *builder*; promoting needs *promoter*; memberships and deletion are the *owner's*. Rows predating accounts carry no project and are public history. Listings show only what the caller has standing on. Management routes ----------------- Paths relative to ``/api/v1``. Jobs and sources ^^^^^^^^^^^^^^^^ ``POST /jobs`` Submit a job. Body: .. code-block:: json { "source": {"type": "git", "url": "https://…", "ref": "main"}, "project": "slug" , "format": "docker", "name": "my-project", "arch": "arm64", "arches": ["amd64", "riscv64"], "env": "debian:12@amd64/glibc-2.36", "mode": "build", "test_cmd": "make check", "formats": "auto", "reuse_build": true, "cache": "fresh", "ephemeral": false } Only ``source`` is required. ``source`` is ``{"type": "git", "url": …, "ref": …}`` (a remote clone URL; ``ref`` defaults to the remote HEAD) or ``{"type": "tarball", "id": "src-…"}`` (an id from ``POST /sources``). There is no path transport: a host path would let a network submission read the service's filesystem. ``project`` targets a namespace, a slug you own (created on first submit) or ``owner/slug`` (builder standing required); absent: your ``personal`` project. ``arch`` and ``arches`` are mutually exclusive (one names a single target, the other a fan-out list). ``mode`` is ``build`` (default) or ``test``; ``test_cmd`` implies test mode. With a ``formats`` selector (``"auto"`` or a list of ``deb``, ``docker``, ``snap``, ``flatpak``, ``rpm``, ``appimage``, ``native``) the response is ``{"group": "g-…", "jobs": […]}``, one job per claimant, × each ``arches`` entry. ``reuse_build: false`` disables the build-once hand-off (on by default for groups with one build-system claimant, single arch). ``cache`` is ``fresh`` (default), ``artifact`` (memoization) or ``incremental`` (warm trees). ``ephemeral: true`` excludes the artifact from the registries and collects it after the instance's ephemeral TTL (24 h by default); the job record, failure metadata and logs always survive. A submission from an already-over-quota account answers ``403`` by name before any build runs. ``GET /jobs`` List jobs, up to 100, only what you have standing on. ``?group=`` scopes to one fan-out group; ``?project=slug`` (or ``owner/slug``) to one project. ``GET /jobs/:id`` Job status and metadata, plus ``artifacts`` (the produced artifact ids; empty for build-check successes) and ``failure`` metadata on failure (phase, exit code, outcome, class, node, log tail). ``DELETE /jobs/:id`` Cancel a job (queued: outright; running: stops at the next command boundary). Needs *builder*. Returns the job. ``GET /jobs/:id/logs`` Streamed logs (SSE): the current buffer replays, then live lines follow until the job's terminal ``end`` event, and the stream closes right after it. ``?format=text`` (or ``Accept: text/plain``) returns the whole log as plain text. ``GET /jobs/:id/artifact`` Download the whole artifact as ``_.tar.gz``. ``GET /jobs/:id/results`` Structured test results of a test job: per-file suite/case counts and the failing case names with their failure messages. ``404`` when nothing was collected. ``POST /sources`` Upload a source tarball (the raw body: a gzipped tar) → ``{"id": "src-…"}``. Capped at 256 MiB. Artifacts ^^^^^^^^^ ``GET /artifacts`` The registry listing (up to 500, visible ones). Filters: ``?name=``, ``?channel=`` (``staging``/``rc``/``release``), ``?arch=``, ``?ephemeral=true`` (ephemeral-only), ``?project=slug|owner/slug``. ``GET /artifacts/:id`` Artifact metadata: name, version, format, arch, channels, and the files with digests. ``GET /artifacts/:id/files/:filename`` Download one file of an artifact. ``GET /artifacts/:id/shares`` The artifact's share links: who minted them, until when, which are revoked. The tokens are not in the listing (shown once at mint, stored hashed), so it can audit but never resurrect a link. Viewer standing. ``POST /artifacts/:id/shares`` Mint a share link: ``{"expires_in_secs": 86400}`` (absent: the link never expires on its own) → the plaintext token (``sgs-…``), shown once, the ready-to-paste path ``/s/``, and the stored row. Viewer standing is enough: reading the artifact is the most a link can ever grant. Audited. ``DELETE /shares/:id`` Revoke a share link, immediately: every holder of the plaintext loses access at once. The link's creator or the project's owner may revoke. Revoked and unknown share one ``404``, the same answer the serve path gives, for the same reason. Audited. ``POST /artifacts/:id/promote`` Promote to a channel: ``{"channel": "rc"}`` or ``"release"``. Needs *promoter*; audited (who, what, when). One-directional: a label, never a rebuild. Operations ^^^^^^^^^^ ``GET /ops`` Long-lived operations (a job and the process handling it), up to 200. ``GET /metrics`` Prometheus-format counters (``text/plain; version=0.0.4``). The scraper authenticates like any other client. ``GET /version`` ``{"version": "...", "api": 3}``. ``GET /health`` Liveness, mounted at the root and open: probes carry no token. Accounts and auth ^^^^^^^^^^^^^^^^^ ``GET /auth/methods`` Open to unauthenticated callers; also mounted at the root, for pre-login discovery. The configured login providers: ``{"providers": [{"name", "kind", "authorize_url"}]}``. The ``authorize_url`` has the client id and scope baked in; the client appends ``redirect_uri`` and ``state``. An empty list means no login is offered. ``POST /auth/session`` Open to unauthenticated callers; also mounted at the root. Exchange one provider's web-flow code: ``{"provider", "code", "redirect_uri"}``. The service is the confidential client of every kind and runs the exchange server-side, so no client secret ever reaches a browser. Known identity → ``{"status": "login", "token", "expires_at", "subject"}``; first sight → ``{"status": "signup", "signup_token", "handle"}``, and nothing is created until ``POST /auth/signup`` confirms the handle. ``POST /auth/signup`` Open to unauthenticated callers; also mounted at the root. Confirm a sign-up: ``{"signup_token", "handle"}`` → the login reply. The proposed handle is a suggestion; any free, non-reserved handle works. A taken handle → ``409``, a reserved one → ``400``; both leave the grant alive for a retry. An expired or unknown grant → ``410``/``404``, and the login starts over (grants expire in 10 minutes). ``POST /auth/device`` Open to unauthenticated callers; also mounted at the root. Start a device login (what ``saggar login``'s browser mode runs): → ``{"user_code", "device_code", "verification_uri", "expires_in", "interval"}``: the 8-character code (no look-alike glyphs) a signed-in user confirms at the verification page (``https://saggar.dev/device`` on the default instance), within ``expires_in`` (600 s); ``device_code`` is the poll capability. Rate-limited per address. ``POST /auth/device/confirm`` Confirm a device login as the signed-in caller: ``{"user_code"}`` → ``204``, what the app's device page does behind its authenticated proxy. The typed code is normalized: case, dashes and spaces are ignored. Unknown → ``404``, expired → ``410``, a repeat confirm → ``204`` like the first (idempotent). Guarded: under ``/api/v1`` only. ``POST /auth/device/token`` Open to unauthenticated callers; also mounted at the root. Poll a device login: ``{"device_code"}``, long-polling up to 25 s (set the client timeout above it; honor ``interval``, 2 s). ``{"status": "pending"}`` until a signed-in user confirms, then ``{"status": "approved", "token", "expires_at", "subject"}``, the exact reply a web-flow login returns. Unknown → ``404``, expired → ``410``; the poll never consumes the login, so a lost response answers again. ``GET /auth/me`` The authenticated subject, its projects (with roles), and the account's storage standing (``storage.used_bytes`` against ``storage.quota_bytes``; ``null`` = unlimited). ``GET /auth/tokens`` Your tokens, never their secrets. ``POST /auth/tokens`` Mint one: ``{"name", "expires_in_days", "scope"}``. The secret is in this one response (``{"token": "sgt-…", ...}``); only its hash is stored. ``scope`` absent or ``"full"``: the subject everywhere; ``"registry"``: pull-only (see `Authentication`_). ``DELETE /auth/tokens/:id`` Revoke one of your tokens; it stops authenticating immediately. ``GET /auth/identities`` Your linked logins, oldest first (provider key + display name when still configured). ``POST /auth/identities`` Link another provider login to your account: ``{"provider", "code", "redirect_uri"}``, the same OAuth round-trip as a sign-in, attaching instead of entering. An identity already linked to another account → ``409`` naming its handle. ``DELETE /auth/identities?provider=…`` Unlink one login, refused while it is the account's last (lock-out guard). After unlinking, that provider's next login opens a fresh sign-up. Projects ^^^^^^^^ ``GET /projects`` Your projects (owned + member, with roles), newest first. ``POST /projects`` Create one: ``{"slug": "web"}`` (lowercase letters, digits, dashes). You own what you create. ``GET /projects/:owner/:slug`` One project. ``PATCH /projects/:owner/:slug`` Flip visibility: ``{"visibility": "private"|"public"}`` (owner). A public project's artifacts are pullable by anyone (see :ref:`registries-authz`). ``DELETE /projects/:owner/:slug`` Delete it (owner; refused while jobs exist, because build history is referenced, not orphaned). ``GET /projects/:owner/:slug/members`` The members, with roles. ``PUT /projects/:owner/:slug/members`` Grant or change one: ``{"handle", "role": "viewer"|"builder"|"promoter"}`` (owner only; ``owner`` is the project record and cannot be granted). ``DELETE /projects/:owner/:slug/members/:handle`` Revoke a membership (owner only). ``GET /projects/:owner/:slug/remotes`` The project's git remotes (its mirrors; derive-on-submit matches these). ``POST /projects/:owner/:slug/remotes`` Register one: ``{"url": …}`` (owner; idempotent; watch registration adds remotes too). ``DELETE /projects/:owner/:slug/remotes?url=…`` Forget one remote (owner; refused while a watch of the project polls that URL). Watches ^^^^^^^ ``GET /watches`` The watches you can see (with their project): unattributed watches, plus every watch of a project you have *viewer* standing on. ``POST /watches`` Register one, one per repo, keyed by the normalized URL (duplicate → conflict): .. code-block:: json { "url": "https://git.example.com/me/web", "project": "web", "branches": ["main", "release/*"], "tags": [], "settings": {"formats": "auto", "test": null}, "interval_secs": 300, "enabled": true, "forge": "gitea", "credential": "…" } ``settings`` carries the submit defaults of triggered builds (``formats``, ``arches``, ``env``, ``cache``, ``ephemeral``, ``test``, ``test_cmd``). ``forge`` is ``generic`` (the default; it reports nothing), ``gitea``, ``forgejo`` or ``github``; ``credential`` is the forge API token for commit-status reporting, stored write-only, never served back. Save-time validation refuses the incoherent: a tests-only watch with a formats list, a ``cache: "artifact"`` trigger (memoization does not compose with groups), an empty pattern or arch list, a zero interval. ``GET /watches/:id`` One watch in full, including its ``webhook_secret``, the HMAC key to paste into the forge's webhook config. ``PUT /watches/:id`` Change what you passed at creation; a patch, so omitted fields keep the watch's current value. The URL and the id are immutable. ``"rotate_webhook_secret": true`` mints a fresh webhook secret (the forge config must be updated; the new value rides the response). ``"credential"`` replaces the forge token. ``DELETE /watches/:id`` Remove it. The history stays; re-registering starts clean. ``POST /watches/:id/trigger`` Poll right now; returns the groups each unseen head produced (empty when nothing new). GitHub App ^^^^^^^^^^ The GitHub App is the service's repo-import and private-clone registration, separate from login (see :doc:`../how-to/github-import`): ``GET /forges/github/repositories`` The repositories the caller's own GitHub App installations reach, the import page's GitHub listing: ``{"repositories": [{"url" (clone URL), "full_name", "description", "updated_at"}]}``. Nothing to list: an empty list plus a ``reason`` (``"github app not configured"`` / ``"no github app installed"``). Runner fleet ^^^^^^^^^^^^ Mounted under ``/api/v1`` beside the rest (see :doc:`worker-api` for the protocol side): ``GET /nodes`` The runners the caller owns or is shared with: node id, arch, engine version, state, owner handle, ``owned`` flag, declared conduct profile (``network``, ``docker_format``), last seen. ``POST /nodes/retire`` Retire a runner you own: ``{"node_id": …}`` → the row stays listed ``retired``. Not idempotent: unknown or already-retired answers ``404``. ``GET /nodes/:id/shares`` The accounts the runner is shared with (owner only). ``PUT /nodes/:id/shares`` Grant use to an account: ``{"handle": "…"}`` → the sharee list. ``DELETE /nodes/:id/shares/:handle`` Revoke a share → the sharee list; future claims stop immediately, a running job finishes its lease. Share links ^^^^^^^^^^^ Open routes at the instance root, with no subject guard and no ``/api/v1`` prefix. The unguessable token in the path is the auth, the way a webhook's signature is, and every request pays a per-address window first, so guessing or farming costs the caller: ``GET /s/{token}`` The whole artifact the link names, as the same ``_.tar.gz`` bundle an authenticated download serves. ``GET /s/{token}/files/{filename}`` One file of that artifact, streamed exactly like the authenticated per-file download. An expired link answers ``410``; a revoked or unknown one answers the same ``404`` as any unknown name, so revocation does not announce itself. A link grants its artifact and nothing else: not the job, not its logs, not the project, not a sibling artifact. Errors ------ Errors are JSON: ``{"error": ""}`` with a precise status. ``400`` for a malformed body, ``401`` for a missing or unknown credential, ``403`` for a scope or standing refusal (named: which role was required, or that the token is registry-scoped), ``404`` for unknown rows, ``409`` for conflicts (a taken watch URL, a linked identity, a membership), ``410`` where a grant, device login or share link has expired; it existed and ran out, unlike the ``404`` a revoked or unknown name answers. Denials on the registries are deliberately indistinguishable from unknown names (see :doc:`registries`).