Backup API
REST endpoints for creating, listing, inspecting, verifying, restoring, rotating, and deleting backup bundles. All routes live under/api/v1/admin/backups and are gated by the authed + wsCtx middleware chain. Handlers additionally re-check the workspace role (canRole(role, "manage")) — only OWNER and ADMIN pass. Every mutation writes one row to audit_log.
Routes are registered in internal/api/router_admin.go (domain split in May 2026); handlers live in internal/api/backup.go.
Every endpoint requires authentication, workspace context, and OWNER or ADMIN on the workspace. The
metrics endpoint is stricter still — see its authorisation warning.Authentication and response contract
Every request uses the authenticated workspace context; no endpoint accepts a workspace ID in its body. Requests without a body are marked explicitly below, and path-based operations takepath as a query parameter. Statuses:
successful JSON operations return 200 or 201, successful deletion returns
204, and invalid input, authentication, authorization, conflicts, and server
failures use the status codes listed in each operation’s error table.
Endpoints
Path-based endpoints (
inspect, verify, download, restore, delete) validate that the supplied path lives under ~/.crewship/backups and belongs to the caller’s workspace. Symlinks are explicitly rejected during path validation — no escape via ln -s to read files outside the backup directory. Paths from other workspaces return 404 Not Found rather than 403, so the endpoint does not confirm existence of bundles outside the caller’s scope.Create and list
Create new bundles and enumerate the ones already on disk for the current workspace.POST /api/v1/admin/backups
Create a new backup bundle. Runs inline (seconds to minutes); no async job queue.Request
Response — 201 Created
scope_level is omitted when the bundle has no explicit level recorded in its manifest.
New bundles are written at
format_version 3 (backup.FormatVersion, bumped v2→v3 on 2026-08-03 when the payload gained the crew/<slug> section carrying the crew and per-agent memory trees). Restore enforces an N-2 compatibility window: this binary reads format_version in [MinSupportedFormatVersion, FormatVersion] = [1, 3]. A bundle newer than the reader returns ErrFormatTooNew (400); older than the window returns ErrFormatTooOld (400).Errors
Audit action:
backup.create.
GET /api/v1/admin/backups
List bundles from~/.crewship/backups filtered to the caller’s workspace (bundles of other workspaces are hidden from the response).
Response — 200 OK
backup_catalog first (reconciled against disk on each call); a missing/empty catalog falls back to a filesystem scan. scope_level is omitted when the source manifest did not record it.
Locking and metrics
Inspect or force-release the per-workspace advisory lock, and read process-lifetime backup counters.GET /api/v1/admin/backups/status
Report the workspace’s advisory lock.Response — 200 OK
held: false omits the detail fields. The lock TTL is 1 hour (backup.DefaultLockTTL). Missing workspace context returns 400 (not 401).
DELETE /api/v1/admin/backups/status
Force-release the per-workspace lock regardless of owner. Intended as an emergency escape hatch when a crashed backup left a stale lock behind. Returns204 No Content.
Audit action: backup.unlock.
GET /api/v1/admin/backups/metrics
Return a point-in-time snapshot of process-lifetime counters for the backup subsystem — created / failed / restored totals, size bytes, approximate duration quantiles, and per-workspace lock-hold seconds. Counters reset on every restart; persistent reporting should readaudit_log instead.
Response — 200 OK
Errors
Inspect, verify, and self-test
Read a bundle’s manifest, recompute its checksum, or run an end-to-end canary without leaving a bundle on disk.POST /api/v1/admin/backups/self-test
Per-crew round-trip canary: collect → destroy a canary marker inside the crew container → restore → verify → cleanup. Used by the seed CLI and CI harness to validate the backup pipeline end-to-end without leaving a bundle on disk. Lightweight: no encryption, no DB dump. Gated by workspaceOWNER/ADMIN (canRole(role, "manage")), the same as the other backup admin endpoints — not by CREWSHIP_OWNER_EMAIL.
Request body:
Response:
200 OK — backup.SelfTestResult JSON (collect/restore/verify status and timing for each phase).
GET /api/v1/admin/backups/inspect
Return the plaintext MANIFEST for a bundle without decrypting the payload.Query
The request has no body. Response:200 OK with the manifest JSON.
Response — 200 OK
Returns the manifest JSON as produced by backup.Inspect (internal/backup/manifest.go).
GET /api/v1/admin/backups/verify
Recompute the sealed-payload SHA-256 and compare against the MANIFEST. Does not decrypt. Streams the payload from a temp file so peak heap is bounded by the zstd decoder window.Query
The request has no body. Response:200 OK with the verification object.
Response — 200 OK
valid: false sets error to a human-readable reason (checksum mismatch, truncated payload, unreadable bundle, …).
Retention, transfer, and restore
Apply retention policy, stream bundles off-box, restore into the current workspace, or delete bundles from disk.POST /api/v1/admin/backups/rotate
Apply retention policy to the caller’s workspace. Per-workspace only — another workspace’s bundles are never enumerated.Request
keep_last / keep_days must be positive (0 disables the rule). Negatives are rejected.
Response — 200 OK
dry_run: true the paths are returned but the files stay on disk and no audit rows are written.
Audit action (per deleted bundle, non-dry-run only): backup.rotate.
GET /api/v1/admin/backups/download
Stream the raw bundle bytes over HTTP forscp-free transfer off-box.
The request has no body. Response: 200 OK with an application/zstd
attachment; 400 is returned for a missing or unsafe path, 404 for a
bundle outside the workspace or no longer on disk, and 500 for a file error.
Response headers:
Content-Type: application/zstdContent-Disposition: attachment; filename="<file>"Cache-Control: no-storeX-Content-Type-Options: nosniff
backup.download.
POST /api/v1/admin/backups/restore
Restore a bundle into the current workspace.Request
Response — 200 OK
docker_phase_skipped: true when as_workspace / as_crew rewrote the slug — container state is not landed. dropped_crew_filesystems names the crews that lost it. Finish the restore by starting each of them (POST /api/v1/crews/{crewId}/container-start) and then calling this endpoint again with files_only: true and the new workspace in context. It must be container-start and not /provision: files_only writes each section by exec’ing into the crew’s container, so the container has to be running, and provision only builds the image.
Re-calling WITHOUT files_only after a rename does not work and is not the recovery: the forked workspace matches neither the bundle’s id nor its slug, so the tenant guard returns 403 — and its rows are already present under new ids anyway.
crews_count is what the bundle describes; crews_restored is how many crews actually received container state. The two differ whenever a crew carries no filesystem sections or has no provisioned container on this instance. A files_only call that would land nothing is rejected rather than returning 200 with the bundle’s count — an operation reporting success without having written anything is the failure mode this whole area exists to have stopped.
security_level_clamped counts the restored credentials whose security_level was not one of the defined tiers (L1–L4) and were therefore written at the strictest tier instead — every other writer of that column validates it, so restore does too. security_level_clamps is a bounded sample naming the affected credentials and the value the bundle carried; the full list of what happened is also written to the journal as credential.security_level.clamped. On a dry_run both fields report what would be clamped. Re-set each affected credential with PATCH /api/v1/credentials/{id} (security_level) or crewship credential update <name> --security-level N.
Errors
Audit action:
backup.restore (or backup.restore.dry_run for dry_run: true).
DELETE /api/v1/admin/backups
Delete a bundle from disk. Returns204 No Content.
The request has no body. Response: 204 No Content; 400 indicates a
missing or unsafe path, 404 indicates a bundle outside the workspace, and
500 indicates a delete failure.
Query
Audit action:
backup.delete.
Error mapping
internal/api/backup.go:statusForBackupError maps sentinel errors to HTTP status using errors.Is, so the backup package can rework error wording without breaking HTTP contracts:
See also
- Backup & Restore guide — end-to-end walkthrough.
- Security → Audit log — audit row layout for
backup.*events.