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.
Endpoints
| Method | Endpoint | Purpose |
|---|
| POST | /api/v1/admin/backups | Create a new bundle |
| GET | /api/v1/admin/backups | List bundles for the current workspace |
| GET | /api/v1/admin/backups/status | Inspect the per-workspace advisory lock |
| DELETE | /api/v1/admin/backups/status | Force-release the lock |
| GET | /api/v1/admin/backups/metrics | Process-lifetime counters (instance OWNER only) |
| GET | /api/v1/admin/backups/inspect | Read the plaintext MANIFEST |
| GET | /api/v1/admin/backups/verify | Recompute sealed-payload SHA-256 |
| POST | /api/v1/admin/backups/rotate | Apply retention policy |
| GET | /api/v1/admin/backups/download | Stream bundle bytes |
| POST | /api/v1/admin/backups/restore | Restore a bundle |
| POST | /api/v1/admin/backups/self-test | Run a backup self-test (round-trip create + verify) |
| DELETE | /api/v1/admin/backups | Delete a bundle from disk |
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
{
"scope": "workspace",
"scope_level": "standard",
"crew_id": "",
"passphrase": "correct horse battery staple",
"recipient": "",
"no_encrypt": false,
"output_dir": ""
}
| Field | Type | Notes |
|---|
scope | "workspace" | "crew" | Required. "instance" is reserved for the CLI-only flow and rejected by the API. |
scope_level | "quick" | "standard" | "full" | Optional. Defaults to backup.DefaultScopeLevel when omitted. Invalid values rejected with 400. |
crew_id | string | Required when scope == "crew". Whitespace-only values are rejected. |
passphrase | string | AGE passphrase. Exactly one of passphrase / recipient / no_encrypt must be supplied. |
recipient | string | AGE X25519 public key (age1…). Parsed with age.ParseX25519Recipient. |
no_encrypt | bool | Plaintext payload. Use for test / CI only. |
output_dir | string | Optional override. Must resolve under ~/.crewship/backups; symlinks rejected. |
Response — 201 Created
{
"path": "/home/admin/.crewship/backups/crewship-workspace-acme-2026-04-15T12-05-01Z.tar.zst",
"size_bytes": 13421772,
"payload_sha256": "b5f7…",
"format_version": 2,
"scope": "workspace",
"scope_level": "standard",
"created_at": "2026-04-15T12:05:01Z",
"encrypted": true
}
scope_level is omitted when the bundle has no explicit level recorded in its manifest.
New bundles are written at format_version 2 (backup.FormatVersion, bumped v1→v2 on 2026-05-25 when --replace mode and the FK-walked 50+-table dump landed). Restore enforces an N-2 compatibility window: this binary reads format_version in [MinSupportedFormatVersion, FormatVersion] = [1, 2]. A bundle newer than the reader returns ErrFormatTooNew (400); older than the window returns ErrFormatTooOld (400).
Errors
| Status | Condition |
|---|
400 | Invalid body, invalid scope, invalid scope_level, missing crew_id, multiple or zero encryption selectors, invalid AGE recipient, output_dir escapes the default directory. |
401 | Missing or invalid auth. |
403 | Role < ADMIN. |
409 | Another backup already holds the lock (ErrLockHeld) or an agent run is active (ErrAgentRunning). |
500 | DB dump, tar, or encryption failure. |
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
{
"data": [
{
"path": "/home/admin/.crewship/backups/crewship-workspace-acme-2026-04-15T12-05-01Z.tar.zst",
"file_name": "crewship-workspace-acme-2026-04-15T12-05-01Z.tar.zst",
"size_bytes": 13421772,
"scope": "workspace",
"scope_level": "standard",
"encrypted": true,
"created_at": "2026-04-15T12:05:01Z",
"format_version": 2
}
]
}
Entries are sourced from 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": true,
"workspace_id": "ws_abc123",
"acquired_by": "user_def456",
"acquired_at": "2026-04-15T12:04:58Z",
"expires_at": "2026-04-15T13:04:58Z"
}
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. Returns 204 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 read audit_log instead.
Authorisation is stricter than the other backup endpoints. This route is gated by backup.IsInstanceOwner, which checks CREWSHIP_OWNER_EMAIL — a server-level env var, not the workspace role. A workspace OWNER / ADMIN on their own is refused with 403 Forbidden ({"error": "instance owner required"}). The env is intentionally not stored in the database so a compromised SQL session cannot grant itself access.
Response — 200 OK
{
"created_total": 5,
"created_by_scope": { "workspace": 4, "crew": 1 },
"failed_total": 0,
"failed_by_reason": {},
"restored_total": 2,
"size_bytes_total": 67108864,
"duration_seconds_p50": 4.1,
"duration_seconds_p95": 12.7,
"duration_seconds_mean": 6.3,
"lock_held_seconds_by_workspace": { "ws_abc123": 0 }
}
Duration quantiles are approximated from an in-memory ring buffer (tens-to-hundreds of samples between restarts) — adequate for a dashboard tile, not a general-purpose histogram.
Errors
| Status | When |
|---|
401 Unauthorized | No authenticated session. |
403 Forbidden | Authenticated, but email does not match CREWSHIP_OWNER_EMAIL. |
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 workspace OWNER/ADMIN (canRole(role, "manage")), the same as the other backup admin endpoints — not by CREWSHIP_OWNER_EMAIL.
Request body:
{ "crew_id": "crew_abc123" }
| Field | Required | Notes |
|---|
crew_id | ✓ | Crew within the caller’s workspace. Whitespace-only is rejected. |
Response: 200 OK — backup.SelfTestResult JSON (collect/restore/verify status and timing for each phase).
| Status | Condition |
|---|
400 Bad Request | Malformed body or missing crew_id. |
401 Unauthorized | No workspace context. |
403 Forbidden | Role < ADMIN on this workspace. |
404 Not Found | Crew not found in the caller’s workspace. |
503 Service Unavailable | dockerOps not wired (no Docker daemon). |
500 Internal Server Error | Canary insert/destroy/restore/verify failed. |
GET /api/v1/admin/backups/inspect
Return the plaintext MANIFEST for a bundle without decrypting the payload.
Query
| Parameter | Required | Notes |
|---|
path | ✓ | Absolute path. Must live under ~/.crewship/backups and belong to the caller’s workspace. |
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
| Parameter | Required | Notes |
|---|
path | ✓ | Absolute path. Subject to the same path-safety checks as inspect; bundles outside the caller’s workspace return 404. |
Response — 200 OK
{
"valid": true,
"size_bytes": 13421772,
"manifest": { "format_version": 2, "scope": "workspace", "…": "…" },
"error": ""
}
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": 10, "keep_days": 30, "dry_run": false }
At least one of keep_last / keep_days must be positive (0 disables the rule). Negatives are rejected.
Response — 200 OK
{
"deleted": [
"/home/admin/.crewship/backups/crewship-workspace-acme-2025-12-01T…tar.zst"
],
"dry_run": false
}
With 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 for scp-free transfer off-box.
Response headers:
Content-Type: application/zstd
Content-Disposition: attachment; filename="<file>"
Cache-Control: no-store
X-Content-Type-Options: nosniff
Audit action: backup.download.
POST /api/v1/admin/backups/restore
Restore a bundle into the current workspace.
Request
{
"path": "/home/admin/.crewship/backups/crewship-workspace-acme-…tar.zst",
"passphrase": "correct horse battery staple",
"identity": "",
"as_workspace": "",
"as_crew": "",
"replace": false,
"dry_run": false
}
| Field | Notes |
|---|
path | Required. Subject to the same path-safety checks as inspect. |
passphrase | Required for passphrase-encrypted bundles. Empty for --no-encrypt or recipient-encrypted bundles. |
identity | AGE X25519 secret key (AGE-SECRET-KEY-1…) for bundles sealed with --recipient. Parsed with age.ParseX25519Identity; invalid values return 400. |
as_workspace | Land the workspace under a new slug. Docker phase is skipped. |
as_crew | Land the crew under a new slug (crew-scope bundles only). Docker phase is skipped. |
replace | When true, wipe existing target rows matching the bundle’s workspace (by id OR slug) before INSERT. Disaster-recovery path: after a nuke the fresh-bootstrap workspace has a new CUID but the same slug — replace clears the conflicting target so the bundle lands with its original IDs. |
dry_run | Decrypt + replay the DB transaction, then roll back. No workspace / crew / agent writes are applied. |
Response — 200 OK
{
"manifest": { "format_version": 2, "scope": "workspace", "…": "…" },
"restored_ws": "acme",
"restored_workspace_id": "ws_abc123",
"crews_count": 4,
"rows_inserted": 312,
"docker_phase_skipped": false
}
docker_phase_skipped: true when as_workspace / as_crew rewrote the slug — container state is not landed; call POST /api/v1/crews/{crewId}/provision afterwards.
Errors
| Status | Condition |
|---|
400 | Invalid path, format too new/old (ErrFormatTooNew, ErrFormatTooOld), invalid manifest, invalid checksum, decryption failure (wrong passphrase / corrupted bundle). |
403 | Bundle is not bound to the caller’s current workspace (by id or slug) and the instance already has at least one workspace. The cross-tenant write guard (allowRestore) refuses the restore; restore on the source instance or use a fresh instance for cross-tenant DR. |
404 | Bundle file does not exist on disk (ErrBundleNotFound). |
409 | ErrNoOpRestore — bundle would insert zero rows; treated as “nothing to do”. |
500 | Unexpected failure during extraction / replay, or the cross-tenant authorization probe failed. |
Audit action: backup.restore (or backup.restore.dry_run for dry_run: true).
DELETE /api/v1/admin/backups
Delete a bundle from disk. Returns 204 No Content.
Query
| Parameter | Required | Notes |
|---|
path | ✓ | Absolute path. Must live under ~/.crewship/backups and belong to the caller’s workspace. |
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:
| Sentinel | Status |
|---|
backup.ErrAdminRequired | 403 |
backup.ErrAgentRunning, backup.ErrLockHeld | 409 |
backup.ErrFormatTooNew, backup.ErrFormatTooOld, backup.ErrSchemaTooOld, backup.ErrInvalidManifest, backup.ErrInvalidScope | 400 |
backup.ErrDecryption, backup.ErrInvalidChecksum | 400 |
backup.ErrNoOpRestore | 409 |
| any other | 500 |
See also