@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.
@@ -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 };