Skip to main content

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.
MethodPathPurpose
POST/api/v1/admin/backupsCreate a new bundle
GET/api/v1/admin/backupsList bundles for the current workspace
GET/api/v1/admin/backups/statusInspect the per-workspace advisory lock
GET/api/v1/admin/backups/metricsProcess-lifetime counters (instance OWNER only)
DELETE/api/v1/admin/backups/statusForce-release the lock
GET/api/v1/admin/backups/inspectRead the plaintext MANIFEST
GET/api/v1/admin/backups/verifyRecompute sealed-payload SHA-256
POST/api/v1/admin/backups/rotateApply retention policy
GET/api/v1/admin/backups/downloadStream bundle bytes
POST/api/v1/admin/backups/restoreRestore a bundle
POST/api/v1/admin/backups/self-testRun a backup self-test (round-trip create + verify)
DELETE/api/v1/admin/backupsDelete 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": ""
}
FieldTypeNotes
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_idstringRequired when scope == "crew". Whitespace-only values are rejected.
passphrasestringAGE passphrase. Exactly one of passphrase / recipient / no_encrypt must be supplied.
recipientstringAGE X25519 public key (age1…). Parsed with age.ParseX25519Recipient.
no_encryptboolPlaintext payload. Use for test / CI only.
output_dirstringOptional 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

StatusCondition
400Invalid body, invalid scope, invalid scope_level, missing crew_id, multiple or zero encryption selectors, invalid AGE recipient, output_dir escapes the default directory.
401Missing or invalid auth.
403Role < ADMIN.
409Another backup already holds the lock (ErrLockHeld) or an agent run is active (ErrAgentRunning).
500DB 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

StatusWhen
401 UnauthorizedNo authenticated session.
403 ForbiddenAuthenticated, 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" }
FieldRequiredNotes
crew_idCrew within the caller’s workspace. Whitespace-only is rejected.
Response: 200 OKbackup.SelfTestResult JSON (collect/restore/verify status and timing for each phase).
StatusCondition
400 Bad RequestMalformed body or missing crew_id.
401 UnauthorizedNo workspace context.
403 ForbiddenRole < ADMIN on this workspace.
404 Not FoundCrew not found in the caller’s workspace.
503 Service UnavailabledockerOps not wired (no Docker daemon).
500 Internal Server ErrorCanary 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

ParameterRequiredNotes
pathAbsolute 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

ParameterRequiredNotes
pathAbsolute 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
}
FieldNotes
pathRequired. Subject to the same path-safety checks as inspect.
passphraseRequired for passphrase-encrypted bundles. Empty for --no-encrypt or recipient-encrypted bundles.
identityAGE X25519 secret key (AGE-SECRET-KEY-1…) for bundles sealed with --recipient. Parsed with age.ParseX25519Identity; invalid values return 400.
as_workspaceLand the workspace under a new slug. Docker phase is skipped.
as_crewLand the crew under a new slug (crew-scope bundles only). Docker phase is skipped.
dry_runDecrypt + 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

StatusCondition
400Invalid path, format too new/old (ErrFormatTooNew, ErrFormatTooOld), invalid manifest, invalid checksum, decryption failure (wrong passphrase / corrupted bundle).
404Bundle does not belong to caller’s workspace (or does not exist).
409ErrNoOpRestore — bundle would insert zero rows; treated as “nothing to do”.
500Unexpected 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

ParameterRequiredNotes
pathAbsolute 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:
SentinelStatus
backup.ErrAdminRequired403
backup.ErrAgentRunning, backup.ErrLockHeld409
backup.ErrFormatTooNew, backup.ErrFormatTooOld, backup.ErrSchemaTooOld, backup.ErrInvalidManifest, backup.ErrInvalidScope400
backup.ErrDecryption, backup.ErrInvalidChecksum400
backup.ErrNoOpRestore409
any other500

See also