@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
@@ -0,0 +1,700 @@
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
+ import { createHash, createHmac } from 'node:crypto';
60
+ /** Narrowing helpers, so callers do not hand-roll `typeof x.presignGet`. */
61
+ export function canPresign(s) {
62
+ return typeof s.presignGet === 'function';
63
+ }
64
+ export function canList(s) {
65
+ return typeof s.list === 'function';
66
+ }
67
+ // -----------------------------------------------------------------------------
68
+ // Stream helpers
69
+ // -----------------------------------------------------------------------------
70
+ /** Collect a byte stream, refusing to exceed `limit`. */
71
+ export async function collectStream(stream, limit = 512 * 1024 * 1024) {
72
+ const reader = stream.getReader();
73
+ const chunks = [];
74
+ let total = 0;
75
+ try {
76
+ for (;;) {
77
+ const { done, value } = await reader.read();
78
+ if (done)
79
+ break;
80
+ if (!value)
81
+ continue;
82
+ total += value.byteLength;
83
+ if (total > limit) {
84
+ await reader.cancel('limit exceeded').catch(() => { });
85
+ throw new Error(`object exceeds the ${limit}-byte buffered read limit; use stream()`);
86
+ }
87
+ chunks.push(value);
88
+ }
89
+ }
90
+ finally {
91
+ reader.releaseLock();
92
+ }
93
+ const out = new Uint8Array(total);
94
+ let at = 0;
95
+ for (const c of chunks) {
96
+ out.set(c, at);
97
+ at += c.byteLength;
98
+ }
99
+ return out;
100
+ }
101
+ export function bytesToStream(body) {
102
+ return new ReadableStream({
103
+ start(controller) {
104
+ controller.enqueue(body);
105
+ controller.close();
106
+ },
107
+ });
108
+ }
109
+ // -----------------------------------------------------------------------------
110
+ // In-memory adapter (tests, local dev)
111
+ // -----------------------------------------------------------------------------
112
+ export class MemoryStorage {
113
+ provider = 'memory';
114
+ objects = new Map();
115
+ async put(key, body, contentType) {
116
+ const bytes = body instanceof Uint8Array ? body : await collectStream(body);
117
+ const etag = `"${createHash('md5').update(bytes).digest('hex')}"`;
118
+ this.objects.set(key, { body: bytes, contentType, etag, lastModified: new Date() });
119
+ return { bytes: bytes.byteLength, etag };
120
+ }
121
+ async get(key) {
122
+ return this.objects.get(key)?.body ?? null;
123
+ }
124
+ async head(key) {
125
+ const o = this.objects.get(key);
126
+ if (!o)
127
+ return null;
128
+ return {
129
+ size: o.body.byteLength,
130
+ contentType: o.contentType,
131
+ etag: o.etag,
132
+ lastModified: o.lastModified,
133
+ };
134
+ }
135
+ async delete(key) {
136
+ this.objects.delete(key);
137
+ }
138
+ async stream(key, opts = {}) {
139
+ const o = this.objects.get(key);
140
+ if (!o)
141
+ return null;
142
+ const total = o.body.byteLength;
143
+ if (opts.range) {
144
+ const start = Math.max(0, opts.range.start);
145
+ const end = Math.min(opts.range.end ?? total - 1, total - 1);
146
+ if (start > end)
147
+ return null;
148
+ const slice = o.body.subarray(start, end + 1);
149
+ return {
150
+ body: bytesToStream(slice),
151
+ size: slice.byteLength,
152
+ contentType: o.contentType,
153
+ etag: o.etag,
154
+ range: { start, end, total },
155
+ };
156
+ }
157
+ return { body: bytesToStream(o.body), size: total, contentType: o.contentType, etag: o.etag };
158
+ }
159
+ async list(prefix, opts = {}) {
160
+ const limit = Math.max(1, Math.min(opts.limit ?? 1000, 1000));
161
+ const after = opts.cursor ?? '';
162
+ const all = [...this.objects.entries()]
163
+ .filter(([k]) => k.startsWith(prefix) && k > after)
164
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
165
+ const page = all.slice(0, limit);
166
+ return {
167
+ entries: page.map(([k, v]) => ({
168
+ key: k,
169
+ size: v.body.byteLength,
170
+ lastModified: v.lastModified,
171
+ })),
172
+ cursor: all.length > limit ? (page[page.length - 1]?.[0] ?? null) : null,
173
+ };
174
+ }
175
+ /**
176
+ * DELIBERATELY ABSENT: `presignGet`.
177
+ *
178
+ * There is no URL that reaches an in-process Map, so redirect delivery is
179
+ * structurally unavailable here rather than fake. A test that wants to
180
+ * exercise redirect delivery must run against something that can actually
181
+ * mint one -- which is the point.
182
+ */
183
+ /** Test-only: lets a test assert that delete really removed the bytes. */
184
+ keys() {
185
+ return [...this.objects.keys()];
186
+ }
187
+ }
188
+ const MIN_PART_SIZE = 5 * 1024 * 1024;
189
+ const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
190
+ export class S3Storage {
191
+ provider;
192
+ cfg;
193
+ constructor(cfg) {
194
+ for (const k of ['endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey']) {
195
+ if (typeof cfg[k] !== 'string' || cfg[k].length === 0) {
196
+ throw new Error(`S3Storage: missing required config '${k}'`);
197
+ }
198
+ }
199
+ const endpoint = cfg.endpoint.replace(/\/+$/, '');
200
+ this.provider =
201
+ cfg.provider ?? (/\.r2\.cloudflarestorage\.com$/i.test(new URL(endpoint).hostname) ? 'r2' : 's3');
202
+ if (!/^[a-z0-9_-]{1,32}$/.test(this.provider)) {
203
+ throw new Error(`S3Storage: implausible provider name ${JSON.stringify(this.provider)}`);
204
+ }
205
+ this.cfg = {
206
+ endpoint,
207
+ bucket: cfg.bucket,
208
+ region: cfg.region,
209
+ accessKeyId: cfg.accessKeyId,
210
+ secretAccessKey: cfg.secretAccessKey,
211
+ pathStyle: cfg.pathStyle ?? true,
212
+ partSizeBytes: Math.max(MIN_PART_SIZE, cfg.partSizeBytes ?? DEFAULT_PART_SIZE),
213
+ maxPresignSeconds: Math.max(1, Math.min(cfg.maxPresignSeconds ?? 3600, 7 * 24 * 3600)),
214
+ ...(cfg.sessionToken !== undefined ? { sessionToken: cfg.sessionToken } : {}),
215
+ };
216
+ }
217
+ // --- writes ---------------------------------------------------------------
218
+ async put(key, body, contentType, opts = {}) {
219
+ if (body instanceof Uint8Array)
220
+ return this.putBuffer(key, body, contentType);
221
+ return this.putStream(key, body, contentType, opts);
222
+ }
223
+ async putBuffer(key, body, contentType) {
224
+ const res = await this.signedFetch({
225
+ method: 'PUT',
226
+ key,
227
+ body,
228
+ headers: { 'content-type': contentType },
229
+ });
230
+ if (!res.ok)
231
+ throw await s3Error('put', res);
232
+ await res.arrayBuffer(); // drain; undici leaks the connection otherwise
233
+ return { bytes: body.byteLength, etag: res.headers.get('etag') };
234
+ }
235
+ /**
236
+ * Streaming upload.
237
+ *
238
+ * The first `partSizeBytes` are buffered because we cannot know until we have
239
+ * them whether this is a one-shot PUT or a multipart upload, and a one-shot
240
+ * PUT needs `content-length` (and a payload hash) up front. Everything beyond
241
+ * that is uploaded part by part and never all resident at once, so peak
242
+ * memory is bounded by ONE part regardless of object size. That bound is the
243
+ * entire point of this method.
244
+ */
245
+ async putStream(key, body, contentType, opts) {
246
+ const partSize = this.cfg.partSizeBytes;
247
+ const reader = body.getReader();
248
+ const first = await readAtLeast(reader, partSize);
249
+ if (first.done) {
250
+ // Whole object fits in one part.
251
+ reader.releaseLock();
252
+ return this.putBuffer(key, first.chunk, contentType);
253
+ }
254
+ if (opts.contentLength !== undefined && opts.contentLength <= partSize) {
255
+ // Caller lied about the length; trust the bytes, not the claim.
256
+ }
257
+ const uploadId = await this.createMultipartUpload(key, contentType);
258
+ const parts = [];
259
+ let total = 0;
260
+ let partNumber = 0;
261
+ let pending = first.chunk;
262
+ try {
263
+ for (;;) {
264
+ partNumber += 1;
265
+ parts.push({ partNumber, etag: await this.uploadPart(key, uploadId, partNumber, pending) });
266
+ total += pending.byteLength;
267
+ const next = await readAtLeast(reader, partSize);
268
+ if (next.chunk.byteLength === 0 && next.done)
269
+ break;
270
+ pending = next.chunk;
271
+ if (next.done) {
272
+ partNumber += 1;
273
+ parts.push({
274
+ partNumber,
275
+ etag: await this.uploadPart(key, uploadId, partNumber, pending),
276
+ });
277
+ total += pending.byteLength;
278
+ break;
279
+ }
280
+ }
281
+ const etag = await this.completeMultipartUpload(key, uploadId, parts);
282
+ return { bytes: total, etag };
283
+ }
284
+ catch (err) {
285
+ // An abandoned multipart upload is billable storage that no `file` row
286
+ // points at -- the orphan problem, in its most expensive form. Abort is
287
+ // best-effort because the original error is the one worth reporting.
288
+ await this.abortMultipartUpload(key, uploadId).catch(() => { });
289
+ throw err;
290
+ }
291
+ finally {
292
+ reader.releaseLock();
293
+ }
294
+ }
295
+ async createMultipartUpload(key, contentType) {
296
+ const res = await this.signedFetch({
297
+ method: 'POST',
298
+ key,
299
+ query: { uploads: '' },
300
+ body: new Uint8Array(),
301
+ headers: { 'content-type': contentType },
302
+ });
303
+ if (!res.ok)
304
+ throw await s3Error('createMultipartUpload', res);
305
+ const xml = await res.text();
306
+ const uploadId = xmlTag(xml, 'UploadId');
307
+ if (!uploadId)
308
+ throw new Error('storage createMultipartUpload: no UploadId in response');
309
+ return uploadId;
310
+ }
311
+ async uploadPart(key, uploadId, partNumber, body) {
312
+ const res = await this.signedFetch({
313
+ method: 'PUT',
314
+ key,
315
+ query: { partNumber: String(partNumber), uploadId },
316
+ body,
317
+ });
318
+ if (!res.ok)
319
+ throw await s3Error('uploadPart', res);
320
+ await res.arrayBuffer();
321
+ const etag = res.headers.get('etag');
322
+ if (!etag)
323
+ throw new Error('storage uploadPart: no ETag in response');
324
+ return etag;
325
+ }
326
+ async completeMultipartUpload(key, uploadId, parts) {
327
+ const xml = '<CompleteMultipartUpload>' +
328
+ parts
329
+ .map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`)
330
+ .join('') +
331
+ '</CompleteMultipartUpload>';
332
+ const res = await this.signedFetch({
333
+ method: 'POST',
334
+ key,
335
+ query: { uploadId },
336
+ body: new TextEncoder().encode(xml),
337
+ headers: { 'content-type': 'application/xml' },
338
+ });
339
+ if (!res.ok)
340
+ throw await s3Error('completeMultipartUpload', res);
341
+ const text = await res.text();
342
+ // S3 can return 200 with an <Error> body on this call specifically. Treating
343
+ // that as success would report a successful upload of a broken object.
344
+ if (/<Error>/.test(text)) {
345
+ throw new Error(`storage completeMultipartUpload failed with 200 + Error body: ${xmlTag(text, 'Code') ?? text.slice(0, 200)}`);
346
+ }
347
+ return xmlTag(text, 'ETag');
348
+ }
349
+ async abortMultipartUpload(key, uploadId) {
350
+ const res = await this.signedFetch({ method: 'DELETE', key, query: { uploadId } });
351
+ await res.arrayBuffer().catch(() => { });
352
+ }
353
+ // --- reads ----------------------------------------------------------------
354
+ async get(key) {
355
+ const res = await this.signedFetch({ method: 'GET', key });
356
+ if (res.status === 404) {
357
+ await res.arrayBuffer().catch(() => { });
358
+ return null;
359
+ }
360
+ if (!res.ok)
361
+ throw await s3Error('get', res);
362
+ return new Uint8Array(await res.arrayBuffer());
363
+ }
364
+ async head(key) {
365
+ const res = await this.signedFetch({ method: 'HEAD', key });
366
+ // A HEAD has no body to read, but undici still wants the (empty) body
367
+ // consumed before the socket goes back to the pool.
368
+ await res.arrayBuffer().catch(() => { });
369
+ if (res.status === 404)
370
+ return null;
371
+ if (!res.ok)
372
+ throw new Error(`storage head failed: ${res.status}`);
373
+ const len = res.headers.get('content-length');
374
+ const lm = res.headers.get('last-modified');
375
+ return {
376
+ size: len === null ? 0 : Number(len),
377
+ contentType: res.headers.get('content-type'),
378
+ etag: res.headers.get('etag'),
379
+ lastModified: lm ? new Date(lm) : null,
380
+ };
381
+ }
382
+ async stream(key, opts = {}) {
383
+ const headers = {};
384
+ if (opts.range) {
385
+ headers['range'] =
386
+ opts.range.end === undefined
387
+ ? `bytes=${opts.range.start}-`
388
+ : `bytes=${opts.range.start}-${opts.range.end}`;
389
+ }
390
+ const res = await this.signedFetch({ method: 'GET', key, headers });
391
+ if (res.status === 404) {
392
+ await res.arrayBuffer().catch(() => { });
393
+ return null;
394
+ }
395
+ // 416 is "the range you asked for does not exist", which for our purposes is
396
+ // the same answer as "no such bytes" rather than a 500.
397
+ if (res.status === 416) {
398
+ await res.arrayBuffer().catch(() => { });
399
+ return null;
400
+ }
401
+ if (!res.ok)
402
+ throw await s3Error('stream', res);
403
+ if (!res.body)
404
+ throw new Error('storage stream: response had no body');
405
+ const len = res.headers.get('content-length');
406
+ const out = {
407
+ body: res.body,
408
+ size: len === null ? null : Number(len),
409
+ contentType: res.headers.get('content-type'),
410
+ etag: res.headers.get('etag'),
411
+ };
412
+ const cr = res.headers.get('content-range');
413
+ const m = cr && /^bytes (\d+)-(\d+)\/(\d+)$/.exec(cr);
414
+ if (m)
415
+ out.range = { start: Number(m[1]), end: Number(m[2]), total: Number(m[3]) };
416
+ return out;
417
+ }
418
+ async delete(key) {
419
+ const res = await this.signedFetch({ method: 'DELETE', key });
420
+ await res.arrayBuffer().catch(() => { });
421
+ // S3 returns 204 for a delete of a key that never existed. 404 is here for
422
+ // S3-compatible stores that disagree; either way "it is gone" is success.
423
+ if (!res.ok && res.status !== 404)
424
+ throw new Error(`storage delete failed: ${res.status}`);
425
+ }
426
+ async list(prefix, opts = {}) {
427
+ const query = {
428
+ 'list-type': '2',
429
+ prefix,
430
+ 'max-keys': String(Math.max(1, Math.min(opts.limit ?? 1000, 1000))),
431
+ };
432
+ if (opts.cursor)
433
+ query['continuation-token'] = opts.cursor;
434
+ const res = await this.signedFetch({ method: 'GET', key: '', query });
435
+ if (!res.ok)
436
+ throw await s3Error('list', res);
437
+ const xml = await res.text();
438
+ const entries = [];
439
+ for (const c of xml.match(/<Contents>[\s\S]*?<\/Contents>/g) ?? []) {
440
+ const key = xmlTag(c, 'Key');
441
+ if (key === null)
442
+ continue;
443
+ const lm = xmlTag(c, 'LastModified');
444
+ entries.push({
445
+ key: unescapeXml(key),
446
+ size: Number(xmlTag(c, 'Size') ?? 0),
447
+ lastModified: lm ? new Date(lm) : null,
448
+ });
449
+ }
450
+ const truncated = xmlTag(xml, 'IsTruncated') === 'true';
451
+ return { entries, cursor: truncated ? xmlTag(xml, 'NextContinuationToken') : null };
452
+ }
453
+ // --- presigning -----------------------------------------------------------
454
+ /**
455
+ * A query-string-signed GET URL.
456
+ *
457
+ * READ THE WARNING IN `delivery.ts` BEFORE CALLING THIS. The URL is bearer
458
+ * authority that the object store will honour until it expires, and the
459
+ * object store has never heard of a grant, a revocation or an org. That is
460
+ * why it is not reachable from any ordinary delivery path.
461
+ *
462
+ * `response-content-type` and `response-content-disposition` are signed into
463
+ * the URL, so the object store -- not the client -- decides what the bytes are
464
+ * served as. Without them a redirect would drop the `nosniff`/`attachment`
465
+ * protections that `deliveryHeaders()` exists to guarantee.
466
+ */
467
+ async presignGet(key, opts) {
468
+ const expires = Math.max(1, Math.min(Math.floor(opts.expiresInSeconds), this.cfg.maxPresignSeconds));
469
+ const now = new Date();
470
+ const amzDate = amzDateOf(now);
471
+ const dateStamp = amzDate.slice(0, 8);
472
+ const scope = `${dateStamp}/${this.cfg.region}/s3/aws4_request`;
473
+ const { url, canonicalUri } = this.objectUrl(key);
474
+ const query = {
475
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
476
+ 'X-Amz-Credential': `${this.cfg.accessKeyId}/${scope}`,
477
+ 'X-Amz-Date': amzDate,
478
+ 'X-Amz-Expires': String(expires),
479
+ 'X-Amz-SignedHeaders': 'host',
480
+ };
481
+ if (this.cfg.sessionToken)
482
+ query['X-Amz-Security-Token'] = this.cfg.sessionToken;
483
+ if (opts.responseContentType)
484
+ query['response-content-type'] = opts.responseContentType;
485
+ if (opts.responseContentDisposition) {
486
+ query['response-content-disposition'] = opts.responseContentDisposition;
487
+ }
488
+ const canonicalRequest = [
489
+ 'GET',
490
+ canonicalUri,
491
+ canonicalQueryString(query),
492
+ `host:${url.host}\n`,
493
+ 'host',
494
+ 'UNSIGNED-PAYLOAD',
495
+ ].join('\n');
496
+ const signature = this.sign(dateStamp, amzDate, scope, canonicalRequest);
497
+ query['X-Amz-Signature'] = signature;
498
+ return `${url.origin}${canonicalUri}?${canonicalQueryString(query)}`;
499
+ }
500
+ // --- signing --------------------------------------------------------------
501
+ /**
502
+ * Path-style: `https://host/<bucket>/<key>`. Virtual-hosted:
503
+ * `https://<bucket>.host/<key>`.
504
+ *
505
+ * The canonical URI is built by RFC-3986-encoding each SEGMENT and is carried
506
+ * separately from `URL.pathname`, because `new URL()` normalises `.`/`..`
507
+ * segments and re-encodes some characters. Signing one string and sending
508
+ * another is the classic SigV4 bug and produces a 403 that looks like a
509
+ * credentials problem.
510
+ */
511
+ objectUrl(key) {
512
+ const base = new URL(this.cfg.endpoint);
513
+ const segments = key === '' ? [] : key.split('/');
514
+ let host = base.host;
515
+ let path;
516
+ if (this.cfg.pathStyle) {
517
+ path = '/' + [this.cfg.bucket, ...segments].map(rfc3986).join('/');
518
+ }
519
+ else {
520
+ host = `${this.cfg.bucket}.${base.host}`;
521
+ path = '/' + segments.map(rfc3986).join('/');
522
+ }
523
+ const url = new URL(`${base.protocol}//${host}${path}`);
524
+ return { url, canonicalUri: path };
525
+ }
526
+ sign(dateStamp, _amzDate, scope, canonicalRequest) {
527
+ const stringToSign = [
528
+ 'AWS4-HMAC-SHA256',
529
+ _amzDate,
530
+ scope,
531
+ sha256Hex(Buffer.from(canonicalRequest, 'utf8')),
532
+ ].join('\n');
533
+ let k = Buffer.from(`AWS4${this.cfg.secretAccessKey}`, 'utf8');
534
+ for (const part of [dateStamp, this.cfg.region, 's3', 'aws4_request']) {
535
+ k = createHmac('sha256', k).update(part, 'utf8').digest();
536
+ }
537
+ return createHmac('sha256', k).update(stringToSign, 'utf8').digest('hex');
538
+ }
539
+ async signedFetch(req) {
540
+ const { url, canonicalUri } = this.objectUrl(req.key);
541
+ const query = req.query ?? {};
542
+ const cqs = canonicalQueryString(query);
543
+ const now = new Date();
544
+ const amzDate = amzDateOf(now);
545
+ const dateStamp = amzDate.slice(0, 8);
546
+ const payloadHash = sha256Hex(req.body ?? new Uint8Array());
547
+ const headers = {
548
+ host: url.host,
549
+ 'x-amz-content-sha256': payloadHash,
550
+ 'x-amz-date': amzDate,
551
+ };
552
+ if (this.cfg.sessionToken)
553
+ headers['x-amz-security-token'] = this.cfg.sessionToken;
554
+ // Lowercase every supplied header name: SigV4 canonicalises on the lowercase
555
+ // name and sorts on it, so a `Content-Type` from a caller would sort into a
556
+ // different position than the `content-type` actually sent.
557
+ for (const [k, v] of Object.entries(req.headers ?? {}))
558
+ headers[k.toLowerCase()] = v;
559
+ const names = Object.keys(headers).sort();
560
+ const signedHeaders = names.join(';');
561
+ // Header values are trimmed AND internal whitespace runs collapsed, per the
562
+ // SigV4 spec. Skipping the collapse silently breaks any value with a double
563
+ // space in it -- e.g. a content-disposition filename.
564
+ const canonicalHeaders = names.map((h) => `${h}:${canonicalHeaderValue(headers[h])}\n`).join('');
565
+ const canonicalRequest = [
566
+ req.method,
567
+ canonicalUri,
568
+ cqs,
569
+ canonicalHeaders,
570
+ signedHeaders,
571
+ payloadHash,
572
+ ].join('\n');
573
+ const scope = `${dateStamp}/${this.cfg.region}/s3/aws4_request`;
574
+ const signature = this.sign(dateStamp, amzDate, scope, canonicalRequest);
575
+ headers['authorization'] =
576
+ `AWS4-HMAC-SHA256 Credential=${this.cfg.accessKeyId}/${scope}, ` +
577
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
578
+ const target = cqs === '' ? `${url.origin}${canonicalUri}` : `${url.origin}${canonicalUri}?${cqs}`;
579
+ // `host` is set by the HTTP client from the URL and cannot be overridden in
580
+ // undici; it is in `headers` only so that it is signed. Sending it too is
581
+ // harmless where allowed and rejected where not, so it is dropped here and
582
+ // the signature is still computed over the value the client will send.
583
+ const { host: _host, ...wire } = headers;
584
+ return fetch(target, {
585
+ method: req.method,
586
+ headers: wire,
587
+ ...(req.body !== undefined && req.method !== 'GET' && req.method !== 'HEAD'
588
+ ? { body: req.body }
589
+ : {}),
590
+ });
591
+ }
592
+ }
593
+ // -----------------------------------------------------------------------------
594
+ // SigV4 primitives
595
+ // -----------------------------------------------------------------------------
596
+ function sha256Hex(data) {
597
+ return createHash('sha256').update(data).digest('hex');
598
+ }
599
+ function amzDateOf(d) {
600
+ return d.toISOString().replace(/[:-]|\.\d{3}/g, '');
601
+ }
602
+ /**
603
+ * RFC 3986 unreserved-set encoding.
604
+ *
605
+ * `encodeURIComponent` leaves `!'()*` alone and `encodeURI` additionally leaves
606
+ * `#?&=+,:;@$` alone. The previous implementation used `encodeURI` on the whole
607
+ * key, which meant a key containing `#` truncated the URL at the fragment, a key
608
+ * containing `?` started a query string, and a key containing `+` signed one
609
+ * byte sequence and sent another. Keys are constructed by us today, but "the
610
+ * caller never puts a `#` in a key" is not a property the type system carries.
611
+ */
612
+ export function rfc3986(s) {
613
+ return encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
614
+ }
615
+ /** Sorted by encoded key, then encoded value. Empty values keep their `=`. */
616
+ export function canonicalQueryString(query) {
617
+ return Object.entries(query)
618
+ .map(([k, v]) => [rfc3986(k), rfc3986(v)])
619
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
620
+ .map(([k, v]) => `${k}=${v}`)
621
+ .join('&');
622
+ }
623
+ function canonicalHeaderValue(v) {
624
+ return v.trim().replace(/\s+/g, ' ');
625
+ }
626
+ // -----------------------------------------------------------------------------
627
+ // Small helpers
628
+ // -----------------------------------------------------------------------------
629
+ /**
630
+ * Read until `n` bytes are available or the stream ends.
631
+ *
632
+ * `done` means "the stream is finished AND this is everything that was left",
633
+ * which is what lets `putStream` decide between a one-shot PUT and multipart
634
+ * without a second read.
635
+ */
636
+ async function readAtLeast(reader, n) {
637
+ const chunks = [];
638
+ let total = 0;
639
+ while (total < n) {
640
+ const { done, value } = await reader.read();
641
+ if (done)
642
+ return { chunk: concat(chunks, total), done: true };
643
+ if (!value || value.byteLength === 0)
644
+ continue;
645
+ chunks.push(value);
646
+ total += value.byteLength;
647
+ }
648
+ return { chunk: concat(chunks, total), done: false };
649
+ }
650
+ function concat(chunks, total) {
651
+ if (chunks.length === 1 && chunks[0].byteLength === total)
652
+ return chunks[0];
653
+ const out = new Uint8Array(total);
654
+ let at = 0;
655
+ for (const c of chunks) {
656
+ out.set(c, at);
657
+ at += c.byteLength;
658
+ }
659
+ return out;
660
+ }
661
+ function xmlTag(xml, tag) {
662
+ const m = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml);
663
+ return m ? (m[1] ?? null) : null;
664
+ }
665
+ function escapeXml(s) {
666
+ return s.replace(/[<>&'"]/g, (c) => c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '&' ? '&amp;' : c === "'" ? '&apos;' : '&quot;');
667
+ }
668
+ function unescapeXml(s) {
669
+ return s
670
+ .replace(/&lt;/g, '<')
671
+ .replace(/&gt;/g, '>')
672
+ .replace(/&quot;/g, '"')
673
+ .replace(/&apos;/g, "'")
674
+ .replace(/&amp;/g, '&');
675
+ }
676
+ /**
677
+ * Turn an S3 error response into an Error carrying the store's own error code.
678
+ *
679
+ * The status alone is not enough to act on: `AccessDenied`, `SignatureDoesNotMatch`
680
+ * and `InvalidAccessKeyId` are all 403 and mean three completely different
681
+ * operational problems (permissions / our bug / rotated key). Throwing the raw
682
+ * status is how a signing bug spends a week being investigated as an IAM policy.
683
+ */
684
+ async function s3Error(op, res) {
685
+ let code = null;
686
+ let message = null;
687
+ try {
688
+ const text = await res.text();
689
+ code = xmlTag(text, 'Code');
690
+ message = xmlTag(text, 'Message');
691
+ }
692
+ catch {
693
+ /* body already consumed or not XML */
694
+ }
695
+ const err = new Error(`storage ${op} failed: ${res.status}${code ? ` ${code}` : ''}${message ? ` -- ${message}` : ''}`);
696
+ err.s3Code = code;
697
+ err.status = res.status;
698
+ return err;
699
+ }
700
+ //# sourceMappingURL=storage.js.map