> ## 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

> REST endpoints for creating, listing, inspecting, verifying, restoring, rotating, and deleting backup bundles. All routes require OWNER or ADMIN on the workspace.

# 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`.

<Note>
  Every endpoint requires authentication, workspace context, and **OWNER** or **ADMIN** on the workspace. The `metrics` endpoint is stricter still — see its [authorisation warning](#get-apiv1adminbackupsmetrics).
</Note>

## Endpoints

| Method | Endpoint                                                              | Purpose                                             |
| ------ | --------------------------------------------------------------------- | --------------------------------------------------- |
| POST   | [`/api/v1/admin/backups`](#post-apiv1adminbackups)                    | Create a new bundle                                 |
| GET    | [`/api/v1/admin/backups`](#get-apiv1adminbackups)                     | List bundles for the current workspace              |
| GET    | [`/api/v1/admin/backups/status`](#get-apiv1adminbackupsstatus)        | Inspect the per-workspace advisory lock             |
| DELETE | [`/api/v1/admin/backups/status`](#delete-apiv1adminbackupsstatus)     | Force-release the lock                              |
| GET    | [`/api/v1/admin/backups/metrics`](#get-apiv1adminbackupsmetrics)      | Process-lifetime counters (instance OWNER only)     |
| GET    | [`/api/v1/admin/backups/inspect`](#get-apiv1adminbackupsinspect)      | Read the plaintext MANIFEST                         |
| GET    | [`/api/v1/admin/backups/verify`](#get-apiv1adminbackupsverify)        | Recompute sealed-payload SHA-256                    |
| POST   | [`/api/v1/admin/backups/rotate`](#post-apiv1adminbackupsrotate)       | Apply retention policy                              |
| GET    | [`/api/v1/admin/backups/download`](#get-apiv1adminbackupsdownload)    | Stream bundle bytes                                 |
| POST   | [`/api/v1/admin/backups/restore`](#post-apiv1adminbackupsrestore)     | Restore a bundle                                    |
| POST   | [`/api/v1/admin/backups/self-test`](#post-apiv1adminbackupsself-test) | Run a backup self-test (round-trip create + verify) |
| DELETE | [`/api/v1/admin/backups`](#delete-apiv1adminbackups)                  | Delete a bundle from disk                           |

<Note>
  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.
</Note>

## 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

```json theme={null}
{
  "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`

```json theme={null}
{
  "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.

<Note>
  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).
</Note>

### 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`

```json theme={null}
{
  "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`

```json theme={null}
{
  "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.

<Warning>
  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.
</Warning>

### Response — `200 OK`

```json theme={null}
{
  "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:**

```json theme={null}
{ "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`

```json theme={null}
{
  "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

```json theme={null}
{ "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`

```json theme={null}
{
  "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

```json theme={null}
{
  "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`

```json theme={null}
{
  "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

* [Backup & Restore guide](/guides/backup) — end-to-end walkthrough.
* [Security → Audit log](/security/audit) — audit row layout for `backup.*` events.
