@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.
- package/CHANGELOG.md +338 -0
- package/LICENSE +202 -0
- package/MIGRATIONS.md +328 -0
- package/NOTICE +37 -0
- package/README.md +343 -0
- package/SEMANTICS.md +729 -0
- package/dist/authz.d.ts +524 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +889 -0
- package/dist/authz.js.map +1 -0
- package/dist/db.d.ts +145 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +217 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.d.ts +293 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +519 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/filelayer.d.ts +542 -0
- package/dist/filelayer.d.ts.map +1 -0
- package/dist/filelayer.js +1360 -0
- package/dist/filelayer.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/simple.d.ts +297 -0
- package/dist/simple.d.ts.map +1 -0
- package/dist/simple.js +492 -0
- package/dist/simple.js.map +1 -0
- package/dist/storage.d.ts +269 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +700 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +432 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +862 -0
- package/dist/store.js.map +1 -0
- package/package.json +77 -0
- package/schema.sql +1190 -0
- package/src/authz.ts +1398 -0
- package/src/db.ts +271 -0
- package/src/delivery.ts +737 -0
- package/src/errors.ts +24 -0
- package/src/filelayer.ts +1836 -0
- package/src/index.ts +7 -0
- package/src/simple.ts +666 -0
- package/src/storage.ts +917 -0
- package/src/store.ts +1072 -0
- package/test/delivery.test.ts +0 -0
- package/test/group-subjects.test.ts +1072 -0
- package/test/helpers.ts +65 -0
- package/test/listing.test.ts +689 -0
- package/test/local-s3.d.mts +33 -0
- package/test/local-s3.mjs +400 -0
- package/test/persistence.test.ts +953 -0
- package/test/regression.test.ts +619 -0
- package/test/s3-live.test.ts +322 -0
- package/test/security.test.ts +1652 -0
- package/test/semantics.test.ts +888 -0
- package/test/storage.test.ts +437 -0
- package/test/tiers.test.ts +432 -0
- package/test/vault-example.test.ts +302 -0
- package/tsconfig.build.json +29 -0
- 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
|
+
}
|