@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,322 @@
1
+ /**
2
+ * LIVE S3 / R2 INTEGRATION.
3
+ *
4
+ * SKIPPED unless real credentials are present in the environment. Present them
5
+ * and it runs automatically -- in CI, in a local shell, anywhere -- with no flag
6
+ * to remember and no separate command. That is deliberate: a suite you have to
7
+ * opt into by name is a suite nobody runs.
8
+ *
9
+ * -----------------------------------------------------------------------------
10
+ * WHAT A MAINTAINER MUST SET
11
+ * -----------------------------------------------------------------------------
12
+ *
13
+ * REQUIRED (all five, or the suite skips):
14
+ *
15
+ * FILELAYER_TEST_S3_ENDPOINT https://<account>.r2.cloudflarestorage.com
16
+ * or https://s3.<region>.amazonaws.com
17
+ * FILELAYER_TEST_S3_BUCKET a bucket that may be written to and
18
+ * emptied. USE A DEDICATED TEST BUCKET.
19
+ * FILELAYER_TEST_S3_REGION 'auto' for R2, the real region for AWS
20
+ * FILELAYER_TEST_S3_ACCESS_KEY_ID
21
+ * FILELAYER_TEST_S3_SECRET_ACCESS_KEY
22
+ *
23
+ * OPTIONAL:
24
+ *
25
+ * FILELAYER_TEST_S3_SESSION_TOKEN for STS / temporary credentials
26
+ * FILELAYER_TEST_S3_PATH_STYLE 'false' to exercise virtual-hosted
27
+ * addressing (AWS default style).
28
+ * Defaults to path-style, which is what
29
+ * R2 requires.
30
+ * FILELAYER_TEST_S3_PREFIX key prefix. Defaults to
31
+ * 'filelayer-ci/'. Everything the suite
32
+ * writes lives under it and is deleted
33
+ * afterwards.
34
+ * FILELAYER_TEST_S3_MULTIPART '1' to run the multipart test, which
35
+ * uploads ~11 MB. Off by default so the
36
+ * suite stays cheap on every push;
37
+ * turn it ON in the nightly job.
38
+ *
39
+ * IAM PERMISSIONS REQUIRED on the bucket:
40
+ * s3:PutObject, s3:GetObject, s3:DeleteObject, s3:ListBucket,
41
+ * s3:AbortMultipartUpload (multipart cleanup),
42
+ * s3:ListBucketMultipartUploads (only if you enable the multipart test)
43
+ *
44
+ * -----------------------------------------------------------------------------
45
+ * WHY THIS FILE EXISTS EVEN THOUGH test/storage.test.ts PASSES
46
+ * -----------------------------------------------------------------------------
47
+ *
48
+ * `test/storage.test.ts` proves the WIRE FORMAT against a server that verifies
49
+ * every signature. It cannot prove anything about the counterparty: TLS, real
50
+ * IAM evaluation, R2's divergences from S3, AWS's checksum requirements,
51
+ * throttling and retry behaviour, read-after-write visibility, or the specific
52
+ * error codes a real store returns. Those are the things that break a storage
53
+ * adapter in production, and none of them can be simulated honestly. This suite
54
+ * is where they get tested; until it has run against a real bucket, the adapter
55
+ * is "wire-correct" and not "proven".
56
+ */
57
+
58
+ import assert from 'node:assert/strict';
59
+ import { describe, it, before, after } from 'node:test';
60
+ import { S3Storage, bytesToStream, collectStream } from '../src/storage.ts';
61
+ import { createTestDb } from '../src/db.ts';
62
+ import { Filelayer } from '../src/filelayer.ts';
63
+ import { REDIRECT_ACKNOWLEDGEMENT } from '../src/delivery.ts';
64
+
65
+ const env = process.env;
66
+ const REQUIRED = [
67
+ 'FILELAYER_TEST_S3_ENDPOINT',
68
+ 'FILELAYER_TEST_S3_BUCKET',
69
+ 'FILELAYER_TEST_S3_REGION',
70
+ 'FILELAYER_TEST_S3_ACCESS_KEY_ID',
71
+ 'FILELAYER_TEST_S3_SECRET_ACCESS_KEY',
72
+ ] as const;
73
+
74
+ const missing = REQUIRED.filter((k) => !env[k]);
75
+ const enabled = missing.length === 0;
76
+ const skip = enabled
77
+ ? false
78
+ : `live S3 credentials not present (missing: ${missing.join(', ')}); see the header of this file`;
79
+
80
+ const PREFIX = (env['FILELAYER_TEST_S3_PREFIX'] ?? 'filelayer-ci/').replace(/\/*$/, '/');
81
+ const RUN = `${PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}/`;
82
+ const MULTIPART = env['FILELAYER_TEST_S3_MULTIPART'] === '1';
83
+
84
+ function makeStorage(): S3Storage {
85
+ return new S3Storage({
86
+ endpoint: env['FILELAYER_TEST_S3_ENDPOINT']!,
87
+ bucket: env['FILELAYER_TEST_S3_BUCKET']!,
88
+ region: env['FILELAYER_TEST_S3_REGION']!,
89
+ accessKeyId: env['FILELAYER_TEST_S3_ACCESS_KEY_ID']!,
90
+ secretAccessKey: env['FILELAYER_TEST_S3_SECRET_ACCESS_KEY']!,
91
+ ...(env['FILELAYER_TEST_S3_SESSION_TOKEN']
92
+ ? { sessionToken: env['FILELAYER_TEST_S3_SESSION_TOKEN'] }
93
+ : {}),
94
+ ...(env['FILELAYER_TEST_S3_PATH_STYLE'] === 'false' ? { pathStyle: false } : {}),
95
+ partSizeBytes: 5 * 1024 * 1024,
96
+ });
97
+ }
98
+
99
+ const enc = (s: string) => new TextEncoder().encode(s);
100
+ const dec = (u: Uint8Array) => new TextDecoder().decode(u);
101
+
102
+ describe('S3Storage against LIVE object storage', { skip }, () => {
103
+ let store: S3Storage;
104
+ const written: string[] = [];
105
+ const key = (name: string) => {
106
+ const k = `${RUN}${name}`;
107
+ written.push(k);
108
+ return k;
109
+ };
110
+
111
+ before(() => {
112
+ store = makeStorage();
113
+ });
114
+
115
+ // Best effort, and loud if it fails: a test suite that leaves objects in a
116
+ // customer-shaped bucket is a test suite that costs money forever.
117
+ after(async () => {
118
+ if (!enabled) return;
119
+ for (const k of written) {
120
+ try {
121
+ await store.delete(k);
122
+ } catch (err) {
123
+ console.error(`LEAKED TEST OBJECT ${k}: ${String(err)}`);
124
+ }
125
+ }
126
+ });
127
+
128
+ it('reports which provider it is', () => {
129
+ assert.ok(['s3', 'r2'].includes(store.provider), `unexpected provider ${store.provider}`);
130
+ });
131
+
132
+ it('put / get / head / delete', async () => {
133
+ const k = key('basic.txt');
134
+ const r = await store.put(k, enc('live hello'), 'text/plain');
135
+ assert.equal(r.bytes, 10);
136
+ assert.ok(r.etag, 'a real store returns an ETag');
137
+
138
+ assert.equal(dec((await store.get(k))!), 'live hello');
139
+
140
+ const h = await store.head(k);
141
+ assert.equal(h!.size, 10);
142
+ // R2 and S3 both echo the content type; if this fails the adapter is not
143
+ // sending it on the PUT, which the local harness cannot detect because it
144
+ // stores whatever it is given.
145
+ assert.equal(h!.contentType, 'text/plain');
146
+ assert.ok(h!.lastModified instanceof Date && !Number.isNaN(h!.lastModified.getTime()));
147
+
148
+ await store.delete(k);
149
+ assert.equal(await store.get(k), null);
150
+ assert.equal(await store.head(k), null);
151
+ });
152
+
153
+ it('a missing key is null, not a thrown error', async () => {
154
+ assert.equal(await store.get(`${RUN}definitely-absent`), null);
155
+ assert.equal(await store.head(`${RUN}definitely-absent`), null);
156
+ assert.equal(await store.stream(`${RUN}definitely-absent`), null);
157
+ });
158
+
159
+ it('deleting an absent key succeeds', async () => {
160
+ await store.delete(`${RUN}also-absent`);
161
+ });
162
+
163
+ it('survives keys with characters that break naive URL construction', async () => {
164
+ // The exact class of key that made the previous adapter write to the wrong
165
+ // object. Worth running against a real store because S3 and R2 disagree
166
+ // with each other about some of these.
167
+ for (const name of ['a#b.txt', 'a b.txt', 'a+b.txt', "a'b(c).txt", 'ünï-Ω.txt', 'a,b;c@d.txt']) {
168
+ const k = key(name);
169
+ await store.put(k, enc(`v:${name}`), 'application/octet-stream');
170
+ assert.equal(dec((await store.get(k))!), `v:${name}`, name);
171
+ }
172
+ // A '?' in a key is legal in S3 but several proxies mangle it, so it is
173
+ // asserted separately and its failure is informative rather than fatal to
174
+ // the rest of the suite.
175
+ const q = key('a?b.txt');
176
+ await store.put(q, enc('q'), 'application/octet-stream');
177
+ assert.equal(dec((await store.get(q))!), 'q');
178
+ });
179
+
180
+ it('streams, and honours ranges', async () => {
181
+ const k = key('range.bin');
182
+ await store.put(k, enc('abcdefghij'), 'application/octet-stream');
183
+
184
+ const whole = (await store.stream(k))!;
185
+ assert.equal(dec(await collectStream(whole.body)), 'abcdefghij');
186
+ assert.equal(whole.size, 10);
187
+
188
+ const part = (await store.stream(k, { range: { start: 2, end: 5 } }))!;
189
+ assert.equal(dec(await collectStream(part.body)), 'cdef');
190
+ assert.deepEqual(part.range, { start: 2, end: 5, total: 10 });
191
+
192
+ const tail = (await store.stream(k, { range: { start: 8 } }))!;
193
+ assert.equal(dec(await collectStream(tail.body)), 'ij');
194
+ });
195
+
196
+ it('lists with a prefix', async () => {
197
+ const a = key('list/1');
198
+ const b = key('list/2');
199
+ await store.put(a, enc('1'), 'text/plain');
200
+ await store.put(b, enc('2'), 'text/plain');
201
+ const page = await store.list(`${RUN}list/`);
202
+ const keys = page.entries.map((e) => e.key).sort();
203
+ assert.deepEqual(keys, [a, b].sort());
204
+ assert.ok(page.entries[0]!.lastModified instanceof Date, 'orphan collection needs this');
205
+ assert.equal(page.entries[0]!.size, 1);
206
+ });
207
+
208
+ it('presigns a GET the real store honours, with pinned response headers', async () => {
209
+ const k = key('presigned.html');
210
+ await store.put(k, enc('<b>hi</b>'), 'text/html');
211
+ const url = await store.presignGet(k, {
212
+ expiresInSeconds: 60,
213
+ responseContentType: 'application/octet-stream',
214
+ responseContentDisposition: 'attachment; filename="x.html"',
215
+ });
216
+ const res = await fetch(url);
217
+ const body = await res.text();
218
+ assert.equal(res.status, 200, body);
219
+ // If this fails, redirect delivery would serve user-uploaded HTML as HTML.
220
+ assert.equal(res.headers.get('content-type'), 'application/octet-stream');
221
+ assert.match(res.headers.get('content-disposition') ?? '', /^attachment/);
222
+ assert.equal(body, '<b>hi</b>');
223
+ });
224
+
225
+ it('a presigned URL expires, and a tampered one is refused', async () => {
226
+ const k = key('expiring.txt');
227
+ await store.put(k, enc('x'), 'text/plain');
228
+ const url = await store.presignGet(k, { expiresInSeconds: 1 });
229
+ assert.equal((await fetch(url)).status, 200);
230
+ await new Promise((r) => setTimeout(r, 2000));
231
+ assert.equal((await fetch(url)).status, 403, 'the real store must enforce the expiry');
232
+
233
+ const fresh = await store.presignGet(k, { expiresInSeconds: 60 });
234
+ const tampered = fresh.replace('expiring.txt', 'something-else.txt');
235
+ assert.ok([403, 404].includes((await fetch(tampered)).status));
236
+ });
237
+
238
+ it('surfaces a real error code rather than a bare status', async () => {
239
+ const bad = new S3Storage({
240
+ endpoint: env['FILELAYER_TEST_S3_ENDPOINT']!,
241
+ bucket: env['FILELAYER_TEST_S3_BUCKET']!,
242
+ region: env['FILELAYER_TEST_S3_REGION']!,
243
+ accessKeyId: env['FILELAYER_TEST_S3_ACCESS_KEY_ID']!,
244
+ secretAccessKey: `${env['FILELAYER_TEST_S3_SECRET_ACCESS_KEY']}-wrong`,
245
+ });
246
+ await assert.rejects(
247
+ () => bad.get(`${RUN}whatever`),
248
+ /SignatureDoesNotMatch|InvalidAccessKeyId|AccessDenied/,
249
+ );
250
+ });
251
+
252
+ it(
253
+ 'multipart: uploads a stream larger than one part, byte-exactly',
254
+ { skip: MULTIPART ? false : 'set FILELAYER_TEST_S3_MULTIPART=1 (uploads ~11 MB)' },
255
+ async () => {
256
+ const k = key('multipart.bin');
257
+ const total = 5 * 1024 * 1024 * 2 + 1234;
258
+ const src = new Uint8Array(total);
259
+ let x = 0x9e3779b9;
260
+ for (let i = 0; i < total; i++) {
261
+ x = (x * 1664525 + 1013904223) >>> 0;
262
+ src[i] = x & 0xff;
263
+ }
264
+ const r = await store.put(k, bytesToStream(src), 'application/octet-stream');
265
+ assert.equal(r.bytes, total);
266
+ assert.equal((await store.head(k))!.size, total);
267
+ const back = await collectStream((await store.stream(k))!.body);
268
+ assert.deepEqual(Buffer.from(back), Buffer.from(src));
269
+ },
270
+ );
271
+ });
272
+
273
+ describe('Filelayer end to end against LIVE object storage', { skip }, () => {
274
+ it('uploads, authorizes, delivers, redirects and deletes', async () => {
275
+ const storage = makeStorage();
276
+ const { db } = await createTestDb();
277
+ const fl = new Filelayer(db, storage, {
278
+ baseUrl: 'https://files.test',
279
+ redirectDelivery: {
280
+ acknowledgeRevocationWindow: REDIRECT_ACKNOWLEDGEMENT,
281
+ ttlSeconds: 60,
282
+ },
283
+ });
284
+ const alice = (await fl.createActor('live-alice')).id;
285
+ const org = (await fl.createOrg('live-org', 'Live', { ownerActorId: alice })).id;
286
+
287
+ const file = await fl.upload({ actorId: alice }, org, {
288
+ name: 'live.txt',
289
+ contentType: 'text/plain',
290
+ body: enc('end to end'),
291
+ });
292
+ // The whole point of defect #1: the row must name the store the bytes are
293
+ // actually in.
294
+ assert.equal(file.storageProvider, storage.provider);
295
+ const { rows } = await db.query<{ storage_provider: string }>(
296
+ `SELECT storage_provider FROM file WHERE id = $1`,
297
+ [file.id],
298
+ );
299
+ assert.equal(rows[0]!.storage_provider, storage.provider);
300
+
301
+ assert.equal(dec((await fl.read({ actorId: alice }, file.id)).body), 'end to end');
302
+
303
+ await fl.share({ actorId: alice }, file.id, { subject: { type: 'anonymous' } });
304
+ const d = await fl.readStream({ actorId: null }, file.id, { mode: 'auto' });
305
+ assert.equal(d.mode, 'redirect', 'an anonymous grant is eligible for redirect delivery');
306
+ if (d.mode === 'redirect') {
307
+ const res = await fetch(d.url);
308
+ assert.equal(res.status, 200);
309
+ assert.equal(await res.text(), 'end to end');
310
+ }
311
+
312
+ await fl.delete({ actorId: alice }, file.id);
313
+ assert.equal(await storage.get(file.storageKey), null, 'the bytes are really gone');
314
+ assert.equal((await fl.store.verifyAuditChain(org)).valid, true);
315
+ });
316
+ });
317
+
318
+ if (!enabled) {
319
+ // One line, so a CI log makes it obvious the suite did not run and why. A
320
+ // silent skip is how a suite stays skipped for a year.
321
+ console.error(`[s3-live] SKIPPED -- ${skip}`);
322
+ }