# Object storage

> SeaweedFS, S3, R2 and other S3-compatible stores, bring-your-own bucket per workspace, and region pinning.

Source: https://critlab.app/docs/self-hosting/storage

critlab stores bytes — package files, voice notes, screenshots and thumbnails — in any S3-compatible object store, and everything else in Postgres. It uses the S3 API only, so the same install runs on SeaweedFS, AWS S3, Cloudflare R2, Ceph RGW or MinIO.

## The platform bucket

Every install has one bucket, configured on **web and sandbox** with the same values:

| Variable | Example |
| --- | --- |
| `S3_ENDPOINT` | `http://seaweedfs:9000`, `https://s3.eu-central-1.amazonaws.com`, `https://<account>.r2.cloudflarestorage.com` |
| `S3_BUCKET` | `critlab` |
| `S3_REGION` | `us-east-1` (default); your bucket's region on AWS |
| `S3_ACCESS_KEY`, `S3_SECRET_KEY` | the key pair, kept in a Secret |
| `S3_FORCE_PATH_STYLE` | `true` (default) for SeaweedFS, MinIO and most self-hosted stores; `false` for AWS virtual-hosted addressing |

Create the bucket before first start; critlab does not create it (the Helm chart's bundled SeaweedFS has a job that does). Keep the bucket private. Browsers never talk to it directly — web and sandbox fetch and stream every object after their own access checks — so it needs no public access and no CORS rules.

Package files are stored under their SHA-256 content hash, once per bucket, so publishing a new version only uploads the files that changed. Files no version references any more are removed by a garbage-collection sweep after a grace period.

### Which store to choose

| Store | Use it when |
| --- | --- |
| SeaweedFS | The default for self-hosted: one container (`weed server -s3`), bundled by the chart and the Compose file. |
| Ceph RGW (e.g. through Rook) | You need a highly available store on your own hardware. |
| AWS S3, Cloudflare R2 | You already run in a cloud. |
| MinIO | You already run it. Its community edition is archived upstream, so critlab no longer bundles it. |

> **Presigned uploads and checksums**
>
> critlab does not use presigned URLs. If you build tooling that does against SeaweedFS, create the AWS SDK v3 client with `requestChecksumCalculation: "WHEN_REQUIRED"`: the SDK's default embeds a checksum of an empty body at signing time, and SeaweedFS correctly rejects the real upload with `BadDigest`.

## Bring your own bucket per workspace

A workspace on a licensed install can send its own uploads to a bucket it owns. An owner configures it in **Settings → Storage** (or `crit storage configure`, `crit storage verify`):

1. **Save** the endpoint, bucket, region, optional prefix and key pair. Nothing is routed yet.
2. **Verify** runs a probe: `HeadBucket`, then writes, reads back byte-for-byte and deletes 16 random bytes under `critlab-verify/`, then lists with the prefix. Each step reports the store's own error (`AccessDenied`, `NoSuchBucket`, `SignatureDoesNotMatch`…).
3. From the moment it passes, new uploads land in that bucket. If a later check fails, uploads are **refused**, never silently redirected to the platform bucket.

Grant the key at least `s3:GetObject`, `s3:PutObject`, `s3:DeleteObject` on the bucket (or prefix) and `s3:ListBucket`. Every stored object records which bucket it lives in, so disconnecting or switching buckets later never moves or strands existing files: old versions keep reading from where they were written. critlab never deletes objects from a bucket you own, on disconnect or otherwise.

To move a workspace's existing files into its new bucket, run on a machine with access to both:

```bash
pnpm --filter @critlab/web storage:migrate <workspace-slug> --dry-run
pnpm --filter @critlab/web storage:migrate <workspace-slug>
```

It copies what is missing, then re-points rows only once every byte is in place; it is safe to re-run.

### Operator settings for workspace buckets

- `STORAGE_SECRET` encrypts the stored key pairs (AES-256-GCM; falls back to `AUTH_SECRET`). **Rotating it invalidates every stored credential** — each workspace must re-enter its keys.
- `STORAGE_ALLOW_PRIVATE_ENDPOINTS=1` on web **and** sandbox lets a workspace bucket live on a private network address. Off by default, because the endpoint is a URL the server signs requests to. Cloud metadata addresses stay blocked either way.
- The sandbox gets a workspace bucket's credentials from web over `/api/internal/storage-target`, authenticated with the internal lookup secret, which must match on both (`INTERNAL_LOOKUP_SECRET`, else `DOMAIN_LOOKUP_SECRET`, else `AUTH_SECRET`). Set `INTERNAL_ORIGIN` so the sandbox reaches web on the internal network.

## Region pinning

Each workspace has a data region (EU first). When an `eu` workspace connects a bucket in a `us-*` region, uploads are refused with `region_mismatch` unless the owner explicitly allows cross-region storage. This pins **stored files** only: comments, transcripts and workspace records live in whichever Postgres your deployment runs, which on a self-hosted install is wherever you put it. The settings page states the region as, for example, "EU (storage: eu-central-1)".

> **Open questions**
>
> - **Object versioning and lifecycle rules.** critlab relies on neither; whether enabling bucket versioning interferes with the garbage-collection sweep is not tested.
> - **Minimum IAM policy.** The permissions above are derived from the verification probe; an exact, tested policy document for AWS is not in the repository yet.
