# Secure S3 File Upload Server for Node.js and Next.js

This guide sets up upup server mode (`mode="server"`): a secure S3 file
upload server on Node.js, mounted in Express, Fastify, Hono or Next.js. In
server mode your server holds the storage credentials and the cloud-drive
OAuth secrets, and it is the trust boundary every upload must pass: the
browser gets a presigned URL only after the server's checks, then PUTs the
bytes straight to your bucket. Cloud-drive files never touch the browser —
the server streams them into the bucket.

This page is the hub — the config object, the routes, the limits, the hooks,
and the observability seam. The per-framework mount recipes live on their
own pages, linked from [Framework adapters](#framework-adapters) below.

Rough time budget: **15–30 minutes** including provider OAuth
registration.

## 1. Install

```sh
pnpm add @useupup/react @useupup/server
```

`@useupup/server` is the Node-side handler. The React package has no
dependency on it — your client bundle stays free of S3 SDKs.

## 2. Mount the handler

The example below is the Next.js App Router; every other framework takes
the same config object through a one-line adapter.

```ts
// app/api/upup/[...route]/route.ts
import { createUpupHandler, InMemoryTokenStore } from '@useupup/server'

const handler = createUpupHandler({
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    },
    // Required, server-only: a stable, high-entropy secret (min 16 chars),
    // shared across every server instance. createUpupHandler throws without it.
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
    providers: {
        googleDrive: {
            clientId: process.env.GOOGLE_CLIENT_ID!,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        },
        dropbox: {
            appKey: process.env.DROPBOX_APP_KEY!,
            appSecret: process.env.DROPBOX_APP_SECRET!,
        },
        // oneDrive, box — same shape
    },
    tokenStore: new InMemoryTokenStore(), // swap for Redis in prod
    getUserId: async req => {
        // Resolve the session user. Return null → OAuth 401s.
        const session = await getSessionFromCookie(req)
        return session?.userId ?? null
    },
})

export const GET = handler
export const POST = handler
```

<Callout type="note">
    `getUserId` (or `auth`) is required once you set `providers` or `tokenStore`
    — it scopes tokens per user, and without it the upload routes return `403
    AUTH_REQUIRED`. See [Server auth](/docs/guides/server-auth/).
</Callout>

The handler routes on path suffix: `/presign`, `/multipart/*`,
`/auth/:provider`, `/auth/:provider/cb`, `/files/:provider`,
`/files/:provider/transfer`, `/health`. All paths are relative to the
folder you mount it at.

Running Express, Fastify, Hono, the Pages Router, or a bare Node server
instead? See [Framework adapters](#framework-adapters) below — same config
object, one adapter call each.

## 3. Point the uploader at it

```tsx
<UpupUploader
    mode="server"
    serverUrl="/api/upup"
    provider="aws"
    sources={['local', 'googleDrive', 'dropbox']}
/>
```

No cloud-drive `clientId` props needed on the client — the server
holds them.

## 4. Register OAuth apps

For each drive you enable:

| Provider     | Console                                                  | Callback URL                                        |
| ------------ | -------------------------------------------------------- | --------------------------------------------------- |
| Google Drive | `console.cloud.google.com` → APIs → OAuth 2.0 Client IDs | `https://yourapp.com/api/upup/auth/google-drive/cb` |
| OneDrive     | `portal.azure.com` → App registrations                   | `https://yourapp.com/api/upup/auth/one-drive/cb`    |
| Dropbox      | `www.dropbox.com/developers/apps`                        | `https://yourapp.com/api/upup/auth/dropbox/cb`      |
| Box          | `app.box.com/developers/console`                         | `https://yourapp.com/api/upup/auth/box/cb`          |

Scopes required:

- Google: `https://www.googleapis.com/auth/drive.readonly`
- OneDrive: `Files.Read.All offline_access` (`offline_access` is what makes
  Microsoft return a refresh token — without it, sessions die when the access
  token expires)
- Dropbox: `files.content.read files.metadata.read`
- Box: `root_readonly`

## 5. Production token store

`InMemoryTokenStore` is a reference implementation. Replace with any
KV-shaped store for production:

```ts
// Redis example
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)

const tokenStore = {
    async get(key) {
        return (await redis.get(key)) ?? null
    },
    async set(key, value, ttlSeconds) {
        if (ttlSeconds) await redis.setex(key, ttlSeconds, value)
        else await redis.set(key, value)
    },
    async delete(key) {
        await redis.del(key)
    },
}
```

The contract is three strings-in-strings-out methods. Cloudflare KV,
DynamoDB, Postgres — anything shaped like this works.

## 6. Tuning

```ts
createUpupHandler({
    // ...storage, uploadTokenSecret, and providers from step 2
    maxFileSize: 500 * 1024 * 1024, // 500 MB
    allowedTypes: ['image/*', 'video/*'],
    multipartResumeWindowSeconds: 86400, // 24h; 0 disables POST /multipart/resume
    hooks: {
        onBeforeUpload: async (file, req) => {
            // Return false to reject the upload
            return true
        },
        onFileUploaded: async (file, req) => {
            // Persist a DB row pointing at file.key
        },
    },
})
```

The server→S3 multipart cutoff on the **cloud-drive transfer** path is
fixed and not configurable: files up to 5 MiB stream through as a single
PUT; larger files use S3 multipart with 5 MiB chunks. Server memory
envelope is one chunk at a time regardless of file size — the old
`multipartThreshold` knob was removed so the memory bound cannot be raised
away by configuration.

## 7. Large files: the multipart flow

<MultipartFlowDiagram />

By default a file is uploaded whole: `POST /presign` hands the browser one
signed URL and the bytes go straight to storage. Turn on multipart
(client-side `resumable: { protocol: 'multipart' }`, threshold 5 MiB by
default — see [Resumable uploads](/docs/resumable-uploads/)) and the
browser drives the lifecycle above against your server instead. The diagram
shows the happy path; steps 4 and 5 below cover finishing and recovering:

1. **`POST /multipart/init`** — the server starts the S3 upload and returns
   the object key, the `uploadId`, the part size, and an **HMAC-signed
   token**. The token binds the key, the `uploadId`, the owning user, an
   expiry, and a size envelope. It is the only state the client carries;
   the server keeps no session.
2. **`POST /multipart/sign-part`** — one call per part, sending the token
   and a part number, answered with a presigned URL for exactly that part.
3. **The part `PUT`s go browser → storage directly.** Part bytes never
   traverse your server on this path, so a 5 GB upload costs it nothing but
   the signing calls. Parts are 5 MiB by default and floored at S3's 5 MiB
   minimum; the browser uploads several in parallel and collects each
   part's `ETag`.
4. **`POST /multipart/complete`** — the server sums the bytes S3 actually
   received, rejects and aborts the upload with `403` if the total falls
   outside the token's signed envelope, and only then finalizes the object.
   An explicit cancel calls `POST /multipart/abort`.
5. **`POST /multipart/resume`** — the recovery step. The browser presents the
   token it saved, and the server answers with the parts storage already holds
   (each with its byte size) plus a freshly-signed token. That is how an upload
   survives a reload or a tab close, and how a token that outlived its one-hour
   expiry is refreshed mid-upload. Its window is `multipartResumeWindowSeconds`
   (24 hours by default, measured from the original `init`); set it to `0` to
   switch the route off entirely.

<Callout type="warning" title="Set an AbortIncompleteMultipartUpload lifecycle rule">
    Because resume is on by default client-side, a failed or abandoned
    multipart upload is **not** aborted server-side — its parts are kept
    deliberately, so the next attempt continues from them. Parts nobody ever
    resumes are never cleaned up by upup and S3 bills for them.

    Configure an `AbortIncompleteMultipartUpload` lifecycle rule on the bucket
    with a 1–7 day expiry. Every S3-compatible provider supports it, MinIO
    included. See
    [Cross-reload resume](/docs/resumable-uploads/#cross-reload-resume).

</Callout>

Two consequences worth knowing:

- **Any instance can serve any step.** The signed token replaces
  server-side session state, so a load-balanced or serverless deployment
  works as long as every instance shares the same `uploadTokenSecret`.
  Different secrets across instances is the classic mid-upload failure —
  the `/health` fingerprint below catches it.
- **The token's owner is enforced, not just its signature.** With
  `getUserId` configured, `sign-part`, `complete`, `abort`, and `resume` all
  check that the caller is the user the token was issued to and answer `403
AUTH_DENIED` otherwise. Without a `getUserId` resolver, possession of the
  token is the model.
- **`resume` is the one route that tolerates an expired token**, since
  refreshing one is its job. It applies the resume window instead, anchored at
  the original `init` and carried forward on every re-issue, so the usable life
  of a leaked token is bounded even across a chain of resumes. See
  [Server Auth & Trust Model](/docs/guides/server-auth/#the-upload-token).

This is a different path from the cloud-drive transfer described in
[Limits](#the-drive-transfer-memory-bound), where the server itself pulls
bytes from a drive and pushes them to S3.

## 8. Re-authentication

When an OAuth access token expires, the server returns
`401 { reauth: true }`. The React component catches this and surfaces
the provider's "Sign in" button. One click re-auths and the user
continues where they left off.

Re-auth is the fallback, not the routine: when the provider issued a
refresh token, the server stores it alongside the access token (as a
no-expiry entry in `tokenStore`) and refreshes proactively before drive
calls, so users rarely see the prompt. The `reauth: true` path fires
when there is no refresh token or the refresh itself fails.

## Framework adapters

`createUpupHandler` is a plain `(req: Request) => Promise<Response>`
function. Every adapter wraps that same handler and takes the same config
object — the one you built in step 2.

| Framework                                                      | Entry point              | Import from               |
| -------------------------------------------------------------- | ------------------------ | ------------------------- |
| [Express](/docs/guides/server-adapters/express/)               | `createUpupMiddleware`   | `@useupup/server/express` |
| [Fastify](/docs/guides/server-adapters/fastify/)               | `createUpupPlugin`       | `@useupup/server/fastify` |
| [Hono](/docs/guides/server-adapters/hono/)                     | `createUpupRoutes`       | `@useupup/server/hono`    |
| [Next.js — App Router](/docs/guides/server-adapters/nextjs/)   | `createUpupNextHandler`  | `@useupup/next/server`    |
| [Next.js — Pages Router](/docs/guides/server-adapters/nextjs/) | `createUpupPagesHandler` | `@useupup/next/server`    |

Each page carries the complete mount recipe for that framework plus its own
body-parsing, proxy-origin, and CORS notes — those genuinely differ, and
getting them wrong is the most common first-run failure.

### One config, every adapter

Define the config once and share it:

```ts
// lib/upup-config.ts
import type { UpupServerConfig } from '@useupup/server'

export const upupConfig: UpupServerConfig = {
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    },
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
    // providers, tokenStore, getUserId… as in step 2
}
```

In a Next.js app you can author the same object through
`defineUpupConfig` from `@useupup/next/server` for editor autocomplete —
it's a typed pass-through, not a second validation layer (required-field
validation always happens inside `createUpupHandler`, so direct callers
are protected too).

### Custom Node server

Any Node framework without a dedicated adapter can reuse the same bridge
the Express, Fastify, and Pages Router adapters are built on —
`toWebRequest` and `writeWebResponse` from `@useupup/server/node-bridge`.
Don't hand-roll the conversion; the bridge already handles multi-value
headers and skips `content-length` (Node recomputes it, and a copied value
risks a mismatch).

```ts
import { createServer } from 'node:http'
import { createUpupHandler } from '@useupup/server'
import { toWebRequest, writeWebResponse } from '@useupup/server/node-bridge'
import { upupConfig } from './lib/upup-config'

const handler = createUpupHandler(upupConfig)

createServer(async (req, res) => {
    const chunks: Buffer[] = []
    for await (const chunk of req) chunks.push(Buffer.from(chunk))

    const webReq = toWebRequest({
        url: new URL(req.url ?? '/', `http://${req.headers.host}`).toString(),
        method: req.method ?? 'GET',
        headers: req.headers,
        // toWebRequest drops the body for GET/HEAD on its own.
        body: chunks.length ? Buffer.concat(chunks) : undefined,
    })

    await writeWebResponse(
        {
            status: code => {
                res.statusCode = code
            },
            setHeader: (name, value) => {
                res.setHeader(name, value)
            },
            send: body => {
                res.end(body)
            },
        },
        await handler(webReq),
    )
}).listen(3000)
```

The sink is three methods — `status`, `setHeader`, `send` — so Express's
`res`, Fastify's `reply`, and `NextApiResponse` all satisfy it with a thin
rename.

## Lifecycle hooks

Four optional hooks let you gate uploads, rewrite what the server hands back,
and react to completions:

```ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    hooks: {
        onBeforeUpload: async (file, req) => true, // false rejects with 403
        onPresignResponse: (response, ctx) => {
            // return an object to replace the payload; nothing to keep it
        },
        onFileUploaded: async (file, req) => {
            // one file finished — file.key, .name, .size, .type, .url
        },
        onUploadComplete: async (files, req) => {
            // a request's file(s) finished
        },
    },
})
```

**Which hook fires on which path.** Read this before wiring alerting,
billing, or webhooks on top of them — the gaps are structural, not bugs:

| Route                            | `onBeforeUpload` | `onPresignResponse` | `onFileUploaded` | `onUploadComplete` |
| -------------------------------- | ---------------- | ------------------- | ---------------- | ------------------ |
| `POST /presign`                  | yes              | yes                 | no               | no                 |
| `POST /multipart/init`           | yes              | yes                 | no               | no                 |
| `POST /multipart/sign-part`      | no               | yes                 | no               | no                 |
| `POST /multipart/complete`       | no               | no                  | yes              | yes                |
| `POST /files/:provider/transfer` | no               | no                  | yes              | no                 |

- **`onBeforeUpload` is an admission gate, not a completion signal.** It runs
  during metadata validation on `/presign` and `/multipart/init`, after the
  `maxFileSize` and `allowedTypes` checks. Returning `false` responds
  `403 Upload rejected` and nothing is presigned. To explain the rejection,
  **throw an `UpupError`** instead — see
  [Explaining a rejection](#explaining-a-rejection) below.
- **`onPresignResponse` is the only hook that can change a response body.** It
  sees the three presign-side payloads and nothing else — see
  [Rewriting presign responses](#rewriting-presign-responses).
- **`onFileUploaded` fires once per file on the two server-side-completion
  paths only:** `/multipart/complete` (the server just finished the S3
  multipart upload) and `/files/:provider/transfer` (the server just finished
  streaming a cloud-drive file into S3). In both cases the server can actually
  see the finished object.
- **`onUploadComplete` fires only on `/multipart/complete`, always with a
  single-element array.** The server completes one file per request and has no
  cross-file batching concept. For a true "the whole batch is done" signal,
  use the client-side `onUploadComplete` prop instead — that one sees the
  entire selection.
- **Client-direct presigned PUTs fire no server hook at all.** `POST /presign`
  only hands the browser a URL; the bytes then go straight to S3 and the server
  never observes completion. If you need server visibility into that path, use
  the client-side `onUploadComplete` prop, or point `processingEndpoint` at an
  SSE route so the client tells your server when it's done.
- On the multipart-complete path, `file.type` is always `''` — the declared
  MIME type isn't retained server-side once the upload completes.

A hook that throws **after** a successful upload is reported through
`onError` and swallowed, never re-coded as a 500. The object is already
durably in S3, so a 500 would only tell the client to retry something that
already succeeded.

### Rewriting presign responses

If your bucket is not reachable from the browser — a private MinIO behind a
same-origin proxy route, a docker-internal hostname in local dev, a VPC-only
endpoint — the signed URL the server produces is not the URL the browser can
use. `onPresignResponse` gets the last look at the payload and can replace it:

```ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    hooks: {
        onPresignResponse: (response, ctx) => {
            if (!('uploadUrl' in response)) return
            return {
                ...response,
                uploadUrl: response.uploadUrl.replace(
                    'https://minio.internal:9000',
                    'https://app.example.com/api/s3',
                ),
            }
        },
    },
})
```

Return an object to replace the payload; return nothing to leave it alone.

It fires on exactly three responses, told apart by `ctx.phase`:

| `ctx.phase`           | Route                       | Payload                                  |
| --------------------- | --------------------------- | ---------------------------------------- |
| `presign`             | `POST /presign`             | `PresignedUrlResponse`                   |
| `multipart-init`      | `POST /multipart/init`      | `MultipartInitResponse` plus the `token` |
| `multipart-sign-part` | `POST /multipart/sign-part` | `MultipartSignPartResponse`              |

`ctx` also carries `req`, the server-chosen `key` (always the key in the
payload), the resolved `userId`, and `metadata` — the client-declared file
metadata, absent on `multipart-sign-part`, which sees only a verified token and
a part number.

<Callout type="warn">
    The hook runs **after** every auth, policy, and token check, so it cannot
    widen what a caller was allowed to do — a request that would answer 401 or
    403 never reaches it. But rewriting `uploadUrl` does change where the
    browser sends bytes: the URL you substitute must land at the same object,
    and whatever proxy it points at is now part of your upload path.
</Callout>

Cover all three phases if you use multipart. Rewriting only `/presign` leaves
multipart uploads pointed at the unreachable host.

### Explaining a rejection

`onBeforeUpload` returning `false` answers a deliberately generic
`403 { "error": "Upload rejected" }` — it says nothing about why. When the
reason is something the user should see, throw an `UpupError` instead and its
message and code are serialized into the 403 body:

```ts
import { UpupQuotaError } from '@useupup/core'

hooks: {
    onBeforeUpload: async (file, req) => {
        const { used, limit } = await getQuota(req)
        if (used + file.size > limit) {
            throw new UpupQuotaError(
                'Storage limit exceeded — upgrade to keep uploading',
                limit,
                used,
            )
        }
        return true
    }
}
```

```json
{
    "error": "Storage limit exceeded — upgrade to keep uploading",
    "code": "QUOTA_EXCEEDED"
}
```

Only `UpupError` and its subclasses are serialized, and only from this hook.
Any other throw stays a generic `500` with the real cause going to `onError`
alone — an accidental `Error` carrying a connection string or a stack trace
never reaches the client.

## Observability

### The `onError` seam

Every error path in the handler — 500s, invalid upload tokens, OAuth and
token-exchange failures, failed drive-transfer aborts, health-check storage
failures — routes through one logger:

```ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    onError: event => {
        // event: route, method, status, code, message, requestId,
        //        error: { name, message, stack }
        myLogger.error('upup-server', event)
    },
})
```

If you don't supply `onError`, the default writes one structured line via
`console.error('[upup:server]', JSON.stringify(event))` — error visibility
is on out of the box, not something you wire up after your first incident.
Pass a no-op to silence it.

**Redaction guarantee.** An error event is only ever built from a static
route string, the request method, the HTTP status, a machine `code`, a
generic message, and the caught error's `name` / `message` / `stack`.
Request bodies, drive tokens, `uploadTokenSecret`, S3 credentials,
signatures, and `Authorization` headers are never put into an event.

That contract is enforced structurally, not by convention: every event
passes through a scrubber at the single reporting seam before it reaches
your logger, so even an error whose message accidentally interpolated a
credential is cleaned. It rewrites `Authorization` header dumps, bare
`Bearer` tokens, the SigV4 `x-amz-signature` / `x-amz-credential` /
`x-amz-security-token` params, and AWS access-key ids to `[REDACTED]`.
It's deliberately conservative — ordinary stack frames, file paths, and
function names survive intact. Treat it as defense in depth, not licence to
build an event out of a secret.

### Request IDs

Every response the handler produces carries an `x-upup-request-id` header,
and the same value appears as `event.requestId` in your `onError` events.
That's the join key between a client-reported failure and your server logs —
health responses included; there are no exceptions to the contract.

### The `/health` endpoint

```sh
curl https://yourapp.com/api/upup/health
```

```json
{
    "status": "ok",
    "checks": { "config": "ok", "storage": "ok" },
    "summary": {
        "storageType": "aws",
        "anonymousUploads": false,
        "anonymousDrives": false,
        "driveProviders": 2,
        "uploadTokenTtlSeconds": 3600
    }
}
```

It's unauthenticated by design — the check runs _before_ `config.auth`, so
uptime and deploy probes work without credentials — and **always responds
200**. The `status` field carries the real signal, so an orchestrator won't
restart a container over a transient S3 blip.

- `checks.config` is `ok` when `storage.bucket`, `storage.region`, and a
  valid-length `uploadTokenSecret` are all present.
- `checks.storage` is a cheap bucket-head probe (no listing, no transfer),
  cached for 30 seconds so repeated polling doesn't hammer S3. A failed probe
  is also reported through `onError`.
- `summary` is labels, flags, and counts only — never a secret value.

To catch cross-instance secret drift after a rolling deploy, opt into a
fingerprint:

```ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    health: { exposeSecretFingerprint: true },
})
```

That adds `uploadTokenFingerprint` — the first 8 hex characters of
SHA-256(`uploadTokenSecret`). Two instances showing different fingerprints
are running different secrets, which breaks multipart uploads that start on
one and continue on another. It's a one-way hash, not the secret. Default is
off.

Full request/response shapes for every route:
[Server HTTP API](/docs/api-reference/server-http/). For client-side error
wiring, see [Error monitoring](/docs/guides/error-monitoring/).

## Multi-bucket routing

`storage` takes either one static object or a **resolver** called per request,
for apps that split uploads across buckets — images in one, unscanned documents
in a quarantine bucket, each tenant in their own:

```ts
import { defineUpupConfig } from '@useupup/next/server'

const BUCKETS = {
    images: { type: 'aws', bucket: 'app-images', region: 'us-east-1' },
    quarantine: { type: 'aws', bucket: 'app-quarantine', region: 'us-east-1' },
    documents: { type: 'aws', bucket: 'app-docs', region: 'eu-west-1' },
} as const

export default defineUpupConfig({
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET,
    storage: ctx => {
        // A continuation of a multipart upload: return the bucket it started in.
        if (ctx.storageId) return byIdentity(ctx.storageId)

        const requested = ctx.metadata?.uploadClass
        if (requested === 'images') return BUCKETS.images
        if (requested === 'unscanned') return BUCKETS.quarantine
        return BUCKETS.documents
    },
})
```

The client picks a class by sending a `metadata` object alongside the file's
name, type, and size in the presign body. `ctx` carries `req`, `phase`,
`userId`, `metadata`, `fileName`, `contentType`, `size` — and `storageId` on
multipart continuations.

<Callout type="warn">
    `ctx.metadata` is **whatever the client sent**. Switch on it against a fixed
    map like the one above; never build a bucket name, a path prefix, or a
    credential out of it. A resolver that does `bucket: ctx.metadata.bucket`
    hands every caller the run of your account.
</Callout>

### How multipart stays in one bucket

A multipart upload resolves its bucket once, at `init` — but `sign-part`,
`complete`, and `abort` arrive later carrying only a token, with none of the
metadata that decision was made from. upup closes that gap by stamping an
opaque **storage identity** into the HMAC-signed upload token at `init` and
handing it back to your resolver as `ctx.storageId`.

Return the storage matching that id and the upload proceeds. The server then
re-derives the identity of whatever you returned and answers
`403 AUTH_DENIED` on a mismatch, so a resolver that ignores `storageId` fails
loudly instead of writing parts into the wrong bucket. The client never supplies
the identity unsigned, and it is a hash of bucket, endpoint, and region only —
never of your credentials, so rotating an access key does not strand uploads in
flight.

A token issued while `storage` was still a static object carries no identity;
if you then switch to a resolver, uploads already in flight answer `403` and
restart from `init`. Upload tokens live one hour.

### What changes with a resolver

- **Validation moves to request time.** A static config is checked when
  `createUpupHandler` is constructed; a resolver has nothing to check until a
  request arrives, so a bad result fails that one request with a `500` and the
  cause goes to `onError`. There is no fallback bucket — a misrouted write is
  worse than a failed one.
- **`/health` reports `storage: "skipped"`** and `storageType: "dynamic"`.
  There is no single destination to probe, and calling your resolver from an
  unauthenticated liveness route would reach a real backend.
- **S3 clients are cached per destination**, keyed on endpoint, region, bucket,
  and access key ID, so a multi-bucket deployment does not rebuild a client and
  its connection pool on every presign.

### `keyStrategy` sees it too

`keyStrategy` now receives `metadata` and `req` alongside the existing
`userId` / `fileName` / `contentType` / `size` — existing strategies keep
working unchanged:

```ts
keyStrategy: ctx =>
    `${ctx.metadata?.tenant ?? 'shared'}/${ctx.userId ?? 'anon'}/${crypto.randomUUID()}`
```

The same warning applies: `ctx.metadata` is untrusted, and here it lands in an
object key. Sanitize anything you interpolate.

## Download URLs

Every route that reports a stored object hands back a **presigned GET** —
`downloadUrl` on `/presign` and `/multipart/complete`, `url` on
`/files/:provider/transfer`. Those links expire after **3 days** by default.

### `downloadUrlExpiresIn`

One knob sets the TTL, in seconds, for every signed GET the server issues:

```ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    downloadUrlExpiresIn: 900, // 15 minutes
})
```

It covers the download half only. The **upload** URL's own 1-hour expiry is
separate and unaffected, so shortening download links never shortens the
window a large upload has to finish in.

### Gated downloads: `getDownloadUrl`

For content you serve later — a link on a dashboard, an email attachment, a
paywalled asset — you need a fresh URL for a key stored weeks ago, with no
upload in flight. `getDownloadUrl` is that operation on its own, with no
handler and no HTTP route involved:

```ts
import { getDownloadUrl } from '@useupup/server'
import { upupConfig } from './lib/upup-config'

const url = await getDownloadUrl(upupConfig, invoice.storageKey, {
    expiresIn: 300, // 5 minutes, for this link only
})
```

The first argument is your server config, or any object with a `storage`
slice — it reuses the same credentials and endpoint the handler uses. Expiry
resolves in this order:

1. the per-call `expiresIn`,
2. `config.downloadUrlExpiresIn`,
3. the 3-day default.

Two things to keep in mind:

- **It authorizes nothing.** It signs whatever key you hand it. Check that the
  current user may see that object _before_ you call it — this is the same
  trust position as `S3Client.getSignedUrl`, not a replacement for your access
  control.
- **S3-compatible providers only**, like the rest of the package. A
  `storage.type` with no S3 API (`azure`) throws `UpupConfigError`, the same
  error `createUpupHandler` throws at construct time.

## Limits

### `maxFileSize`

Enforced on both upload paths, and on the drive path it's enforced twice:

- `POST /presign` and `POST /multipart/init` reject a declared size over the
  limit with `413 File too large` before anything is signed.
- `POST /files/:provider/transfer` fast-rejects the drive-declared size with
  `413`, then enforces the cap again against the bytes actually streamed. A
  file that lies about its size is aborted mid-transfer and leaves nothing
  behind in the bucket — the streamed-byte check is the authoritative one.

### `allowedTypes`

An allowlist of MIME types; `415 File type not allowed` otherwise. One shared
policy across the upload and drive-transfer paths:

- Omitted or empty → every type passes.
- An `image/*` entry honours the wildcard.
- An absent or empty type does **not** match a non-empty allowlist. A file
  with no declared MIME type is rejected, never silently waved through.

These are server-side policy. The client-side `maxFileSize` / file-type props
give the user a fast local error; the server checks are what actually hold,
since a client can be bypassed.

### The drive-transfer memory bound

When the server pulls a file out of a cloud drive and pushes it to S3, its
memory envelope is fixed at **5 MiB** regardless of file size. A file whose
size the drive reports as 5 MiB or less goes through as a single PUT (one
buffered body); everything else — including anything the drive reports no
size for — streams through bounded 5 MiB multipart parts, one part in memory
at a time.

This cutoff is **not configurable, deliberately**. The old
`multipartThreshold` knob was removed because raising it reintroduced
unbounded buffering — a memory-safety bound must not be something an
integrator can raise away. There is no replacement setting; a 5 GB drive file
and a 5 MB one cost the server the same memory.
