Documentation Index
Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
Use this file to discover all available pages before exploring further.
Backup API
All endpoints 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.
| Method | Path | 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 |
GET | /api/v1/admin/backups/metrics | Process-lifetime counters (instance OWNER only) |
DELETE | /api/v1/admin/backups/status | Force-release the lock |
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.
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": 1,
"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.
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": 1
}
]
}
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.
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).
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. |
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. |
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/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": 1, "scope": "workspace", "…": "…" },
"error": ""
}
valid: false sets error to a human-readable reason (checksum mismatch, truncated payload, unreadable bundle, …).
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": "",
"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. |
dry_run | Decrypt + replay the DB transaction, then roll back. No workspace / crew / agent writes are applied. |
Response — 200 OK
{
"manifest": { "format_version": 1, "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). |
404 | Bundle does not belong to caller’s workspace (or does not exist). |
409 | ErrNoOpRestore — bundle would insert zero rows; treated as “nothing to do”. |
500 | Unexpected failure during extraction / replay. |
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