# File Storage (R2)

Quickback provides built-in file storage using Cloudflare R2.

## Two modes

`defineFileStorage("cloudflare-r2")` has two modes:

- **Presign-only (default)** — emits just the `ctx.storage` signer (presigned
  PUT/GET URLs straight to R2). **No new D1, no `/storage/v1/*` endpoints, no
  files worker.** You compute keys and store file references in your own table.
  This is the recommended path for most apps and the lightest way to add
  uploads — see [Presigned uploads](#presigned-uploads-recommended).
- **Managed (`managed: true`)** — adds the turnkey subsystem on top: a FILES_DB
  metadata database (buckets + objects), the `/storage/v1/*` upload/download/
  manage endpoints, and a public files worker for serving. Opt in only when you
  want buckets/objects handled for you instead of bringing your own media table.

```ts
// Presign-only (default) — ctx.storage against a bucket, nothing else
defineFileStorage("cloudflare-r2", { bucketName: "my-app-media" })

// Managed subsystem — adds FILES_DB + /storage/v1/* + files worker
defineFileStorage("cloudflare-r2", { managed: true, bucketName: "my-app-files" })
```

New projects can omit `bucketName` — it defaults to `<project>-media`, so the
only setup is `wrangler r2 bucket create <name>` once plus the R2 API-token
secrets. Existing projects set `bucketName` to a bucket they already have.

The sections below document the **managed** subsystem; for the default
presign-only path jump to [Presigned uploads](#presigned-uploads-recommended).

## Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│                         Your Application                            │
│                                                                     │
│  Upload/Manage Files              Serve Files                       │
│  ─────────────────────           ──────────────                     │
│  api.yourdomain.com              files.yourdomain.com               │
│  /storage/v1/*                   /*                                 │
│                                                                     │
│  ┌─────────────────────┐         ┌─────────────────────┐           │
│  │ API Worker          │         │ Files Worker        │           │
│  │                     │         │                     │           │
│  │ POST   /bucket      │         │ GET /public/*       │           │
│  │ GET    /bucket      │         │   → No auth         │           │
│  │ POST   /object/*    │         │                     │           │
│  │ DELETE /object/*    │         │ GET /*              │           │
│  │                     │         │   → Session + RBAC  │           │
│  └──────────┬──────────┘         └──────────┬──────────┘           │
│             │                               │                       │
│             └───────────┬───────────────────┘                       │
│                         │                                           │
│                         ▼                                           │
│              ┌─────────────────────┐                                │
│              │   R2 Bucket         │                                │
│              │   quickback-files   │                                │
│              └─────────────────────┘                                │
└─────────────────────────────────────────────────────────────────────┘
```

## Enabling File Storage

Add the `fileStorage` provider to your `quickback.config.ts`:

```typescript
export default {
  name: 'my-app',
  providers: {
    runtime: { name: 'cloudflare' },
    database: { name: 'cloudflare-d1' },
    auth: { name: 'better-auth' },
    fileStorage: {
      name: 'cloudflare-r2',
      config: {
        managed: true,
        binding: 'R2_BUCKET',
        bucketName: 'my-app-files',
        filesBinding: 'FILES_DB',
        maxFileSize: 10 * 1024 * 1024, // 10MB
        allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
      },
    },
  },
};
```

> `managed: true` is what turns on everything on this page — the `/storage/v1/*`
> routes, the files worker, and the `FILES_DB` metadata database. Without it R2
> file storage is **presign-only**: the compiler signs requests against the
> bucket by name and emits **no** bucket binding into `wrangler.toml`, so
> `binding` and `filesBinding` are ignored and `env.R2_BUCKET` is `undefined`
> at runtime (the compile warns when you set them anyway).
>
> To read the bucket directly from your own code in presign-only mode, declare
> it yourself under [`bindings.r2Buckets`](/configure/bindings).


## API Endpoints

### Storage API (api.yourdomain.com/storage/v1)

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/bucket` | Create a bucket |
| GET | `/bucket` | List buckets |
| GET | `/bucket/:name` | Get bucket info |
| DELETE | `/bucket/:name` | Delete bucket (must be empty) |
| POST | `/object/:bucket/*path` | Upload file (bytes through the Worker) |
| POST | `/presign/:bucket/*path` | **Presigned upload URL (bytes direct to R2)** |
| POST | `/confirm/:bucket/*path` | Confirm a presigned upload (reconcile size + etag) |
| GET | `/object/:bucket/*path` | Download file |
| HEAD | `/object/:bucket/*path` | Get file metadata |
| DELETE | `/object/:bucket/*path` | Delete file (soft delete) |
| GET | `/object` | List objects |
| POST | `/url` | Get file URL for serving |

> For anything larger than a small image — video, audio, big PDFs — prefer the
> **presigned** path. The bytes go straight to R2, so you skip the Worker's
> request-body cap and don't burn Worker CPU streaming the upload. See
> [Presigned uploads](#presigned-uploads-recommended) below.


### Files Worker (files.yourdomain.com)

| Method | Path | Auth Required |
|--------|------|---------------|
| GET/HEAD | `/public/*` | No |
| GET/HEAD | `/*` | Yes (session + RBAC) |

## Buckets

Buckets organize files and define access control policies.

### Creating a Bucket

```bash
POST /storage/v1/bucket
{
  "name": "avatars",
  "readScope": "organization",
  "writeScope": "organization",
  "readRoles": ["admin", "member"],
  "writeRoles": ["admin"],
  "deleteRoles": ["admin"]
}
```

### Scope Options

| Scope | Read Behavior | Write Behavior |
|-------|---------------|----------------|
| `public` | Anyone can read | N/A |
| `organization` | Org members only | Org members only |
| `user` | Owner only | Owner only |

> **`writeScope: "user"` is enforced per object, not per organization.** Any
> member of the org may upload to such a bucket, so the object key is the only
> thing separating one member's files from another's — and keys are built from a
> client-supplied path. Before any write, Quickback checks whether the target key
> already has an owner: writing to a key owned by another user returns `403`
> `ACCESS_OWNERSHIP_REQUIRED`.
>
> This applies to presigned uploads too. A signed `PUT` URL *is* a write
> capability, so ownership is checked **before** the URL is issued, not when the
> bytes land.


> **The read policy covers metadata, not just bytes.** `readScope` and
> `readRoles` are applied on every route that can reveal an object — downloads,
> `GET /object` listings, and `POST /url` lookups alike. Being a member of the
> owning organization is not sufficient on its own.
>
> - **Listing** (`GET /object`) returns only objects in buckets you may read.
>   The filter is applied in the query, so `limit` and `offset` page over your
>   readable set — you never receive a short page because rows were removed
>   after the fact. Naming a bucket you cannot read returns `403`
>   `ACCESS_ROLE_REQUIRED`; with no `bucket` filter, unreadable buckets are
>   simply absent.
> - **URL lookup** (`POST /url`) returns `404` when the bucket policy denies
>   you — the same response as an object that does not exist. This is
>   deliberate: a `403` would confirm that the id or key you supplied resolves
>   to a real object, letting a caller enumerate objects they cannot read.
>
> For `readScope: "user"` buckets, both routes additionally require that you own
> the object.


### Role-Based Access

You can restrict operations to specific roles:

```json
{
  "readRoles": ["admin", "member"],
  "writeRoles": ["admin", "editor"],
  "deleteRoles": ["admin"]
}
```

An empty array `[]` means no role restriction (all authenticated users).

## Uploading Files

```bash
POST /storage/v1/object/avatars/profile.jpg
Content-Type: image/jpeg
Content-Length: 12345

<binary data>
```

Response:
```json
{
  "id": "obj_123",
  "key": "org_abc/avatars/profile.jpg",
  "bucket": "avatars",
  "name": "profile.jpg",
  "size": 12345,
  "mimeType": "image/jpeg",
  "readScope": "organization"
}
```

## Presigned uploads (recommended)

Streaming bytes through the Worker (`POST /object`) is fine for small files, but
it puts every byte on the Worker's request path — subject to the body-size cap,
and billed as Worker CPU/duration. For media (video, audio, large PDFs), use a
**presigned URL**: the Worker runs all the access checks and hands back a
short-lived URL the client uploads to **directly**. The bytes never touch your
Worker.

```bash
# 1. Ask the API to sign an upload URL (auth + bucket + role checks run here)
curl -X POST https://api.example.com/storage/v1/presign/videos/clip.mp4 \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "contentType": "video/mp4", "size": 73400320 }'

# Response:
# {
#   "id": "obj_abc",
#   "key": "org_abc/videos/clip.mp4",
#   "uploadUrl": "https://<account>.r2.cloudflarestorage.com/...&X-Amz-Signature=...",
#   "method": "PUT",
#   "headers": { "Content-Type": "video/mp4" },
#   "expiresAt": "2026-06-02T12:10:00.000Z"
# }

# 2. Upload the bytes straight to R2 (replay the returned headers verbatim)
curl -X PUT "<uploadUrl>" -H "Content-Type: video/mp4" --data-binary @clip.mp4

# 3. Confirm — reconciles the recorded size + etag against what actually landed
curl -X POST https://api.example.com/storage/v1/confirm/videos/clip.mp4 \
  -H "Authorization: Bearer <token>"
```

> If `contentType` is supplied at step 1 it is **bound into the signature** — the
> client MUST send exactly that `Content-Type` header on the PUT, or R2 rejects
> it. Omit it to let the client send any type.


### Required secrets

Presigning uses R2's S3-compatible API, which needs an **R2 API token** — the
Workers bucket binding alone cannot presign. Create a token in the Cloudflare
dashboard (R2 → Manage API Tokens) and set:

```bash
wrangler secret put R2_ACCOUNT_ID         # your Cloudflare account id
wrangler secret put R2_ACCESS_KEY_ID      # R2 API token access key id
wrangler secret put R2_SECRET_ACCESS_KEY  # R2 API token secret
# Optional: wrangler secret put R2_S3_ENDPOINT  # override the derived endpoint
```

### Signing from your own action

The same signer is exposed as `ctx.storage` inside any
[`defineAction`](/define/actions) — so you can author an upload endpoint that
runs your own access rules (org, team, relationship/scoped roles) and stores the
key in **your own table**, instead of the generic `buckets`/`objects` metadata:

```typescript title="quickback/features/events/actions/signMediaUpload.ts"
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { images } from "../images";

export default defineAction({
  description: 'Issue a presigned R2 upload URL for a media file on an event.',
  path: '/event/:eventId/media/sign-upload',
  method: 'POST',
  input: z.object({ filename: z.string(), contentType: z.string() }),
  access: { roles: ['admin', 'member', 'scope:event:attendee'] },
  async execute({ input, ctx, db, storage }) {
    // Tenant-scoped key — secure by construction
    const key = `org/${ctx.activeOrgId}/event/${input.eventId}/${crypto.randomUUID()}-${input.filename}`;
    const upload = await storage.signPutUrl(key, {
      contentType: input.contentType,
      expiresIn: 600,
    });
    await db.insert(images).values({ key, eventId: input.eventId /* … */ });
    return { uploadUrl: upload.url, key, headers: upload.headers };
  },
});
```

`storage` exposes:

| Method | Returns | Use |
|--------|---------|-----|
| `signPutUrl(key, { contentType?, expiresIn? })` | `{ url, method, headers, key, expiresAt }` | Client uploads directly to R2 |
| `signGetUrl(key, { expiresIn?, downloadFilename? })` | `string` | Client downloads directly from R2 |

This is the supported replacement for streaming a raw binary body through an
action — the JSON body parser and its ~1 MiB cap stay on for every other route.

## Serving Files

### Public Files

Files in buckets with `readScope: "public"` are stored with a `public/` prefix:

```
https://files.yourdomain.com/public/org_abc/avatars/logo.png
```

No authentication required.

### Private Files

Private files require a valid Better Auth session cookie:

```
https://files.yourdomain.com/org_abc/documents/report.pdf
```

The files worker:
1. Validates the session token
2. Re-checks the caller's **current** membership in the object's organization
3. Verifies role permissions (if `readRoles` configured)
4. Serves the file or returns 403

> Step 2 is a live lookup, not a read of the session row. A session outlives
> membership changes, so its stored active organization only counts while a
> current member row backs it. Remove a member and their org-scoped private-file
> access stops on the next request — including on the JWT fast path, where the
> signed `orgId` and `role` claims are re-derived from the database rather than
> trusted as minted.


## Generated Files

When file storage is configured, the compiler generates:

| File | Purpose |
|------|---------|
| `src/storage/routes.ts` | Storage API routes (upload, presign, confirm, download) |
| `src/storage/presign.ts` | R2 presigned-URL signer (`createPresigner`, backs `ctx.storage`) |
| `src/files/schema.ts` | Files database schema (buckets, objects) |
| `cloudflare-workers/files/index.ts` | Files worker for serving |
| `cloudflare-workers/files/wrangler.toml` | Files worker config |

The compiler also adds [`aws4fetch`](https://github.com/mhart/aws4fetch) to your
`package.json` (used by the presigner) and the R2 presign secrets to your
generated `CloudflareBindings` type.

## Deployment

After compiling:

1. **Create the R2 bucket:**
   ```bash
   wrangler r2 bucket create my-app-files
   ```

2. **Create the files database:**
   ```bash
   wrangler d1 create my-app-files
   ```

3. **Run migrations:**
   ```bash
   wrangler d1 migrations apply my-app-files --local
   wrangler d1 migrations apply my-app-files --remote
   ```

4. **Deploy the API:**
   ```bash
   wrangler deploy
   ```

5. **Deploy the files worker:**
   ```bash
   cd cloudflare-workers/files
   wrangler deploy
   ```

6. **Set the R2 presign secrets** (only if you use presigned uploads — see
   [Presigned uploads](#presigned-uploads-recommended)):
   ```bash
   wrangler secret put R2_ACCOUNT_ID
   wrangler secret put R2_ACCESS_KEY_ID
   wrangler secret put R2_SECRET_ACCESS_KEY
   ```

## Configuration Reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `managed` | `boolean` | `false` | Opt into the FILES_DB subsystem + `/storage/v1/*` + files worker. Off = presign-only. |
| `bucketName` | `string` | `<project>-media` | R2 bucket the signer targets (override per-call with `signPutUrl(key, { bucket })`) |
| `presign` | `object` | - | Env-var name overrides: `{ accountIdEnv, accessKeyIdEnv, secretAccessKeyEnv, endpointEnv }` |
| `binding` | `string` | `R2_BUCKET` | R2 bucket binding name (managed mode) |
| `filesBinding` | `string` | `FILES_DB` | Files metadata D1 binding (managed mode) |
| `maxFileSize` | `number \| string` | 10MB | Max upload size — bytes or `"100mb"` (managed mode) |
| `allowedTypes` | `string[]` | Images only | Allowed MIME types (managed mode) |
| `publicDomain` | `string` | - | Custom domain for files worker (managed mode) |

### How the size limit is enforced

`maxFileSize` (and a bucket's `fileSizeLimit`) is enforced against bytes that
actually arrive, not against what the client claims:

- **Through-Worker uploads** (`POST /object/...`) are read through a byte
  counter that aborts the moment the limit is crossed, so nothing over-limit
  reaches R2. `Content-Length` is still checked first — it cheaply turns away
  honest oversized clients — but it is optional and client-supplied, so it is
  never the enforcement point.
- **Presigned uploads** go straight from the client to R2, and the signature
  binds the content type, not the length. The `size` you declare at presign
  time is therefore advisory. `POST /confirm/...` HEADs the object, compares
  the real size against the bucket limit, and **deletes an over-limit object**
  rather than recording it — otherwise the declared-size check could be
  sidestepped by simply never calling confirm.

Both paths answer `413` with the actual and permitted byte counts.

## Security Model

- **Upload security**: Enforced by API worker (auth middleware, org check, role check)
- **Serve security**: Enforced by files worker (session validation, metadata RBAC)
- **Soft deletes**: Files are marked deleted in metadata but retained in R2
- **Tenant isolation**: All files are prefixed with organization ID
