@useupup/server 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Devino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # @useupup/server
2
+
3
+ Server-mode endpoints for [upup](https://github.com/DevinoSolutions/upup): S3/MinIO
4
+ presign + proxy upload, drive-token exchange (Google Drive / OneDrive /
5
+ Dropbox / Box), and an HMAC-signed upload-token trust model so the client
6
+ never asserts the object key or S3 `uploadId` it's writing to.
7
+
8
+ `createUpupHandler(config)` returns a framework-agnostic `(req: Request) =>
9
+ Promise<Response>`. Thin adapters wire it into Express, Fastify, Hono, and
10
+ Next.js (`@useupup/server/express`, `/fastify`, `/hono`, `/next`).
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install @useupup/server
16
+ ```
17
+
18
+ ## Minimal config
19
+
20
+ ```ts
21
+ import { createUpupHandler } from '@useupup/server'
22
+
23
+ const handler = createUpupHandler({
24
+ storage: {
25
+ type: 'aws',
26
+ bucket: 'my-bucket',
27
+ region: 'us-east-1',
28
+ // accessKeyId/secretAccessKey — omit to use the environment's default
29
+ // AWS credential chain. Set `endpoint` (+ optionally `forcePathStyle`)
30
+ // for MinIO / R2 / DigitalOcean Spaces / any S3-compatible provider.
31
+ },
32
+ uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET,
33
+ })
34
+ ```
35
+
36
+ By default, `POST /presign` and `POST /multipart/init` reject anonymous
37
+ callers with `403 AUTH_REQUIRED` unless you configure `auth`, `getUserId`, or
38
+ opt in explicitly:
39
+
40
+ ```ts
41
+ createUpupHandler({ /* ... */, allowAnonymousUploads: true })
42
+ ```
43
+
44
+ `allowAnonymousUploads` collapses every caller into one shared anonymous
45
+ namespace — fine for demos and upstream-auth deployments (tus/companion-style,
46
+ where authentication happens before the request reaches this handler), never
47
+ for multi-tenant production.
48
+
49
+ ### `uploadTokenSecret` is REQUIRED — and must match across every instance
50
+
51
+ `createUpupHandler` throws at construction time if `uploadTokenSecret` is
52
+ missing or under 16 characters. This secret HMAC-signs the stateless upload
53
+ token issued at multipart-init and re-verified on every sign-part / complete
54
+ / abort / resume call — it is what binds the client-supplied token to the
55
+ server-chosen object key and S3 `uploadId` so a client can never assert
56
+ either on the way back.
57
+
58
+ **It must be byte-for-byte identical on every server instance / worker /
59
+ lambda that can see the same upload.** A rolling redeploy that generates a
60
+ fresh secret per instance (or a mismatched secret between regions/canaries)
61
+ means requests routed to a different instance than the one that issued the
62
+ token fail multipart uploads with `403 bad_signature` — indistinguishable
63
+ from a real forgery unless you know to check this. Generate one secret and
64
+ inject it as a shared config value (env var / secret manager), e.g.:
65
+
66
+ ```sh
67
+ openssl rand -hex 32
68
+ ```
69
+
70
+ See [`/health`](#health) below for a way to detect this drift across a fleet
71
+ without comparing the secret value directly.
72
+
73
+ #### Token semantics: TTL & replay
74
+
75
+ An upload token is issued at `/multipart/init`, re-verified on every
76
+ `/multipart/sign-part`, `/multipart/complete`, and `/multipart/abort` call, and
77
+ re-issued by `/multipart/resume`. It carries no nonce — expiry is the only
78
+ freshness check:
79
+
80
+ - **TTL.** `exp` is set to `now + DEFAULT_UPLOAD_TOKEN_TTL_SECONDS` (3600s / 1
81
+ hour) at init. Once `exp` passes, sign-part / complete / abort reject it with
82
+ `403 {code: 'expired'}`.
83
+ - **Refresh.** `/multipart/resume` is the one route that accepts an expired
84
+ token, because exchanging it for a fresh one is its purpose — an upload can
85
+ outlive an hour. The replacement re-signs the same `k`/`u`/`uid`/`smin`/`smax`
86
+ with a new `exp`, so nothing about what the token authorizes changes.
87
+ - **Resume window.** The relaxed expiry is re-bounded by
88
+ `multipartResumeWindowSeconds` (default 86400 / 24h), measured from `iat` —
89
+ the ORIGINAL init, carried forward unchanged on every re-issue, so a chain of
90
+ resumes cannot extend it. Past the window, resume answers
91
+ `403 {code: 'expired'}`. Tokens minted before `iat` existed fall back to
92
+ `exp - TTL`. Set the knob to `0` to remove the route entirely; a negative or
93
+ fractional value throws `UpupConfigError` at construction.
94
+ **The trade:** a leaked token is usable for the window rather than an hour,
95
+ but only to continue the same upload, to the same key, inside the same signed
96
+ size envelope, still owner-bound whenever `getUserId` is set.
97
+ - **Replay window.** Within that hour, the _same_ token may be sent to
98
+ sign-part/complete/abort **any number of times** — this is by design, not a
99
+ bug: a client legitimately re-signs a part after a network retry, or drives
100
+ a multi-part upload with many sequential sign-part calls, all against one
101
+ init-issued token. `handler-extended.test.ts`'s `F-107` suite pins this
102
+ accepted property at the HTTP boundary.
103
+ - **What replay is bounded by.** A replayed token can only re-drive parts
104
+ within the S3 `uploadId` it was issued for, and only within the `smin`/`smax`
105
+ byte envelope signed at init (enforced at `/multipart/complete` — see
106
+ [Error codes](#error-codes)). When `getUserId` is configured, replay is
107
+ further bound to the uid that owned the token at init — a different
108
+ authenticated user replaying a leaked token gets `403 AUTH_DENIED` (see
109
+ `allowAnonymousUploads` below and the F-106 uid-binding tests).
110
+ - **No single-use / nonce enforcement, by design.** This token model is
111
+ intentionally stateless — there is no consumed-nonce tracking, so a token is
112
+ valid for every call until `exp`, not just the first. If your deployment
113
+ needs single-use tokens, back the token verification with a nonce/jti store
114
+ in your `TokenStore` implementation (not provided out of the box).
115
+
116
+ ## Cross-reload resume — one thing you must configure
117
+
118
+ `POST /multipart/resume` lets a client re-attach to an upload a page reload,
119
+ tab close, or crash left in flight. It takes `{ token }` and nothing else, and
120
+ answers `{ key, token, parts }` — the parts the provider already holds, each
121
+ with its byte size, plus a freshly-signed token. The `uploadId` is never sent
122
+ by the client and never returned.
123
+
124
+ ```ts
125
+ createUpupHandler({
126
+ // ...storage, uploadTokenSecret
127
+ multipartResumeWindowSeconds: 86400, // default; 0 removes the route
128
+ })
129
+ ```
130
+
131
+ **Because upup's client keeps resume on by default, an interrupted multipart
132
+ upload is no longer aborted server-side — its parts are kept so the next
133
+ attempt can continue from them.** Parts nobody ever resumes are not cleaned up
134
+ by this package, and S3 bills for them.
135
+
136
+ Configure an `AbortIncompleteMultipartUpload` lifecycle rule on the bucket with
137
+ a 1–7 day expiry. Every S3-compatible provider supports it, MinIO included.
138
+ This is the one piece of operational setup the feature asks of you.
139
+
140
+ ## Error codes
141
+
142
+ Every non-2xx JSON response body is `{ error: <generic human message>, code:
143
+ <machine code> }`. The human message is safe to log/display as-is; the
144
+ `code` is the stable value to branch on (retry logic, alerting, i18n
145
+ mapping). The real exception detail (name/message/stack) is **never** sent
146
+ to the client — it goes only to the [`onError`](#onerror--the-logging-seam) seam.
147
+
148
+ | `code` | Where it comes from | Typical cause |
149
+ | --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
150
+ | `PRESIGN_FAILED` | `POST /presign` 500 | Storage provider rejected the presign call |
151
+ | `STORAGE_ERROR` | multipart init/sign-part/complete/abort/resume 500, drive list/transfer 500, uncaught router error | S3/MinIO call failed, or an unhandled exception anywhere in routing |
152
+ | `NOT_FOUND` | `POST /multipart/resume`, 404 | The provider no longer holds that multipart upload — completed, aborted, or reaped by a lifecycle rule. A 4xx on purpose: the client drops its session and starts fresh |
153
+ | `BAD_REQUEST` | any route, 400 | Empty body, malformed JSON, or invalid file metadata (missing/wrong-typed `name`/`type`/`size`) |
154
+ | `AUTH_REQUIRED` | `POST /presign`, `POST /multipart/init`, 403 | Neither `auth`, `getUserId`, nor `allowAnonymousUploads` is configured — anonymous uploads are rejected by default |
155
+ | `AUTH_DENIED` | multipart sign-part/complete/abort/resume, 403 | The resolved caller (via `getUserId`) doesn't match the uid the upload token was issued to |
156
+ | `AUTH_PROVIDER_ERROR` | OAuth token exchange, 502 | The provider's token endpoint rejected the code/refresh-token exchange |
157
+ | `AUTH_EXPIRED` | drive token refresh failure (internal) | Refresh token dead/revoked — forces a clean re-auth |
158
+
159
+ Upload-token verification failures (multipart sign-part/complete/abort/resume)
160
+ are a separate, narrower vocabulary — always `403` — because they describe the
161
+ token itself, not a storage/auth outcome:
162
+
163
+ | Token `code` | Meaning |
164
+ | --------------- | ---------------------------------------------------------------------------------------------------- |
165
+ | `malformed` | Token isn't the expected `body.signature` shape, or its payload is missing required fields |
166
+ | `bad_signature` | Token is well-shaped but the HMAC signature doesn't verify (wrong/rotated secret, or tampering) |
167
+ | `expired` | Token's `exp` claim is in the past — or, on `/multipart/resume` alone, the resume window has elapsed |
168
+
169
+ On the client, `@useupup/core`'s upload strategies read these bodies and
170
+ construct typed errors (`UpupStorageError` / `UpupAuthError`) carrying the
171
+ same `.code`, and `errorCodeToMessageKey()` maps a code to an i18n catalog
172
+ key for display.
173
+
174
+ ## `onError` — the logging seam
175
+
176
+ ```ts
177
+ createUpupHandler({
178
+ // ...
179
+ onError(event) {
180
+ // event: { route, method, status, code, message, requestId?,
181
+ // error?: { name, message, stack? } }
182
+ myLogger.error('upup-server', event)
183
+ },
184
+ })
185
+ ```
186
+
187
+ Called on every error path (500s, invalid upload tokens, OAuth/token-exchange
188
+ failures, health-check storage failures). **Guaranteed never to receive**
189
+ request bodies, tokens, `uploadTokenSecret`, S3 credentials, signatures, or
190
+ `Authorization` headers — only a static route string, the HTTP method,
191
+ status, machine `code`, a generic message, and the caught error's
192
+ `name`/`message`/`stack`.
193
+
194
+ If you don't supply `onError`, the default logs one structured line via
195
+ `console.error('[upup:server]', JSON.stringify(event))` — so error visibility
196
+ is on by default, not something you have to wire up before your first
197
+ incident. Override it to ship events to Datadog/Sentry/your own sink, or
198
+ supply a no-op to silence it.
199
+
200
+ ## Lifecycle hooks
201
+
202
+ ```ts
203
+ createUpupHandler({
204
+ // ...
205
+ hooks: {
206
+ onBeforeUpload: async (file, req) => true, // reject by returning false
207
+ onFileUploaded: async (file, req) => {
208
+ /* one file completed */
209
+ },
210
+ onUploadComplete: async (files, req) => {
211
+ /* a request's file(s) completed */
212
+ },
213
+ },
214
+ })
215
+ ```
216
+
217
+ **Which hook fires on which upload path — read this before wiring alerting or
218
+ webhooks on top of these:**
219
+
220
+ - **`onFileUploaded`** fires once per file on both server-side-completion
221
+ paths: `POST /multipart/complete` (server has just finished the S3
222
+ multipart upload) and the drive **transfer** path,
223
+ `POST /files/:provider/transfer` (server has just finished streaming a
224
+ cloud-drive file into S3). Both are genuinely server-side completions — the
225
+ server can see the finished object.
226
+ - **`onUploadComplete`** fires only on `POST /multipart/complete`, and always
227
+ with a **single-element array** — the server completes one file per
228
+ request and has no cross-file batching concept. If you need a true "all the
229
+ user's files are done" signal, use the client-side `onUploadComplete` prop
230
+ in `@useupup/core`/the UI packages instead, which does see the whole batch.
231
+ - **Client-direct presigned-PUT uploads bypass the server entirely** (`POST
232
+ /presign` only hands the client a URL; the browser then PUTs straight to
233
+ S3), so **no server-side hook fires for that path at all** — the server
234
+ never observes completion. If you need server-visibility into presigned
235
+ uploads, use the client-side `onUploadComplete` prop, or point
236
+ `processingEndpoint` at an SSE route so the client tells your server when
237
+ it's done.
238
+ - On the multipart-complete path, the hook's `file.type` is always `''` —
239
+ the declared MIME type is not retained server-side once the multipart
240
+ upload completes.
241
+
242
+ ## `/health`
243
+
244
+ ```sh
245
+ curl https://your-app.example.com/api/upup/health
246
+ ```
247
+
248
+ ```json
249
+ { "status": "ok", "checks": { "config": "ok", "storage": "ok" } }
250
+ ```
251
+
252
+ Unauthenticated (checked **before** `config.auth`, so uptime/deploy probes
253
+ work without credentials) and always responds `200` — it's liveness-friendly;
254
+ the `status`/`checks` fields carry the actual health signal rather than the
255
+ HTTP status code, so an orchestrator doesn't restart a container over a
256
+ transient S3 blip. `checks.config` is `'ok'` when `storage.bucket`,
257
+ `storage.region`, and a valid-length `uploadTokenSecret` are all present;
258
+ `checks.storage` is a cheap `HeadBucketCommand` probe (no object
259
+ listing/transfer), TTL-cached for 30 seconds so repeated polling doesn't
260
+ hammer the real provider. A storage-check failure also fires the `onError`
261
+ seam.
262
+
263
+ ### Spotting cross-instance secret drift
264
+
265
+ Opt in to a fingerprint of `uploadTokenSecret` (never the secret itself):
266
+
267
+ ```ts
268
+ createUpupHandler({ /* ... */, health: { exposeSecretFingerprint: true } })
269
+ ```
270
+
271
+ ```json
272
+ { "status": "ok", "checks": { ... }, "uploadTokenFingerprint": "a1b2c3d4" }
273
+ ```
274
+
275
+ `uploadTokenFingerprint` is the first 8 hex characters of
276
+ `SHA-256(uploadTokenSecret)` — a 32-bit, non-reversible fingerprint over a
277
+ ≥128-bit secret. Curl `/health` on each instance behind your load balancer;
278
+ if the fingerprints differ, some instances are running with a different
279
+ secret than others (the exact rolling-redeploy hazard described above), and
280
+ you'll see `bad_signature` errors that look like forgery but aren't.
281
+ Consider network-ACLing this route if you'd rather not expose even the
282
+ fingerprint publicly.
283
+
284
+ ## `TokenStore` — bring your own in production
285
+
286
+ ```ts
287
+ import { InMemoryTokenStore } from '@useupup/server'
288
+ ```
289
+
290
+ **`InMemoryTokenStore` is DEV-ONLY.** It is a zero-dependency reference
291
+ implementation useful for local development, demos, and quick prototypes —
292
+ and unsuitable for production for three reasons:
293
+
294
+ 1. **Lost on restart.** All OAuth drive tokens and pending OAuth state
295
+ evaporate the moment the process restarts or redeploys.
296
+ 2. **Not shared across workers.** Each process/worker/lambda has its own
297
+ independent `Map`; a request handled by a different instance than the one
298
+ that stored the token sees it as missing (a spurious re-auth prompt).
299
+ 3. **Grows unbounded.** Nothing evicts stale entries beyond their own TTL
300
+ expiry logic — there is no capacity bound.
301
+
302
+ Ship a `TokenStore` backed by Redis, Cloudflare KV, or your own
303
+ database/table for any real deployment. The interface is intentionally
304
+ narrow (`get`/`set`/`delete`, string-keyed, optional TTL) to match common KV
305
+ stores directly:
306
+
307
+ ```ts
308
+ export interface TokenStore {
309
+ get(key: string): Promise<string | null>
310
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>
311
+ delete(key: string): Promise<void>
312
+ }
313
+ ```
314
+
315
+ ## Links
316
+
317
+ - [Documentation](https://useupup.com/docs/)
318
+ - [Source & monorepo](https://github.com/DevinoSolutions/upup)
319
+
320
+ ## License
321
+
322
+ MIT
@@ -0,0 +1,297 @@
1
+ import { PresignedUrlResponse, MultipartInitResponse, MultipartSignPartResponse, StorageProvider, UpupCorsConfig } from '@useupup/core';
2
+
3
+ type UpupServerErrorEvent = {
4
+ route: string;
5
+ method: string;
6
+ status: number;
7
+ code: string;
8
+ message: string;
9
+ requestId?: string | undefined;
10
+ error?: {
11
+ name: string;
12
+ message: string;
13
+ stack?: string | undefined;
14
+ };
15
+ };
16
+ type UpupServerLogger = (event: UpupServerErrorEvent) => void;
17
+
18
+ /**
19
+ * Free-form routing hints the CLIENT sends alongside a file's name/type/size,
20
+ * as the `metadata` field of a `/presign`, `/multipart/init`, or drive-transfer
21
+ * body. upup neither interprets nor validates it — it is carried through to
22
+ * `keyStrategy` and the storage resolver verbatim.
23
+ *
24
+ * It is ATTACKER-CONTROLLED. Treat it as you would a query parameter: switch on
25
+ * it against a fixed allow-list, never let it name a bucket, a path prefix, or
26
+ * a credential directly.
27
+ */
28
+ type UpupClientMetadata = Record<string, unknown>;
29
+ /** Context passed to a custom keyStrategy. */
30
+ interface KeyStrategyContext {
31
+ /** Resolved userId, or null when anonymous. */
32
+ userId: string | null;
33
+ fileName: string;
34
+ contentType: string;
35
+ size: number;
36
+ /** The client's `metadata` field, if it sent one. Untrusted — see
37
+ * {@link UpupClientMetadata}. */
38
+ metadata?: UpupClientMetadata;
39
+ /** The originating request, past every auth and policy check. */
40
+ req: Request;
41
+ }
42
+ /** Which of the three presign-side responses `onPresignResponse` is rewriting. */
43
+ type PresignResponsePhase = 'presign' | 'multipart-init' | 'multipart-sign-part';
44
+ interface PresignResponseContext {
45
+ /** The originating request, already past every auth and policy check. */
46
+ req: Request;
47
+ phase: PresignResponsePhase;
48
+ /** The server-chosen object key. Present on all three phases — on
49
+ * `multipart-sign-part` it comes from the VERIFIED token, not the client. */
50
+ key: string;
51
+ /** The client-declared file (name/type/size). Absent on
52
+ * `multipart-sign-part`, which sees only a token and a part number. */
53
+ file?: FileMetadata;
54
+ /** The client's `metadata` field, if it sent one. Untrusted — see
55
+ * {@link UpupClientMetadata}. Absent on `multipart-sign-part`. */
56
+ metadata?: UpupClientMetadata;
57
+ /** Resolved userId, or null for an anonymous (server-namespaced) upload. */
58
+ userId: string | null;
59
+ }
60
+ /** What the hook receives — narrow it on `ctx.phase`, or with `'uploadUrl' in response`. */
61
+ type PresignResponseBody = PresignedUrlResponse | (MultipartInitResponse & {
62
+ token: string;
63
+ }) | MultipartSignPartResponse;
64
+ /** What the hook may return: the same shapes, plus any extra fields you want
65
+ * to add for your client. */
66
+ type PresignResponseRewrite = PresignResponseBody & Record<string, unknown>;
67
+ type OnPresignResponse = (response: PresignResponseBody, ctx: PresignResponseContext) => PresignResponseRewrite | void | Promise<PresignResponseRewrite | void>;
68
+ /** One bucket's worth of S3 / S3-compatible connection settings. */
69
+ interface UpupStorageConfig {
70
+ /**
71
+ * An S3 / S3-compatible provider label. @useupup/server only speaks the S3
72
+ * API (buildS3ClientConfig always builds an @aws-sdk/client-s3 client) —
73
+ * set `endpoint` for any non-AWS backend (MinIO/R2/DO Spaces/etc). A
74
+ * provider with no S3-compatible surface (currently `StorageProvider.Azure`
75
+ * — see @useupup/core's NON_S3_STORAGE_PROVIDERS) is rejected by
76
+ * createUpupHandler at construct time.
77
+ */
78
+ type: StorageProvider | string;
79
+ bucket: string;
80
+ region: string;
81
+ accessKeyId?: string;
82
+ secretAccessKey?: string;
83
+ /** S3-compatible endpoint (MinIO / Cloudflare R2 / DO Spaces / on-prem). Omit for AWS S3. */
84
+ endpoint?: string;
85
+ /** Path-style addressing. Defaults to true when `endpoint` is set (required by MinIO).
86
+ * Only applies when `endpoint` is set; ignored for native AWS S3. */
87
+ forcePathStyle?: boolean;
88
+ [key: string]: unknown;
89
+ }
90
+ /** Which operation is asking for a storage config. */
91
+ type StorageResolverPhase = 'presign' | 'multipart-init' | 'multipart-sign-part' | 'multipart-complete' | 'multipart-abort' | 'multipart-resume' | 'drive-transfer';
92
+ interface StorageResolverContext {
93
+ /** The originating request, past every auth and policy check. */
94
+ req: Request;
95
+ phase: StorageResolverPhase;
96
+ /** Resolved userId, or null for an anonymous (server-namespaced) upload. */
97
+ userId: string | null;
98
+ /** The client's `metadata` field, if it sent one. Untrusted — see
99
+ * {@link UpupClientMetadata}. Absent on the multipart continuation
100
+ * phases, which carry only a token. */
101
+ metadata?: UpupClientMetadata;
102
+ fileName?: string;
103
+ contentType?: string;
104
+ size?: number;
105
+ /**
106
+ * Set on `multipart-sign-part` / `-complete` / `-abort` / `-resume` ONLY:
107
+ * the opaque
108
+ * identity of the storage this upload's `init` resolved, carried inside the
109
+ * HMAC-signed upload token. Return the SAME storage for it — the server
110
+ * re-derives the identity of whatever you return and answers `403
111
+ * AUTH_DENIED` if it does not match, so a continuation can never be
112
+ * steered to a different bucket than the one it started in.
113
+ */
114
+ storageId?: string;
115
+ }
116
+ type UpupStorageResolver = (ctx: StorageResolverContext) => UpupStorageConfig | Promise<UpupStorageConfig>;
117
+ type UpupServerConfig = {
118
+ /**
119
+ * One static bucket, or a resolver called per request to pick one — three
120
+ * buckets by upload class, a tenant's own bucket, a quarantine bucket for
121
+ * unscanned files. A resolver is validated at RESOLVE time (a bad config
122
+ * fails that request with a 500), not at construct time like the static
123
+ * form.
124
+ */
125
+ storage: UpupStorageConfig | UpupStorageResolver;
126
+ providers?: {
127
+ googleDrive?: {
128
+ clientId: string;
129
+ clientSecret: string;
130
+ };
131
+ dropbox?: {
132
+ appKey: string;
133
+ appSecret: string;
134
+ };
135
+ oneDrive?: {
136
+ clientId: string;
137
+ clientSecret: string;
138
+ tenantId?: string;
139
+ };
140
+ box?: {
141
+ clientId: string;
142
+ clientSecret: string;
143
+ };
144
+ };
145
+ tokenStore?: TokenStore;
146
+ /**
147
+ * Identify the authenticated user for OAuth + tokenStore scoping.
148
+ * Return null if the request has no authenticated user (OAuth will 401).
149
+ * If omitted, falls back to a singleton 'default' user — fine for demos,
150
+ * unsuitable for multi-tenant production.
151
+ */
152
+ getUserId?: (req: Request) => Promise<string | null>;
153
+ /**
154
+ * HMAC secret for stateless upload tokens (multipart key/uploadId binding).
155
+ * REQUIRED. Stable, high-entropy, >=16 chars, shared across all instances.
156
+ * `createUpupHandler` throws if missing or too short.
157
+ */
158
+ uploadTokenSecret?: string;
159
+ /**
160
+ * Override object-key generation. Default namespaces by userId:
161
+ * `<userId|anon>/<uuid>/<sanitized-filename>`. The client never chooses the key.
162
+ */
163
+ keyStrategy?: (ctx: KeyStrategyContext) => string;
164
+ /**
165
+ * TTL, in SECONDS, for the signed GET download URLs this server hands back
166
+ * (`downloadUrl` on the presign / multipart-complete / drive-transfer
167
+ * responses, and `getDownloadUrl`'s result). Defaults to 3 days. Lower it
168
+ * for gated content — a 15-minute link is `900`. This is the download half
169
+ * only; the upload URL's own 1-hour expiry is unaffected.
170
+ */
171
+ downloadUrlExpiresIn?: number;
172
+ /**
173
+ * Permit drive providers / tokenStore WITHOUT a getUserId resolver, collapsing
174
+ * every caller into one shared anonymous namespace. Demos only — never in
175
+ * multi-tenant production. Default false -> createUpupHandler throws.
176
+ */
177
+ allowAnonymous?: boolean;
178
+ /**
179
+ * Permit `/presign` + `/multipart/init` with no `auth` and no `getUserId`
180
+ * resolver — uploads run under the shared anonymous namespace. Demos /
181
+ * upstream-auth deployments (tus/companion-style, where auth is handled
182
+ * before the request reaches this handler) only. Default false -> those
183
+ * routes return 403 AUTH_REQUIRED.
184
+ */
185
+ allowAnonymousUploads?: boolean;
186
+ /**
187
+ * onFileUploaded/onUploadComplete fire on server-side-completion paths only
188
+ * (multipart-complete, drive transfer) -- direct presigned-PUT uploads never
189
+ * reach the server on completion, so no hook fires for them. See the
190
+ * README's "Lifecycle hooks" section for the full per-path breakdown.
191
+ */
192
+ hooks?: {
193
+ /**
194
+ * Admission gate. Return `false` to reject with a generic
195
+ * `403 Upload rejected`; THROW an `UpupError` to reject with that
196
+ * error's own message and code in the 403 body (a quota check can say
197
+ * "Storage limit exceeded — upgrade to keep uploading"). Any other
198
+ * throw stays a generic 500 — internal error text never reaches the
199
+ * client.
200
+ */
201
+ onBeforeUpload?: (file: FileMetadata, req: Request) => Promise<boolean>;
202
+ onFileUploaded?: (file: UploadedFile, req: Request) => Promise<void>;
203
+ onUploadComplete?: (files: UploadedFile[], req: Request) => Promise<void>;
204
+ /**
205
+ * Last look at a presign-side response body before it is sent, for
206
+ * deployments where the storage endpoint is not browser-reachable
207
+ * (a same-origin proxy route, a docker-internal MinIO hostname, a
208
+ * VPC-only endpoint). Return an object to REPLACE the payload; return
209
+ * nothing to leave it as-is.
210
+ *
211
+ * Fires on exactly three responses, identified by `ctx.phase`:
212
+ * `POST /presign` (`presign`), `POST /multipart/init`
213
+ * (`multipart-init`, token already issued), and
214
+ * `POST /multipart/sign-part` (`multipart-sign-part`).
215
+ *
216
+ * It runs AFTER every auth, policy, and token check and cannot bypass
217
+ * any of them — a request that would 401/403 never reaches the hook.
218
+ * Rewriting `uploadUrl` changes where the browser sends bytes, so the
219
+ * URL you substitute must land at the same object.
220
+ */
221
+ onPresignResponse?: OnPresignResponse;
222
+ };
223
+ /**
224
+ * How long after its ORIGINAL `/multipart/init` an upload may still be
225
+ * resumed via `POST /multipart/resume`, in seconds. Default 86400 (24h),
226
+ * matching the client's localStorage session TTL. Set `0` to disable the
227
+ * route entirely (it then 404s like any unknown path) — the cost of the
228
+ * route is that a leaked token stays usable for this window, though only to
229
+ * continue the SAME upload, to the SAME key, inside the SAME signed size
230
+ * envelope, and still owner-bound whenever `getUserId` is configured.
231
+ * Resuming re-issues a token with a fresh 1h expiry but carries the original
232
+ * issue time forward, so rolling resumes can never extend this window.
233
+ */
234
+ multipartResumeWindowSeconds?: number;
235
+ auth?: (req: Request) => Promise<boolean>;
236
+ maxFileSize?: number;
237
+ allowedTypes?: string[];
238
+ cors?: UpupCorsConfig;
239
+ /**
240
+ * Called on every error path (500s, invalid upload tokens, OAuth/token-exchange
241
+ * failures, health-check storage failures). Never receives secrets, tokens,
242
+ * request bodies, or Authorization headers — only a route/method/status/code/
243
+ * message plus the caught error's name/message/stack. Default: logs a
244
+ * structured line via console.error.
245
+ */
246
+ onError?: UpupServerLogger;
247
+ /** Options for the built-in GET /health route. */
248
+ health?: {
249
+ /**
250
+ * Expose the first 8 hex chars of SHA-256(uploadTokenSecret) on /health so
251
+ * operators can spot cross-instance secret drift without revealing the
252
+ * secret itself. Default: false.
253
+ */
254
+ exposeSecretFingerprint?: boolean;
255
+ };
256
+ };
257
+ /**
258
+ * Key-value store the server uses for OAuth state + drive access tokens.
259
+ * Interface matches Redis / Cloudflare KV / any string-keyed KV.
260
+ * Consumers implement this against their own persistence layer.
261
+ */
262
+ interface TokenStore {
263
+ get(key: string): Promise<string | null>;
264
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>;
265
+ delete(key: string): Promise<void>;
266
+ }
267
+ /** Drive OAuth tokens we persist after a successful /auth/:provider/cb. */
268
+ interface DriveTokens {
269
+ accessToken: string;
270
+ expiresAt?: number | undefined;
271
+ scope?: string | undefined;
272
+ tokenType?: string | undefined;
273
+ refreshToken?: string | undefined;
274
+ }
275
+ /** Short-lived OAuth state map, keyed by the random state param. */
276
+ interface OAuthState {
277
+ userId: string;
278
+ provider: string;
279
+ returnTo?: string | undefined;
280
+ }
281
+ interface FileMetadata {
282
+ name: string;
283
+ size: number;
284
+ type: string;
285
+ /** Free-form routing hints from the client. Untrusted — see
286
+ * {@link UpupClientMetadata}. */
287
+ metadata?: UpupClientMetadata;
288
+ }
289
+ interface UploadedFile {
290
+ key: string;
291
+ name: string;
292
+ size: number;
293
+ type: string;
294
+ url: string;
295
+ }
296
+
297
+ export type { DriveTokens as D, FileMetadata as F, KeyStrategyContext as K, OAuthState as O, PresignResponsePhase as P, StorageResolverPhase as S, TokenStore as T, UpupServerConfig as U, UpupStorageConfig as a, UploadedFile as b, PresignResponseContext as c, PresignResponseBody as d, PresignResponseRewrite as e, OnPresignResponse as f, UpupClientMetadata as g, UpupStorageResolver as h, StorageResolverContext as i };