@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,437 @@
1
+ /**
2
+ * THE S3 ADAPTER, ACTUALLY EXERCISED.
3
+ *
4
+ * `S3Storage` shipped as "included so the interface is proven to be
5
+ * implementable against real object storage, not because it has been
6
+ * exercised". This suite exercises it, against `test/local-s3.mjs` -- a local
7
+ * S3-protocol server that recomputes every SigV4 signature from the request as
8
+ * received and returns `SignatureDoesNotMatch` on any mismatch.
9
+ *
10
+ * WHAT THIS PROVES: the wire format. Canonical URI encoding, canonical query
11
+ * string ordering, header canonicalisation, the payload hash, the signing key
12
+ * derivation, multipart sequencing and part-size rules, ranged reads, HEAD,
13
+ * DELETE, list pagination, and presigned URL construction including expiry.
14
+ *
15
+ * WHAT IT DOES NOT PROVE: anything about real AWS or real R2 -- TLS, IAM policy
16
+ * evaluation, R2's divergences from S3, eventual consistency, throttling,
17
+ * checksum algorithms AWS may require in future. See `test/s3-live.test.ts`,
18
+ * which runs the same operations against a real bucket when the
19
+ * `FILELAYER_TEST_S3_*` environment variables are set, and skips otherwise.
20
+ */
21
+
22
+ import assert from 'node:assert/strict';
23
+ import { describe, it, before, after } from 'node:test';
24
+ import { createLocalS3 } from './local-s3.mjs';
25
+ import {
26
+ MemoryStorage,
27
+ S3Storage,
28
+ bytesToStream,
29
+ canonicalQueryString,
30
+ collectStream,
31
+ rfc3986,
32
+ } from '../src/storage.ts';
33
+
34
+ const AK = 'AKIAFILELAYERTEST000';
35
+ const SK = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY';
36
+
37
+ const enc = (s: string) => new TextEncoder().encode(s);
38
+ const dec = (u: Uint8Array) => new TextDecoder().decode(u);
39
+
40
+ describe('S3Storage against a signature-verifying local S3', () => {
41
+ let s3: ReturnType<typeof createLocalS3>;
42
+ let store: S3Storage;
43
+ const sigFailures: unknown[] = [];
44
+
45
+ before(async () => {
46
+ s3 = createLocalS3({ accessKeyId: AK, secretAccessKey: SK, bucket: 'fl-test' });
47
+ s3.server.on('sigfail', (v: unknown) => sigFailures.push(v));
48
+ await s3.listen();
49
+ store = new S3Storage({
50
+ endpoint: s3.endpoint(),
51
+ bucket: 'fl-test',
52
+ region: 'auto',
53
+ accessKeyId: AK,
54
+ secretAccessKey: SK,
55
+ partSizeBytes: 5 * 1024 * 1024,
56
+ });
57
+ });
58
+
59
+ after(async () => {
60
+ await s3.close();
61
+ });
62
+
63
+ it('never produces a signature the server rejects', async () => {
64
+ // Asserted at the end of the suite too, but stated here so the failure
65
+ // message names the real problem rather than a downstream symptom.
66
+ assert.deepEqual(sigFailures, []);
67
+ });
68
+
69
+ it('put/get/head/delete round trip', async () => {
70
+ const r = await store.put('org/one.txt', enc('hello world'), 'text/plain');
71
+ assert.equal(r.bytes, 11);
72
+ assert.ok(r.etag);
73
+
74
+ assert.equal(dec((await store.get('org/one.txt'))!), 'hello world');
75
+
76
+ const h = await store.head('org/one.txt');
77
+ assert.equal(h!.size, 11);
78
+ assert.equal(h!.contentType, 'text/plain');
79
+ assert.ok(h!.lastModified instanceof Date);
80
+
81
+ await store.delete('org/one.txt');
82
+ assert.equal(await store.get('org/one.txt'), null);
83
+ assert.equal(await store.head('org/one.txt'), null);
84
+ });
85
+
86
+ it('a missing key is null, not an exception', async () => {
87
+ assert.equal(await store.get('org/nope'), null);
88
+ assert.equal(await store.head('org/nope'), null);
89
+ assert.equal(await store.stream('org/nope'), null);
90
+ });
91
+
92
+ it('deleting a key that never existed is not an error', async () => {
93
+ await store.delete('org/never-existed');
94
+ });
95
+
96
+ /**
97
+ * BUG #1 (fixed). The old adapter built the URL with `encodeURI(key)`, which
98
+ * leaves `#?&=+,:;@$!'()*` unescaped. A `#` truncated the request at the
99
+ * fragment, a `?` started a query string, and a `+` signed one byte sequence
100
+ * while sending another. Every one of these is a 403 or a silent write to the
101
+ * wrong key.
102
+ */
103
+ it('handles keys containing characters encodeURI would have left alone', async () => {
104
+ const nasty = [
105
+ 'org/a#b.txt',
106
+ 'org/a?b.txt',
107
+ 'org/a+b.txt',
108
+ 'org/a b.txt',
109
+ 'org/a&b=c.txt',
110
+ "org/a'b(c).txt",
111
+ 'org/a,b;c:d@e.txt',
112
+ 'org/ünïcøde-Ω.txt',
113
+ 'org/a%2Fb.txt',
114
+ 'org/sub/dir/deep.txt',
115
+ ];
116
+ for (const key of nasty) {
117
+ await store.put(key, enc(`v:${key}`), 'application/octet-stream');
118
+ assert.equal(dec((await store.get(key))!), `v:${key}`, key);
119
+ assert.equal((await store.head(key))!.size, enc(`v:${key}`).byteLength, key);
120
+ }
121
+ // The keys really are distinct objects on the server, i.e. nothing collided.
122
+ for (const key of nasty) assert.ok(s3.objects.has(key), `server is missing ${key}`);
123
+ for (const key of nasty) await store.delete(key);
124
+ });
125
+
126
+ it('a key with a literal + is not confused with a space', async () => {
127
+ await store.put('org/plus+key', enc('plus'), 'text/plain');
128
+ await store.put('org/space key', enc('space'), 'text/plain');
129
+ assert.equal(dec((await store.get('org/plus+key'))!), 'plus');
130
+ assert.equal(dec((await store.get('org/space key'))!), 'space');
131
+ });
132
+
133
+ it('streams, with the right length and content type', async () => {
134
+ await store.put('org/stream.bin', enc('0123456789'), 'application/pdf');
135
+ const s = (await store.stream('org/stream.bin'))!;
136
+ assert.equal(s.size, 10);
137
+ assert.equal(s.contentType, 'application/pdf');
138
+ assert.equal(dec(await collectStream(s.body)), '0123456789');
139
+ });
140
+
141
+ it('honours ranges and reports content-range', async () => {
142
+ await store.put('org/range.bin', enc('abcdefghij'), 'text/plain');
143
+ const mid = (await store.stream('org/range.bin', { range: { start: 2, end: 5 } }))!;
144
+ assert.equal(dec(await collectStream(mid.body)), 'cdef');
145
+ assert.deepEqual(mid.range, { start: 2, end: 5, total: 10 });
146
+
147
+ const tail = (await store.stream('org/range.bin', { range: { start: 7 } }))!;
148
+ assert.equal(dec(await collectStream(tail.body)), 'hij');
149
+ assert.deepEqual(tail.range, { start: 7, end: 9, total: 10 });
150
+
151
+ // A range past the end is 416, which we surface as "no such bytes".
152
+ assert.equal(await store.stream('org/range.bin', { range: { start: 999 } }), null);
153
+ });
154
+
155
+ /**
156
+ * BUG #2 (fixed). There was no multipart path at all: `put()` took a
157
+ * `Uint8Array`, so a large upload was a single PUT of an entirely resident
158
+ * buffer. S3 caps a single PUT at 5 GB and the heap caps it far lower.
159
+ */
160
+ it('uploads a large stream via multipart and reassembles it byte-exactly', async () => {
161
+ const partSize = 5 * 1024 * 1024;
162
+ const total = partSize * 2 + 1234; // 3 parts: full, full, remainder
163
+ const source = deterministicStream(total, 64 * 1024);
164
+
165
+ const r = await store.put('org/big.bin', source, 'application/octet-stream');
166
+ assert.equal(r.bytes, total);
167
+
168
+ const stored = s3.objects.get('org/big.bin')!.body;
169
+ assert.equal(stored.length, total);
170
+ assert.deepEqual(
171
+ Buffer.from(stored.subarray(0, 4096)),
172
+ Buffer.from(deterministicBytes(total).subarray(0, 4096)),
173
+ );
174
+ assert.deepEqual(Buffer.from(stored), Buffer.from(deterministicBytes(total)));
175
+
176
+ const posts = s3.requestLog.filter((l) => l.key === 'org/big.bin' && l.method === 'PUT' && l.query['uploadId']);
177
+ assert.equal(posts.length, 3, 'exactly three UploadPart calls');
178
+ await store.delete('org/big.bin');
179
+ });
180
+
181
+ it('a stream that fits in one part does NOT start a multipart upload', async () => {
182
+ const before = s3.uploads.size;
183
+ const r = await store.put('org/small-stream.bin', bytesToStream(enc('tiny')), 'text/plain');
184
+ assert.equal(r.bytes, 4);
185
+ assert.equal(s3.uploads.size, before, 'no multipart upload was created');
186
+ const initiates = s3.requestLog.filter(
187
+ (l) => l.key === 'org/small-stream.bin' && 'uploads' in l.query,
188
+ );
189
+ assert.equal(initiates.length, 0);
190
+ assert.equal(dec((await store.get('org/small-stream.bin'))!), 'tiny');
191
+ });
192
+
193
+ it('an empty stream produces an empty object, not a failed multipart', async () => {
194
+ const r = await store.put('org/empty.bin', bytesToStream(new Uint8Array(0)), 'text/plain');
195
+ assert.equal(r.bytes, 0);
196
+ assert.equal((await store.get('org/empty.bin'))!.byteLength, 0);
197
+ });
198
+
199
+ /**
200
+ * BUG #3 (fixed). A failed multipart upload used to be impossible because
201
+ * multipart did not exist; now that it does, an abandoned upload is billable
202
+ * storage nothing points at. The adapter aborts on any part failure.
203
+ */
204
+ it('aborts the multipart upload when a part fails', async () => {
205
+ s3.clearFaults();
206
+ let seen = 0;
207
+ s3.injectFault({ key: 'org/doomed.bin', method: 'PUT', status: 503, code: 'SlowDown' });
208
+ const before = s3.uploads.size;
209
+ await assert.rejects(
210
+ () => store.put('org/doomed.bin', deterministicStream(6 * 1024 * 1024, 64 * 1024), 'application/octet-stream'),
211
+ /uploadPart failed: 503 SlowDown/,
212
+ );
213
+ s3.clearFaults();
214
+ seen = s3.uploads.size;
215
+ assert.equal(seen, before, 'the abandoned upload was aborted, not leaked');
216
+ assert.equal(s3.objects.has('org/doomed.bin'), false);
217
+ });
218
+
219
+ /**
220
+ * BUG #4 (fixed). Errors were thrown as `storage get failed: 403`. Three
221
+ * completely different operational problems -- a wrong policy, a rotated key
222
+ * and a signing bug in our own code -- are all 403, and the status alone sends
223
+ * you to the wrong one.
224
+ */
225
+ it('surfaces the S3 error code, not just the status', async () => {
226
+ s3.deactivateAccessKey(AK);
227
+ await assert.rejects(() => store.get('org/anything'), /InvalidAccessKeyId/);
228
+ s3.activateAccessKey(AK);
229
+ });
230
+
231
+ it('a wrong secret produces SignatureDoesNotMatch, proving the server verifies', async () => {
232
+ const wrong = new S3Storage({
233
+ endpoint: s3.endpoint(),
234
+ bucket: 'fl-test',
235
+ region: 'auto',
236
+ accessKeyId: AK,
237
+ secretAccessKey: SK + 'x',
238
+ });
239
+ await assert.rejects(() => wrong.put('org/x', enc('x'), 'text/plain'), /SignatureDoesNotMatch/);
240
+ // ...and the harness saw it as a signature failure, which is the whole
241
+ // reason this harness is worth having.
242
+ assert.ok(sigFailures.length >= 1);
243
+ sigFailures.length = 0;
244
+ });
245
+
246
+ it('lists with a prefix and paginates', async () => {
247
+ for (let i = 0; i < 7; i++) await store.put(`lst/${i}`, enc(String(i)), 'text/plain');
248
+ const first = await store.list('lst/', { limit: 3 });
249
+ assert.equal(first.entries.length, 3);
250
+ assert.ok(first.cursor);
251
+ const second = await store.list('lst/', { limit: 10, cursor: first.cursor });
252
+ assert.equal(second.entries.length, 4);
253
+ assert.equal(second.cursor, null);
254
+ const keys = [...first.entries, ...second.entries].map((e) => e.key);
255
+ assert.deepEqual(keys, ['lst/0', 'lst/1', 'lst/2', 'lst/3', 'lst/4', 'lst/5', 'lst/6']);
256
+ assert.equal(first.entries[0]!.size, 1);
257
+ assert.ok(first.entries[0]!.lastModified instanceof Date);
258
+ });
259
+
260
+ it('presigns a GET that the store accepts, with the response headers pinned', async () => {
261
+ await store.put('org/presigned.txt', enc('presigned body'), 'text/html');
262
+ const url = await store.presignGet('org/presigned.txt', {
263
+ expiresInSeconds: 60,
264
+ responseContentType: 'application/octet-stream',
265
+ responseContentDisposition: 'attachment; filename="x.txt"',
266
+ });
267
+ const res = await fetch(url);
268
+ assert.equal(res.status, 200);
269
+ // The STORE serves the neutralised type, so a redirect cannot lose the
270
+ // protections `deliveryHeaders()` guarantees on the proxied path.
271
+ assert.equal(res.headers.get('content-type'), 'application/octet-stream');
272
+ assert.equal(res.headers.get('content-disposition'), 'attachment; filename="x.txt"');
273
+ assert.equal(await res.text(), 'presigned body');
274
+ });
275
+
276
+ it('a presigned URL expires', async () => {
277
+ await store.put('org/expiring.txt', enc('x'), 'text/plain');
278
+ const url = await store.presignGet('org/expiring.txt', { expiresInSeconds: 1 });
279
+ assert.equal((await fetch(url)).status, 200);
280
+ await new Promise((r) => setTimeout(r, 1100));
281
+ const late = await fetch(url);
282
+ assert.equal(late.status, 403);
283
+ assert.match(await late.text(), /AccessDenied/);
284
+ });
285
+
286
+ it('a tampered presigned URL is rejected', async () => {
287
+ await store.put('org/tamper.txt', enc('secret'), 'text/plain');
288
+ await store.put('org/other.txt', enc('other'), 'text/plain');
289
+ const url = await store.presignGet('org/tamper.txt', { expiresInSeconds: 60 });
290
+ const swapped = url.replace('tamper.txt', 'other.txt');
291
+ const res = await fetch(swapped);
292
+ assert.equal(res.status, 403);
293
+ assert.match(await res.text(), /SignatureDoesNotMatch/);
294
+ sigFailures.length = 0;
295
+ });
296
+
297
+ it('clamps a presigned TTL to the adapter ceiling', async () => {
298
+ const capped = new S3Storage({
299
+ endpoint: s3.endpoint(),
300
+ bucket: 'fl-test',
301
+ region: 'auto',
302
+ accessKeyId: AK,
303
+ secretAccessKey: SK,
304
+ maxPresignSeconds: 30,
305
+ });
306
+ const url = await capped.presignGet('org/tamper.txt', { expiresInSeconds: 86400 });
307
+ assert.match(url, /X-Amz-Expires=30(&|$)/);
308
+ });
309
+
310
+ it('names itself, and derives r2 from an R2 endpoint', () => {
311
+ assert.equal(store.provider, 's3');
312
+ assert.equal(
313
+ new S3Storage({
314
+ endpoint: 'https://abc123.r2.cloudflarestorage.com',
315
+ bucket: 'b',
316
+ region: 'auto',
317
+ accessKeyId: 'a',
318
+ secretAccessKey: 'b',
319
+ }).provider,
320
+ 'r2',
321
+ );
322
+ assert.equal(
323
+ new S3Storage({
324
+ endpoint: 'https://abc123.r2.cloudflarestorage.com',
325
+ bucket: 'b',
326
+ region: 'auto',
327
+ accessKeyId: 'a',
328
+ secretAccessKey: 'b',
329
+ provider: 'r2-eu',
330
+ }).provider,
331
+ 'r2-eu',
332
+ );
333
+ });
334
+
335
+ it('refuses to construct with missing configuration', () => {
336
+ assert.throws(
337
+ () =>
338
+ new S3Storage({
339
+ endpoint: '',
340
+ bucket: 'b',
341
+ region: 'auto',
342
+ accessKeyId: 'a',
343
+ secretAccessKey: 'b',
344
+ }),
345
+ /missing required config 'endpoint'/,
346
+ );
347
+ });
348
+
349
+ it('supports virtual-hosted style URLs', () => {
350
+ const vh = new S3Storage({
351
+ endpoint: 'https://s3.eu-west-1.amazonaws.com',
352
+ bucket: 'my-bucket',
353
+ region: 'eu-west-1',
354
+ accessKeyId: 'a',
355
+ secretAccessKey: 'b',
356
+ pathStyle: false,
357
+ });
358
+ // Exercised only for URL construction here; the local harness is path-style.
359
+ return vh.presignGet('k/1.txt', { expiresInSeconds: 60 }).then((url) => {
360
+ assert.match(url, /^https:\/\/my-bucket\.s3\.eu-west-1\.amazonaws\.com\/k\/1\.txt\?/);
361
+ });
362
+ });
363
+
364
+ it('the harness never saw a bad signature across the whole suite', () => {
365
+ assert.deepEqual(sigFailures, []);
366
+ });
367
+ });
368
+
369
+ describe('SigV4 encoding primitives', () => {
370
+ it('rfc3986 escapes everything encodeURIComponent leaves behind', () => {
371
+ assert.equal(rfc3986("a!b'c(d)e*f"), 'a%21b%27c%28d%29e%2Af');
372
+ assert.equal(rfc3986('a/b'), 'a%2Fb');
373
+ assert.equal(rfc3986('a b'), 'a%20b');
374
+ assert.equal(rfc3986('a+b'), 'a%2Bb');
375
+ assert.equal(rfc3986('~-._'), '~-._', 'unreserved characters are untouched');
376
+ });
377
+
378
+ it('canonical query strings sort on the ENCODED key', () => {
379
+ assert.equal(
380
+ canonicalQueryString({ b: '2', a: '1', 'X-Amz-Date': 'z' }),
381
+ 'X-Amz-Date=z&a=1&b=2',
382
+ );
383
+ assert.equal(canonicalQueryString({ uploads: '' }), 'uploads=');
384
+ });
385
+ });
386
+
387
+ describe('MemoryStorage satisfies the same interface', () => {
388
+ it('round trips, streams, ranges, heads and lists', async () => {
389
+ const m = new MemoryStorage();
390
+ assert.equal(m.provider, 'memory');
391
+ await m.put('a/1', enc('abcdefghij'), 'text/plain');
392
+ assert.equal(dec((await m.get('a/1'))!), 'abcdefghij');
393
+ assert.equal((await m.head('a/1'))!.size, 10);
394
+ const r = (await m.stream('a/1', { range: { start: 1, end: 3 } }))!;
395
+ assert.equal(dec(await collectStream(r.body)), 'bcd');
396
+ assert.deepEqual(r.range, { start: 1, end: 3, total: 10 });
397
+ const l = await m.list('a/');
398
+ assert.equal(l.entries.length, 1);
399
+ });
400
+
401
+ it('accepts a stream body', async () => {
402
+ const m = new MemoryStorage();
403
+ const r = await m.put('a/2', bytesToStream(enc('streamed')), 'text/plain');
404
+ assert.equal(r.bytes, 8);
405
+ assert.equal(dec((await m.get('a/2'))!), 'streamed');
406
+ });
407
+
408
+ it('cannot presign, and says so structurally', () => {
409
+ const m = new MemoryStorage();
410
+ assert.equal((m as { presignGet?: unknown }).presignGet, undefined);
411
+ });
412
+ });
413
+
414
+ // -----------------------------------------------------------------------------
415
+
416
+ function deterministicBytes(n: number): Uint8Array {
417
+ const out = new Uint8Array(n);
418
+ let x = 0x9e3779b9;
419
+ for (let i = 0; i < n; i++) {
420
+ x = (x * 1664525 + 1013904223) >>> 0;
421
+ out[i] = x & 0xff;
422
+ }
423
+ return out;
424
+ }
425
+
426
+ function deterministicStream(n: number, chunk: number): ReadableStream<Uint8Array> {
427
+ const all = deterministicBytes(n);
428
+ let at = 0;
429
+ return new ReadableStream<Uint8Array>({
430
+ pull(controller) {
431
+ if (at >= n) return void controller.close();
432
+ const end = Math.min(at + chunk, n);
433
+ controller.enqueue(all.subarray(at, end));
434
+ at = end;
435
+ },
436
+ });
437
+ }