@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 +21 -0
- package/README.md +322 -0
- package/dist/config-3b434689.d.cts +297 -0
- package/dist/config-3b434689.d.ts +297 -0
- package/dist/express.cjs +2360 -0
- package/dist/express.cjs.map +1 -0
- package/dist/express.d.cts +19 -0
- package/dist/express.d.ts +19 -0
- package/dist/express.js +2358 -0
- package/dist/express.js.map +1 -0
- package/dist/fastify.cjs +2365 -0
- package/dist/fastify.cjs.map +1 -0
- package/dist/fastify.d.cts +32 -0
- package/dist/fastify.d.ts +32 -0
- package/dist/fastify.js +2363 -0
- package/dist/fastify.js.map +1 -0
- package/dist/handler-030ee446.d.cts +6 -0
- package/dist/handler-9f2df17e.d.ts +6 -0
- package/dist/hono.cjs +2314 -0
- package/dist/hono.cjs.map +1 -0
- package/dist/hono.d.cts +7 -0
- package/dist/hono.d.ts +7 -0
- package/dist/hono.js +2312 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.cjs +2349 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +2342 -0
- package/dist/index.js.map +1 -0
- package/dist/next.cjs +2359 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +27 -0
- package/dist/next.d.ts +27 -0
- package/dist/next.js +2355 -0
- package/dist/next.js.map +1 -0
- package/dist/node-http-bridge.cjs +59 -0
- package/dist/node-http-bridge.cjs.map +1 -0
- package/dist/node-http-bridge.d.cts +29 -0
- package/dist/node-http-bridge.d.ts +29 -0
- package/dist/node-http-bridge.js +32 -0
- package/dist/node-http-bridge.js.map +1 -0
- package/package.json +102 -0
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 };
|