@filelayer/core 0.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +202 -0
  3. package/MIGRATIONS.md +328 -0
  4. package/NOTICE +37 -0
  5. package/README.md +343 -0
  6. package/SEMANTICS.md +729 -0
  7. package/dist/authz.d.ts +524 -0
  8. package/dist/authz.d.ts.map +1 -0
  9. package/dist/authz.js +889 -0
  10. package/dist/authz.js.map +1 -0
  11. package/dist/db.d.ts +145 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +217 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/delivery.d.ts +293 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +519 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/errors.d.ts +16 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +21 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/filelayer.d.ts +542 -0
  24. package/dist/filelayer.d.ts.map +1 -0
  25. package/dist/filelayer.js +1360 -0
  26. package/dist/filelayer.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/simple.d.ts +297 -0
  32. package/dist/simple.d.ts.map +1 -0
  33. package/dist/simple.js +492 -0
  34. package/dist/simple.js.map +1 -0
  35. package/dist/storage.d.ts +269 -0
  36. package/dist/storage.d.ts.map +1 -0
  37. package/dist/storage.js +700 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/store.d.ts +432 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +862 -0
  42. package/dist/store.js.map +1 -0
  43. package/package.json +77 -0
  44. package/schema.sql +1190 -0
  45. package/src/authz.ts +1398 -0
  46. package/src/db.ts +271 -0
  47. package/src/delivery.ts +737 -0
  48. package/src/errors.ts +24 -0
  49. package/src/filelayer.ts +1836 -0
  50. package/src/index.ts +7 -0
  51. package/src/simple.ts +666 -0
  52. package/src/storage.ts +917 -0
  53. package/src/store.ts +1072 -0
  54. package/test/delivery.test.ts +0 -0
  55. package/test/group-subjects.test.ts +1072 -0
  56. package/test/helpers.ts +65 -0
  57. package/test/listing.test.ts +689 -0
  58. package/test/local-s3.d.mts +33 -0
  59. package/test/local-s3.mjs +400 -0
  60. package/test/persistence.test.ts +953 -0
  61. package/test/regression.test.ts +619 -0
  62. package/test/s3-live.test.ts +322 -0
  63. package/test/security.test.ts +1652 -0
  64. package/test/semantics.test.ts +888 -0
  65. package/test/storage.test.ts +437 -0
  66. package/test/tiers.test.ts +432 -0
  67. package/test/vault-example.test.ts +302 -0
  68. package/tsconfig.build.json +29 -0
  69. package/tsconfig.json +19 -0
package/src/storage.ts ADDED
@@ -0,0 +1,917 @@
1
+ /**
2
+ * STORAGE ADAPTERS
3
+ *
4
+ * P2 (no ambient authority) is why this interface is deliberately dumb: it
5
+ * takes an opaque key and moves bytes. It has no idea what an org is, cannot
6
+ * be asked "who may read this", and is never consulted during an authorization
7
+ * decision. If the adapter could answer access questions there would be two
8
+ * authorization paths, and the second one is always the one that leaks.
9
+ *
10
+ * Corollary: object keys are NOT secrets and are not treated as such anywhere.
11
+ *
12
+ * -----------------------------------------------------------------------------
13
+ * WHAT CHANGED, AND WHY
14
+ * -----------------------------------------------------------------------------
15
+ *
16
+ * 1. `provider` is now part of the interface.
17
+ *
18
+ * `file.storage_provider` was written as the literal `'memory'` in
19
+ * `Filelayer.upload()`, regardless of which adapter was configured. Against
20
+ * `CREATE UNIQUE INDEX file_storage_key_idx ON file (storage_provider,
21
+ * storage_key)` that means a production database records every object as
22
+ * living in an in-memory store, and the one column that says WHERE the bytes
23
+ * are is wrong for every row. An adapter must therefore be able to name
24
+ * itself, and the name must come from the adapter rather than from the call
25
+ * site.
26
+ *
27
+ * 2. Everything is streaming-capable.
28
+ *
29
+ * `put()` took a `Uint8Array` and delivery returned one. That is a permanent
30
+ * tax on the heap: a 2 GB upload was 2 GB of resident memory in the API
31
+ * process, twice (once in the adapter, once in the response). `put()` now
32
+ * accepts a `ReadableStream` and uses S3 multipart above a part threshold;
33
+ * `stream()` returns a byte stream plus the metadata a correct HTTP response
34
+ * needs, and supports ranged reads.
35
+ *
36
+ * 3. `head()` exists.
37
+ *
38
+ * A `stat()` that has to download the object to learn its size is not a
39
+ * stat.
40
+ *
41
+ * 4. `presignGet()` is OPTIONAL, and its optionality is the point.
42
+ *
43
+ * It is the only capability the redirect delivery mode needs, and an adapter
44
+ * that cannot mint a presigned URL simply does not offer redirect delivery
45
+ * (`MemoryStorage` does not). Nothing else in the system may call it:
46
+ * a presigned URL is authority that outlives the decision that produced it,
47
+ * which is exactly the property P4 exists to deny, so it is reachable only
48
+ * through the explicitly-acknowledged redirect mode in `delivery.ts`.
49
+ *
50
+ * 5. `list()` is OPTIONAL and exists for exactly one caller: orphan collection.
51
+ *
52
+ * The storage write is not transactional (see `db.ts`). Bytes are written
53
+ * before the metadata transaction commits, so a crash in between leaves an
54
+ * object with no `file` row. That is a garbage-collection problem, not a
55
+ * correctness one -- an orphan is unreachable, because every read path starts
56
+ * from a `file` row -- but it is still our problem. `list()` is what makes it
57
+ * collectable.
58
+ */
59
+
60
+ import { createHash, createHmac } from 'node:crypto';
61
+
62
+ // -----------------------------------------------------------------------------
63
+ // The interface
64
+ // -----------------------------------------------------------------------------
65
+
66
+ /** Bytes, or a stream of bytes. Large uploads must use the second form. */
67
+ export type PutBody = Uint8Array | ReadableStream<Uint8Array>;
68
+
69
+ export interface StoragePutOptions {
70
+ /** Total length when known. Lets a small streaming put skip multipart. */
71
+ contentLength?: number;
72
+ }
73
+
74
+ export interface StoragePutResult {
75
+ /** Bytes actually written. Authoritative for `file.size_bytes`. */
76
+ bytes: number;
77
+ etag: string | null;
78
+ }
79
+
80
+ export interface ByteRange {
81
+ start: number;
82
+ /** Inclusive, per HTTP. Omit for "to the end". */
83
+ end?: number;
84
+ }
85
+
86
+ export interface ObjectHead {
87
+ size: number;
88
+ contentType: string | null;
89
+ etag: string | null;
90
+ lastModified: Date | null;
91
+ }
92
+
93
+ export interface ObjectStream {
94
+ body: ReadableStream<Uint8Array>;
95
+ /** Bytes in THIS response (the range length for a ranged read). */
96
+ size: number | null;
97
+ contentType: string | null;
98
+ etag: string | null;
99
+ /** Present only when the store honoured a range request. */
100
+ range?: { start: number; end: number; total: number };
101
+ }
102
+
103
+ export interface ListEntry {
104
+ key: string;
105
+ size: number;
106
+ lastModified: Date | null;
107
+ }
108
+
109
+ export interface PresignOptions {
110
+ /** Seconds. The adapter clamps; the DELIVERY layer clamps harder. */
111
+ expiresInSeconds: number;
112
+ /** Forced onto the response by the object store, so the store cannot be
113
+ * tricked into serving our bytes with an attacker-chosen content type. */
114
+ responseContentType?: string;
115
+ responseContentDisposition?: string;
116
+ }
117
+
118
+ export interface StorageAdapter {
119
+ /**
120
+ * The value written to `file.storage_provider`. It participates in a UNIQUE
121
+ * index with the key, so it is part of an object's identity: change it for an
122
+ * existing deployment and every existing row points at the wrong store.
123
+ *
124
+ * Conventional values: 'memory', 's3', 'r2', 'gcs'.
125
+ */
126
+ readonly provider: string;
127
+
128
+ put(key: string, body: PutBody, contentType: string, opts?: StoragePutOptions): Promise<StoragePutResult>;
129
+ /** Buffered read. Only for callers that genuinely want the whole object. */
130
+ get(key: string): Promise<Uint8Array | null>;
131
+ stream(key: string, opts?: { range?: ByteRange }): Promise<ObjectStream | null>;
132
+ head(key: string): Promise<ObjectHead | null>;
133
+ delete(key: string): Promise<void>;
134
+
135
+ /** Optional. Present => this adapter can support redirect delivery. */
136
+ presignGet?(key: string, opts: PresignOptions): Promise<string>;
137
+ /** Optional. Present => orphan collection can run against this adapter. */
138
+ list?(
139
+ prefix: string,
140
+ opts?: { limit?: number; cursor?: string | null },
141
+ ): Promise<{ entries: ListEntry[]; cursor: string | null }>;
142
+ }
143
+
144
+ /** Narrowing helpers, so callers do not hand-roll `typeof x.presignGet`. */
145
+ export function canPresign(
146
+ s: StorageAdapter,
147
+ ): s is StorageAdapter & Required<Pick<StorageAdapter, 'presignGet'>> {
148
+ return typeof s.presignGet === 'function';
149
+ }
150
+
151
+ export function canList(
152
+ s: StorageAdapter,
153
+ ): s is StorageAdapter & Required<Pick<StorageAdapter, 'list'>> {
154
+ return typeof s.list === 'function';
155
+ }
156
+
157
+ // -----------------------------------------------------------------------------
158
+ // Stream helpers
159
+ // -----------------------------------------------------------------------------
160
+
161
+ /** Collect a byte stream, refusing to exceed `limit`. */
162
+ export async function collectStream(
163
+ stream: ReadableStream<Uint8Array>,
164
+ limit = 512 * 1024 * 1024,
165
+ ): Promise<Uint8Array> {
166
+ const reader = stream.getReader();
167
+ const chunks: Uint8Array[] = [];
168
+ let total = 0;
169
+ try {
170
+ for (;;) {
171
+ const { done, value } = await reader.read();
172
+ if (done) break;
173
+ if (!value) continue;
174
+ total += value.byteLength;
175
+ if (total > limit) {
176
+ await reader.cancel('limit exceeded').catch(() => {});
177
+ throw new Error(`object exceeds the ${limit}-byte buffered read limit; use stream()`);
178
+ }
179
+ chunks.push(value);
180
+ }
181
+ } finally {
182
+ reader.releaseLock();
183
+ }
184
+ const out = new Uint8Array(total);
185
+ let at = 0;
186
+ for (const c of chunks) {
187
+ out.set(c, at);
188
+ at += c.byteLength;
189
+ }
190
+ return out;
191
+ }
192
+
193
+ export function bytesToStream(body: Uint8Array): ReadableStream<Uint8Array> {
194
+ return new ReadableStream<Uint8Array>({
195
+ start(controller) {
196
+ controller.enqueue(body);
197
+ controller.close();
198
+ },
199
+ });
200
+ }
201
+
202
+ // -----------------------------------------------------------------------------
203
+ // In-memory adapter (tests, local dev)
204
+ // -----------------------------------------------------------------------------
205
+
206
+ export class MemoryStorage implements StorageAdapter {
207
+ readonly provider = 'memory';
208
+
209
+ private readonly objects = new Map<
210
+ string,
211
+ { body: Uint8Array; contentType: string; etag: string; lastModified: Date }
212
+ >();
213
+
214
+ async put(key: string, body: PutBody, contentType: string): Promise<StoragePutResult> {
215
+ const bytes = body instanceof Uint8Array ? body : await collectStream(body);
216
+ const etag = `"${createHash('md5').update(bytes).digest('hex')}"`;
217
+ this.objects.set(key, { body: bytes, contentType, etag, lastModified: new Date() });
218
+ return { bytes: bytes.byteLength, etag };
219
+ }
220
+
221
+ async get(key: string): Promise<Uint8Array | null> {
222
+ return this.objects.get(key)?.body ?? null;
223
+ }
224
+
225
+ async head(key: string): Promise<ObjectHead | null> {
226
+ const o = this.objects.get(key);
227
+ if (!o) return null;
228
+ return {
229
+ size: o.body.byteLength,
230
+ contentType: o.contentType,
231
+ etag: o.etag,
232
+ lastModified: o.lastModified,
233
+ };
234
+ }
235
+
236
+ async delete(key: string): Promise<void> {
237
+ this.objects.delete(key);
238
+ }
239
+
240
+ async stream(key: string, opts: { range?: ByteRange } = {}): Promise<ObjectStream | null> {
241
+ const o = this.objects.get(key);
242
+ if (!o) return null;
243
+ const total = o.body.byteLength;
244
+ if (opts.range) {
245
+ const start = Math.max(0, opts.range.start);
246
+ const end = Math.min(opts.range.end ?? total - 1, total - 1);
247
+ if (start > end) return null;
248
+ const slice = o.body.subarray(start, end + 1);
249
+ return {
250
+ body: bytesToStream(slice),
251
+ size: slice.byteLength,
252
+ contentType: o.contentType,
253
+ etag: o.etag,
254
+ range: { start, end, total },
255
+ };
256
+ }
257
+ return { body: bytesToStream(o.body), size: total, contentType: o.contentType, etag: o.etag };
258
+ }
259
+
260
+ async list(
261
+ prefix: string,
262
+ opts: { limit?: number; cursor?: string | null } = {},
263
+ ): Promise<{ entries: ListEntry[]; cursor: string | null }> {
264
+ const limit = Math.max(1, Math.min(opts.limit ?? 1000, 1000));
265
+ const after = opts.cursor ?? '';
266
+ const all = [...this.objects.entries()]
267
+ .filter(([k]) => k.startsWith(prefix) && k > after)
268
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
269
+ const page = all.slice(0, limit);
270
+ return {
271
+ entries: page.map(([k, v]) => ({
272
+ key: k,
273
+ size: v.body.byteLength,
274
+ lastModified: v.lastModified,
275
+ })),
276
+ cursor: all.length > limit ? (page[page.length - 1]?.[0] ?? null) : null,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * DELIBERATELY ABSENT: `presignGet`.
282
+ *
283
+ * There is no URL that reaches an in-process Map, so redirect delivery is
284
+ * structurally unavailable here rather than fake. A test that wants to
285
+ * exercise redirect delivery must run against something that can actually
286
+ * mint one -- which is the point.
287
+ */
288
+
289
+ /** Test-only: lets a test assert that delete really removed the bytes. */
290
+ keys(): string[] {
291
+ return [...this.objects.keys()];
292
+ }
293
+ }
294
+
295
+ // -----------------------------------------------------------------------------
296
+ // S3 / R2 adapter
297
+ // -----------------------------------------------------------------------------
298
+ //
299
+ // Written against the raw REST API with SigV4 signed by node:crypto, so we do
300
+ // not take a dependency on the AWS SDK (which is ~15MB and would dominate our
301
+ // cold-start budget on Workers). R2 is S3-compatible; set `endpoint` to the
302
+ // account endpoint and `region` to 'auto'.
303
+ //
304
+ // TESTING STATUS. This is exercised on every `npm test` run against
305
+ // `test/local-s3.mjs`, a local S3-protocol server that RECOMPUTES EVERY SIGV4
306
+ // SIGNATURE (header-signed and presigned) and rejects a mismatch. That proves
307
+ // the wire format. It does not prove behaviour against real AWS or real R2; see
308
+ // `test/s3-live.test.ts` and the "unproven" list in the README for exactly what
309
+ // is still open.
310
+
311
+ export interface S3Config {
312
+ /** e.g. https://<account>.r2.cloudflarestorage.com, or a test server URL. */
313
+ endpoint: string;
314
+ bucket: string;
315
+ region: string;
316
+ accessKeyId: string;
317
+ secretAccessKey: string;
318
+ /** For STS / temporary credentials. Sent as `x-amz-security-token`. */
319
+ sessionToken?: string;
320
+ /**
321
+ * What lands in `file.storage_provider`. Defaults to 'r2' for an R2 endpoint
322
+ * and 's3' otherwise. It is part of an object's identity (see the UNIQUE
323
+ * index), so pin it explicitly for anything long-lived.
324
+ */
325
+ provider?: string;
326
+ /**
327
+ * `https://host/<bucket>/<key>` (default, and what R2 uses) vs
328
+ * `https://<bucket>.host/<key>`.
329
+ */
330
+ pathStyle?: boolean;
331
+ /** Multipart part size. S3 requires >= 5 MiB for all but the last part. */
332
+ partSizeBytes?: number;
333
+ /** Hard ceiling on a presigned URL's lifetime, in seconds. */
334
+ maxPresignSeconds?: number;
335
+ }
336
+
337
+ const MIN_PART_SIZE = 5 * 1024 * 1024;
338
+ const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
339
+
340
+ export class S3Storage implements StorageAdapter {
341
+ readonly provider: string;
342
+
343
+ private readonly cfg: Required<
344
+ Omit<S3Config, 'sessionToken' | 'provider'>
345
+ > & { sessionToken?: string };
346
+
347
+ constructor(cfg: S3Config) {
348
+ for (const k of ['endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey'] as const) {
349
+ if (typeof cfg[k] !== 'string' || cfg[k].length === 0) {
350
+ throw new Error(`S3Storage: missing required config '${k}'`);
351
+ }
352
+ }
353
+ const endpoint = cfg.endpoint.replace(/\/+$/, '');
354
+ this.provider =
355
+ cfg.provider ?? (/\.r2\.cloudflarestorage\.com$/i.test(new URL(endpoint).hostname) ? 'r2' : 's3');
356
+ if (!/^[a-z0-9_-]{1,32}$/.test(this.provider)) {
357
+ throw new Error(`S3Storage: implausible provider name ${JSON.stringify(this.provider)}`);
358
+ }
359
+ this.cfg = {
360
+ endpoint,
361
+ bucket: cfg.bucket,
362
+ region: cfg.region,
363
+ accessKeyId: cfg.accessKeyId,
364
+ secretAccessKey: cfg.secretAccessKey,
365
+ pathStyle: cfg.pathStyle ?? true,
366
+ partSizeBytes: Math.max(MIN_PART_SIZE, cfg.partSizeBytes ?? DEFAULT_PART_SIZE),
367
+ maxPresignSeconds: Math.max(1, Math.min(cfg.maxPresignSeconds ?? 3600, 7 * 24 * 3600)),
368
+ ...(cfg.sessionToken !== undefined ? { sessionToken: cfg.sessionToken } : {}),
369
+ };
370
+ }
371
+
372
+ // --- writes ---------------------------------------------------------------
373
+
374
+ async put(
375
+ key: string,
376
+ body: PutBody,
377
+ contentType: string,
378
+ opts: StoragePutOptions = {},
379
+ ): Promise<StoragePutResult> {
380
+ if (body instanceof Uint8Array) return this.putBuffer(key, body, contentType);
381
+ return this.putStream(key, body, contentType, opts);
382
+ }
383
+
384
+ private async putBuffer(
385
+ key: string,
386
+ body: Uint8Array,
387
+ contentType: string,
388
+ ): Promise<StoragePutResult> {
389
+ const res = await this.signedFetch({
390
+ method: 'PUT',
391
+ key,
392
+ body,
393
+ headers: { 'content-type': contentType },
394
+ });
395
+ if (!res.ok) throw await s3Error('put', res);
396
+ await res.arrayBuffer(); // drain; undici leaks the connection otherwise
397
+ return { bytes: body.byteLength, etag: res.headers.get('etag') };
398
+ }
399
+
400
+ /**
401
+ * Streaming upload.
402
+ *
403
+ * The first `partSizeBytes` are buffered because we cannot know until we have
404
+ * them whether this is a one-shot PUT or a multipart upload, and a one-shot
405
+ * PUT needs `content-length` (and a payload hash) up front. Everything beyond
406
+ * that is uploaded part by part and never all resident at once, so peak
407
+ * memory is bounded by ONE part regardless of object size. That bound is the
408
+ * entire point of this method.
409
+ */
410
+ private async putStream(
411
+ key: string,
412
+ body: ReadableStream<Uint8Array>,
413
+ contentType: string,
414
+ opts: StoragePutOptions,
415
+ ): Promise<StoragePutResult> {
416
+ const partSize = this.cfg.partSizeBytes;
417
+ const reader = body.getReader();
418
+ const first = await readAtLeast(reader, partSize);
419
+
420
+ if (first.done) {
421
+ // Whole object fits in one part.
422
+ reader.releaseLock();
423
+ return this.putBuffer(key, first.chunk, contentType);
424
+ }
425
+ if (opts.contentLength !== undefined && opts.contentLength <= partSize) {
426
+ // Caller lied about the length; trust the bytes, not the claim.
427
+ }
428
+
429
+ const uploadId = await this.createMultipartUpload(key, contentType);
430
+ const parts: Array<{ partNumber: number; etag: string }> = [];
431
+ let total = 0;
432
+ let partNumber = 0;
433
+ let pending: Uint8Array = first.chunk;
434
+
435
+ try {
436
+ for (;;) {
437
+ partNumber += 1;
438
+ parts.push({ partNumber, etag: await this.uploadPart(key, uploadId, partNumber, pending) });
439
+ total += pending.byteLength;
440
+ const next = await readAtLeast(reader, partSize);
441
+ if (next.chunk.byteLength === 0 && next.done) break;
442
+ pending = next.chunk;
443
+ if (next.done) {
444
+ partNumber += 1;
445
+ parts.push({
446
+ partNumber,
447
+ etag: await this.uploadPart(key, uploadId, partNumber, pending),
448
+ });
449
+ total += pending.byteLength;
450
+ break;
451
+ }
452
+ }
453
+ const etag = await this.completeMultipartUpload(key, uploadId, parts);
454
+ return { bytes: total, etag };
455
+ } catch (err) {
456
+ // An abandoned multipart upload is billable storage that no `file` row
457
+ // points at -- the orphan problem, in its most expensive form. Abort is
458
+ // best-effort because the original error is the one worth reporting.
459
+ await this.abortMultipartUpload(key, uploadId).catch(() => {});
460
+ throw err;
461
+ } finally {
462
+ reader.releaseLock();
463
+ }
464
+ }
465
+
466
+ private async createMultipartUpload(key: string, contentType: string): Promise<string> {
467
+ const res = await this.signedFetch({
468
+ method: 'POST',
469
+ key,
470
+ query: { uploads: '' },
471
+ body: new Uint8Array(),
472
+ headers: { 'content-type': contentType },
473
+ });
474
+ if (!res.ok) throw await s3Error('createMultipartUpload', res);
475
+ const xml = await res.text();
476
+ const uploadId = xmlTag(xml, 'UploadId');
477
+ if (!uploadId) throw new Error('storage createMultipartUpload: no UploadId in response');
478
+ return uploadId;
479
+ }
480
+
481
+ private async uploadPart(
482
+ key: string,
483
+ uploadId: string,
484
+ partNumber: number,
485
+ body: Uint8Array,
486
+ ): Promise<string> {
487
+ const res = await this.signedFetch({
488
+ method: 'PUT',
489
+ key,
490
+ query: { partNumber: String(partNumber), uploadId },
491
+ body,
492
+ });
493
+ if (!res.ok) throw await s3Error('uploadPart', res);
494
+ await res.arrayBuffer();
495
+ const etag = res.headers.get('etag');
496
+ if (!etag) throw new Error('storage uploadPart: no ETag in response');
497
+ return etag;
498
+ }
499
+
500
+ private async completeMultipartUpload(
501
+ key: string,
502
+ uploadId: string,
503
+ parts: Array<{ partNumber: number; etag: string }>,
504
+ ): Promise<string | null> {
505
+ const xml =
506
+ '<CompleteMultipartUpload>' +
507
+ parts
508
+ .map(
509
+ (p) =>
510
+ `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`,
511
+ )
512
+ .join('') +
513
+ '</CompleteMultipartUpload>';
514
+ const res = await this.signedFetch({
515
+ method: 'POST',
516
+ key,
517
+ query: { uploadId },
518
+ body: new TextEncoder().encode(xml),
519
+ headers: { 'content-type': 'application/xml' },
520
+ });
521
+ if (!res.ok) throw await s3Error('completeMultipartUpload', res);
522
+ const text = await res.text();
523
+ // S3 can return 200 with an <Error> body on this call specifically. Treating
524
+ // that as success would report a successful upload of a broken object.
525
+ if (/<Error>/.test(text)) {
526
+ throw new Error(
527
+ `storage completeMultipartUpload failed with 200 + Error body: ${xmlTag(text, 'Code') ?? text.slice(0, 200)}`,
528
+ );
529
+ }
530
+ return xmlTag(text, 'ETag');
531
+ }
532
+
533
+ private async abortMultipartUpload(key: string, uploadId: string): Promise<void> {
534
+ const res = await this.signedFetch({ method: 'DELETE', key, query: { uploadId } });
535
+ await res.arrayBuffer().catch(() => {});
536
+ }
537
+
538
+ // --- reads ----------------------------------------------------------------
539
+
540
+ async get(key: string): Promise<Uint8Array | null> {
541
+ const res = await this.signedFetch({ method: 'GET', key });
542
+ if (res.status === 404) {
543
+ await res.arrayBuffer().catch(() => {});
544
+ return null;
545
+ }
546
+ if (!res.ok) throw await s3Error('get', res);
547
+ return new Uint8Array(await res.arrayBuffer());
548
+ }
549
+
550
+ async head(key: string): Promise<ObjectHead | null> {
551
+ const res = await this.signedFetch({ method: 'HEAD', key });
552
+ // A HEAD has no body to read, but undici still wants the (empty) body
553
+ // consumed before the socket goes back to the pool.
554
+ await res.arrayBuffer().catch(() => {});
555
+ if (res.status === 404) return null;
556
+ if (!res.ok) throw new Error(`storage head failed: ${res.status}`);
557
+ const len = res.headers.get('content-length');
558
+ const lm = res.headers.get('last-modified');
559
+ return {
560
+ size: len === null ? 0 : Number(len),
561
+ contentType: res.headers.get('content-type'),
562
+ etag: res.headers.get('etag'),
563
+ lastModified: lm ? new Date(lm) : null,
564
+ };
565
+ }
566
+
567
+ async stream(key: string, opts: { range?: ByteRange } = {}): Promise<ObjectStream | null> {
568
+ const headers: Record<string, string> = {};
569
+ if (opts.range) {
570
+ headers['range'] =
571
+ opts.range.end === undefined
572
+ ? `bytes=${opts.range.start}-`
573
+ : `bytes=${opts.range.start}-${opts.range.end}`;
574
+ }
575
+ const res = await this.signedFetch({ method: 'GET', key, headers });
576
+ if (res.status === 404) {
577
+ await res.arrayBuffer().catch(() => {});
578
+ return null;
579
+ }
580
+ // 416 is "the range you asked for does not exist", which for our purposes is
581
+ // the same answer as "no such bytes" rather than a 500.
582
+ if (res.status === 416) {
583
+ await res.arrayBuffer().catch(() => {});
584
+ return null;
585
+ }
586
+ if (!res.ok) throw await s3Error('stream', res);
587
+ if (!res.body) throw new Error('storage stream: response had no body');
588
+
589
+ const len = res.headers.get('content-length');
590
+ const out: ObjectStream = {
591
+ body: res.body as ReadableStream<Uint8Array>,
592
+ size: len === null ? null : Number(len),
593
+ contentType: res.headers.get('content-type'),
594
+ etag: res.headers.get('etag'),
595
+ };
596
+ const cr = res.headers.get('content-range');
597
+ const m = cr && /^bytes (\d+)-(\d+)\/(\d+)$/.exec(cr);
598
+ if (m) out.range = { start: Number(m[1]), end: Number(m[2]), total: Number(m[3]) };
599
+ return out;
600
+ }
601
+
602
+ async delete(key: string): Promise<void> {
603
+ const res = await this.signedFetch({ method: 'DELETE', key });
604
+ await res.arrayBuffer().catch(() => {});
605
+ // S3 returns 204 for a delete of a key that never existed. 404 is here for
606
+ // S3-compatible stores that disagree; either way "it is gone" is success.
607
+ if (!res.ok && res.status !== 404) throw new Error(`storage delete failed: ${res.status}`);
608
+ }
609
+
610
+ async list(
611
+ prefix: string,
612
+ opts: { limit?: number; cursor?: string | null } = {},
613
+ ): Promise<{ entries: ListEntry[]; cursor: string | null }> {
614
+ const query: Record<string, string> = {
615
+ 'list-type': '2',
616
+ prefix,
617
+ 'max-keys': String(Math.max(1, Math.min(opts.limit ?? 1000, 1000))),
618
+ };
619
+ if (opts.cursor) query['continuation-token'] = opts.cursor;
620
+ const res = await this.signedFetch({ method: 'GET', key: '', query });
621
+ if (!res.ok) throw await s3Error('list', res);
622
+ const xml = await res.text();
623
+ const entries: ListEntry[] = [];
624
+ for (const c of xml.match(/<Contents>[\s\S]*?<\/Contents>/g) ?? []) {
625
+ const key = xmlTag(c, 'Key');
626
+ if (key === null) continue;
627
+ const lm = xmlTag(c, 'LastModified');
628
+ entries.push({
629
+ key: unescapeXml(key),
630
+ size: Number(xmlTag(c, 'Size') ?? 0),
631
+ lastModified: lm ? new Date(lm) : null,
632
+ });
633
+ }
634
+ const truncated = xmlTag(xml, 'IsTruncated') === 'true';
635
+ return { entries, cursor: truncated ? xmlTag(xml, 'NextContinuationToken') : null };
636
+ }
637
+
638
+ // --- presigning -----------------------------------------------------------
639
+
640
+ /**
641
+ * A query-string-signed GET URL.
642
+ *
643
+ * READ THE WARNING IN `delivery.ts` BEFORE CALLING THIS. The URL is bearer
644
+ * authority that the object store will honour until it expires, and the
645
+ * object store has never heard of a grant, a revocation or an org. That is
646
+ * why it is not reachable from any ordinary delivery path.
647
+ *
648
+ * `response-content-type` and `response-content-disposition` are signed into
649
+ * the URL, so the object store -- not the client -- decides what the bytes are
650
+ * served as. Without them a redirect would drop the `nosniff`/`attachment`
651
+ * protections that `deliveryHeaders()` exists to guarantee.
652
+ */
653
+ async presignGet(key: string, opts: PresignOptions): Promise<string> {
654
+ const expires = Math.max(1, Math.min(Math.floor(opts.expiresInSeconds), this.cfg.maxPresignSeconds));
655
+ const now = new Date();
656
+ const amzDate = amzDateOf(now);
657
+ const dateStamp = amzDate.slice(0, 8);
658
+ const scope = `${dateStamp}/${this.cfg.region}/s3/aws4_request`;
659
+ const { url, canonicalUri } = this.objectUrl(key);
660
+
661
+ const query: Record<string, string> = {
662
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
663
+ 'X-Amz-Credential': `${this.cfg.accessKeyId}/${scope}`,
664
+ 'X-Amz-Date': amzDate,
665
+ 'X-Amz-Expires': String(expires),
666
+ 'X-Amz-SignedHeaders': 'host',
667
+ };
668
+ if (this.cfg.sessionToken) query['X-Amz-Security-Token'] = this.cfg.sessionToken;
669
+ if (opts.responseContentType) query['response-content-type'] = opts.responseContentType;
670
+ if (opts.responseContentDisposition) {
671
+ query['response-content-disposition'] = opts.responseContentDisposition;
672
+ }
673
+
674
+ const canonicalRequest = [
675
+ 'GET',
676
+ canonicalUri,
677
+ canonicalQueryString(query),
678
+ `host:${url.host}\n`,
679
+ 'host',
680
+ 'UNSIGNED-PAYLOAD',
681
+ ].join('\n');
682
+ const signature = this.sign(dateStamp, amzDate, scope, canonicalRequest);
683
+ query['X-Amz-Signature'] = signature;
684
+ return `${url.origin}${canonicalUri}?${canonicalQueryString(query)}`;
685
+ }
686
+
687
+ // --- signing --------------------------------------------------------------
688
+
689
+ /**
690
+ * Path-style: `https://host/<bucket>/<key>`. Virtual-hosted:
691
+ * `https://<bucket>.host/<key>`.
692
+ *
693
+ * The canonical URI is built by RFC-3986-encoding each SEGMENT and is carried
694
+ * separately from `URL.pathname`, because `new URL()` normalises `.`/`..`
695
+ * segments and re-encodes some characters. Signing one string and sending
696
+ * another is the classic SigV4 bug and produces a 403 that looks like a
697
+ * credentials problem.
698
+ */
699
+ private objectUrl(key: string): { url: URL; canonicalUri: string } {
700
+ const base = new URL(this.cfg.endpoint);
701
+ const segments = key === '' ? [] : key.split('/');
702
+ let host = base.host;
703
+ let path: string;
704
+ if (this.cfg.pathStyle) {
705
+ path = '/' + [this.cfg.bucket, ...segments].map(rfc3986).join('/');
706
+ } else {
707
+ host = `${this.cfg.bucket}.${base.host}`;
708
+ path = '/' + segments.map(rfc3986).join('/');
709
+ }
710
+ const url = new URL(`${base.protocol}//${host}${path}`);
711
+ return { url, canonicalUri: path };
712
+ }
713
+
714
+ private sign(dateStamp: string, _amzDate: string, scope: string, canonicalRequest: string): string {
715
+ const stringToSign = [
716
+ 'AWS4-HMAC-SHA256',
717
+ _amzDate,
718
+ scope,
719
+ sha256Hex(Buffer.from(canonicalRequest, 'utf8')),
720
+ ].join('\n');
721
+ let k: Buffer = Buffer.from(`AWS4${this.cfg.secretAccessKey}`, 'utf8');
722
+ for (const part of [dateStamp, this.cfg.region, 's3', 'aws4_request']) {
723
+ k = createHmac('sha256', k).update(part, 'utf8').digest();
724
+ }
725
+ return createHmac('sha256', k).update(stringToSign, 'utf8').digest('hex');
726
+ }
727
+
728
+ private async signedFetch(req: {
729
+ method: string;
730
+ key: string;
731
+ query?: Record<string, string>;
732
+ body?: Uint8Array;
733
+ headers?: Record<string, string>;
734
+ }): Promise<Response> {
735
+ const { url, canonicalUri } = this.objectUrl(req.key);
736
+ const query = req.query ?? {};
737
+ const cqs = canonicalQueryString(query);
738
+ const now = new Date();
739
+ const amzDate = amzDateOf(now);
740
+ const dateStamp = amzDate.slice(0, 8);
741
+ const payloadHash = sha256Hex(req.body ?? new Uint8Array());
742
+
743
+ const headers: Record<string, string> = {
744
+ host: url.host,
745
+ 'x-amz-content-sha256': payloadHash,
746
+ 'x-amz-date': amzDate,
747
+ };
748
+ if (this.cfg.sessionToken) headers['x-amz-security-token'] = this.cfg.sessionToken;
749
+ // Lowercase every supplied header name: SigV4 canonicalises on the lowercase
750
+ // name and sorts on it, so a `Content-Type` from a caller would sort into a
751
+ // different position than the `content-type` actually sent.
752
+ for (const [k, v] of Object.entries(req.headers ?? {})) headers[k.toLowerCase()] = v;
753
+
754
+ const names = Object.keys(headers).sort();
755
+ const signedHeaders = names.join(';');
756
+ // Header values are trimmed AND internal whitespace runs collapsed, per the
757
+ // SigV4 spec. Skipping the collapse silently breaks any value with a double
758
+ // space in it -- e.g. a content-disposition filename.
759
+ const canonicalHeaders = names.map((h) => `${h}:${canonicalHeaderValue(headers[h]!)}\n`).join('');
760
+
761
+ const canonicalRequest = [
762
+ req.method,
763
+ canonicalUri,
764
+ cqs,
765
+ canonicalHeaders,
766
+ signedHeaders,
767
+ payloadHash,
768
+ ].join('\n');
769
+
770
+ const scope = `${dateStamp}/${this.cfg.region}/s3/aws4_request`;
771
+ const signature = this.sign(dateStamp, amzDate, scope, canonicalRequest);
772
+
773
+ headers['authorization'] =
774
+ `AWS4-HMAC-SHA256 Credential=${this.cfg.accessKeyId}/${scope}, ` +
775
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
776
+
777
+ const target = cqs === '' ? `${url.origin}${canonicalUri}` : `${url.origin}${canonicalUri}?${cqs}`;
778
+ // `host` is set by the HTTP client from the URL and cannot be overridden in
779
+ // undici; it is in `headers` only so that it is signed. Sending it too is
780
+ // harmless where allowed and rejected where not, so it is dropped here and
781
+ // the signature is still computed over the value the client will send.
782
+ const { host: _host, ...wire } = headers;
783
+ return fetch(target, {
784
+ method: req.method,
785
+ headers: wire,
786
+ ...(req.body !== undefined && req.method !== 'GET' && req.method !== 'HEAD'
787
+ ? { body: req.body as unknown as BodyInit }
788
+ : {}),
789
+ });
790
+ }
791
+ }
792
+
793
+ // -----------------------------------------------------------------------------
794
+ // SigV4 primitives
795
+ // -----------------------------------------------------------------------------
796
+
797
+ function sha256Hex(data: Uint8Array): string {
798
+ return createHash('sha256').update(data).digest('hex');
799
+ }
800
+
801
+ function amzDateOf(d: Date): string {
802
+ return d.toISOString().replace(/[:-]|\.\d{3}/g, '');
803
+ }
804
+
805
+ /**
806
+ * RFC 3986 unreserved-set encoding.
807
+ *
808
+ * `encodeURIComponent` leaves `!'()*` alone and `encodeURI` additionally leaves
809
+ * `#?&=+,:;@$` alone. The previous implementation used `encodeURI` on the whole
810
+ * key, which meant a key containing `#` truncated the URL at the fragment, a key
811
+ * containing `?` started a query string, and a key containing `+` signed one
812
+ * byte sequence and sent another. Keys are constructed by us today, but "the
813
+ * caller never puts a `#` in a key" is not a property the type system carries.
814
+ */
815
+ export function rfc3986(s: string): string {
816
+ return encodeURIComponent(s).replace(
817
+ /[!'()*]/g,
818
+ (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
819
+ );
820
+ }
821
+
822
+ /** Sorted by encoded key, then encoded value. Empty values keep their `=`. */
823
+ export function canonicalQueryString(query: Record<string, string>): string {
824
+ return Object.entries(query)
825
+ .map(([k, v]) => [rfc3986(k), rfc3986(v)] as const)
826
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
827
+ .map(([k, v]) => `${k}=${v}`)
828
+ .join('&');
829
+ }
830
+
831
+ function canonicalHeaderValue(v: string): string {
832
+ return v.trim().replace(/\s+/g, ' ');
833
+ }
834
+
835
+ // -----------------------------------------------------------------------------
836
+ // Small helpers
837
+ // -----------------------------------------------------------------------------
838
+
839
+ /**
840
+ * Read until `n` bytes are available or the stream ends.
841
+ *
842
+ * `done` means "the stream is finished AND this is everything that was left",
843
+ * which is what lets `putStream` decide between a one-shot PUT and multipart
844
+ * without a second read.
845
+ */
846
+ async function readAtLeast(
847
+ reader: ReadableStreamDefaultReader<Uint8Array>,
848
+ n: number,
849
+ ): Promise<{ chunk: Uint8Array; done: boolean }> {
850
+ const chunks: Uint8Array[] = [];
851
+ let total = 0;
852
+ while (total < n) {
853
+ const { done, value } = await reader.read();
854
+ if (done) return { chunk: concat(chunks, total), done: true };
855
+ if (!value || value.byteLength === 0) continue;
856
+ chunks.push(value);
857
+ total += value.byteLength;
858
+ }
859
+ return { chunk: concat(chunks, total), done: false };
860
+ }
861
+
862
+ function concat(chunks: Uint8Array[], total: number): Uint8Array {
863
+ if (chunks.length === 1 && chunks[0]!.byteLength === total) return chunks[0]!;
864
+ const out = new Uint8Array(total);
865
+ let at = 0;
866
+ for (const c of chunks) {
867
+ out.set(c, at);
868
+ at += c.byteLength;
869
+ }
870
+ return out;
871
+ }
872
+
873
+ function xmlTag(xml: string, tag: string): string | null {
874
+ const m = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml);
875
+ return m ? (m[1] ?? null) : null;
876
+ }
877
+
878
+ function escapeXml(s: string): string {
879
+ return s.replace(/[<>&'"]/g, (c) =>
880
+ c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '&' ? '&amp;' : c === "'" ? '&apos;' : '&quot;',
881
+ );
882
+ }
883
+
884
+ function unescapeXml(s: string): string {
885
+ return s
886
+ .replace(/&lt;/g, '<')
887
+ .replace(/&gt;/g, '>')
888
+ .replace(/&quot;/g, '"')
889
+ .replace(/&apos;/g, "'")
890
+ .replace(/&amp;/g, '&');
891
+ }
892
+
893
+ /**
894
+ * Turn an S3 error response into an Error carrying the store's own error code.
895
+ *
896
+ * The status alone is not enough to act on: `AccessDenied`, `SignatureDoesNotMatch`
897
+ * and `InvalidAccessKeyId` are all 403 and mean three completely different
898
+ * operational problems (permissions / our bug / rotated key). Throwing the raw
899
+ * status is how a signing bug spends a week being investigated as an IAM policy.
900
+ */
901
+ async function s3Error(op: string, res: Response): Promise<Error> {
902
+ let code: string | null = null;
903
+ let message: string | null = null;
904
+ try {
905
+ const text = await res.text();
906
+ code = xmlTag(text, 'Code');
907
+ message = xmlTag(text, 'Message');
908
+ } catch {
909
+ /* body already consumed or not XML */
910
+ }
911
+ const err = new Error(
912
+ `storage ${op} failed: ${res.status}${code ? ` ${code}` : ''}${message ? ` -- ${message}` : ''}`,
913
+ );
914
+ (err as Error & { s3Code?: string | null; status?: number }).s3Code = code;
915
+ (err as Error & { s3Code?: string | null; status?: number }).status = res.status;
916
+ return err;
917
+ }