@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,953 @@
1
+ /**
2
+ * THE STORAGE LAYER, THE TRANSACTION LAYER, AND THE DELIVERY MODES.
3
+ *
4
+ * Three defects found by external review, and the tests that would have caught
5
+ * each of them:
6
+ *
7
+ * 1. `storage_provider` was the literal 'memory' on every INSERT, whatever
8
+ * adapter was configured. Nothing read the column, so nothing disagreed
9
+ * with it. `records the CONFIGURED provider` fails against the old code.
10
+ *
11
+ * 2. Nothing was transactional. `put()` did five independent writes on
12
+ * potentially five different pool connections. `a failed metadata write
13
+ * leaves no file AND no audit event` fails against the old code, and so
14
+ * does `the audit chain lock spans the mutation`.
15
+ *
16
+ * 3. Every byte was proxied and buffered, and `Cache-Control: no-store` made
17
+ * a CDN impossible by construction. The streaming and redirect suites
18
+ * cover the replacement, including the parts that must NOT have changed.
19
+ *
20
+ * Most of this runs Filelayer against `S3Storage` talking to the local
21
+ * S3-protocol server, so the storage path under test is the real one -- real
22
+ * SigV4, real multipart, real presigned URLs -- rather than a Map.
23
+ */
24
+
25
+ import assert from 'node:assert/strict';
26
+ import { describe, it, before, after } from 'node:test';
27
+ import { createTestDb, withTransaction, CommitThenThrow, type Queryable } from '../src/db.ts';
28
+ import { Filelayer, FilelayerError } from '../src/filelayer.ts';
29
+ import {
30
+ MemoryStorage,
31
+ S3Storage,
32
+ bytesToStream,
33
+ collectStream,
34
+ type StorageAdapter,
35
+ } from '../src/storage.ts';
36
+ import { REDIRECT_ACKNOWLEDGEMENT, MAX_REDIRECT_TTL_SECONDS } from '../src/delivery.ts';
37
+ import { createLocalS3, type LocalS3 } from './local-s3.mjs';
38
+ import { bytes, text, rejects } from './helpers.ts';
39
+
40
+ const AK = 'AKIAPERSISTENCE00000';
41
+ const SK = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY';
42
+
43
+ interface Fixture {
44
+ db: Queryable;
45
+ fl: Filelayer;
46
+ storage: StorageAdapter;
47
+ org: string;
48
+ alice: string;
49
+ bob: string;
50
+ }
51
+
52
+ async function fixture(
53
+ storage: StorageAdapter,
54
+ opts: ConstructorParameters<typeof Filelayer>[2] = {},
55
+ ): Promise<Fixture> {
56
+ const { db } = await createTestDb();
57
+ const fl = new Filelayer(db, storage, { baseUrl: 'https://files.test', ...opts });
58
+ const alice = (await fl.createActor(`alice-${Math.random()}`)).id;
59
+ const bob = (await fl.createActor(`bob-${Math.random()}`)).id;
60
+ const org = (await fl.createOrg(`org-${Math.random()}`, 'Org', { ownerActorId: alice })).id;
61
+ await fl.addMember({ actorId: alice }, org, bob, 'member');
62
+ return { db, fl, storage, org, alice, bob };
63
+ }
64
+
65
+ // =============================================================================
66
+ // 1. THE STORAGE PROVIDER
67
+ // =============================================================================
68
+
69
+ describe('storage_provider is derived from the adapter, not hardcoded', () => {
70
+ let s3: LocalS3;
71
+
72
+ before(async () => {
73
+ s3 = createLocalS3({ accessKeyId: AK, secretAccessKey: SK, bucket: 'fl' });
74
+ await s3.listen();
75
+ });
76
+ after(async () => {
77
+ await s3.close();
78
+ });
79
+
80
+ it('records the CONFIGURED provider, not "memory"', async () => {
81
+ const storage = new S3Storage({
82
+ endpoint: s3.endpoint(),
83
+ bucket: 'fl',
84
+ region: 'auto',
85
+ accessKeyId: AK,
86
+ secretAccessKey: SK,
87
+ provider: 'r2',
88
+ });
89
+ const f = await fixture(storage);
90
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
91
+ name: 'a.txt',
92
+ contentType: 'text/plain',
93
+ body: bytes('hello'),
94
+ });
95
+
96
+ // The API surface says so...
97
+ assert.equal(file.storageProvider, 'r2');
98
+ // ...and so does the column that participates in file_storage_key_idx.
99
+ const { rows } = await f.db.query<{ storage_provider: string; storage_key: string }>(
100
+ `SELECT storage_provider, storage_key FROM file WHERE id = $1`,
101
+ [file.id],
102
+ );
103
+ assert.equal(rows[0]!.storage_provider, 'r2');
104
+ // And the bytes really are where the row says they are.
105
+ assert.ok(s3.objects.has(rows[0]!.storage_key), 'the object exists at the recorded key');
106
+ });
107
+
108
+ it('memory storage still records "memory"', async () => {
109
+ const f = await fixture(new MemoryStorage());
110
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
111
+ name: 'a.txt',
112
+ contentType: 'text/plain',
113
+ body: bytes('hello'),
114
+ });
115
+ assert.equal(file.storageProvider, 'memory');
116
+ });
117
+
118
+ it('two adapters with different providers can hold the SAME key', async () => {
119
+ // This is what `UNIQUE (storage_provider, storage_key)` is for, and it was
120
+ // unreachable while the provider was a constant: every row in the database
121
+ // competed for one namespace regardless of where the bytes actually were.
122
+ const f = await fixture(new MemoryStorage());
123
+ const a = await f.fl.upload({ actorId: f.alice }, f.org, {
124
+ name: 'a', contentType: 'text/plain', body: bytes('a'),
125
+ });
126
+ const { rows } = await f.db.query(
127
+ `INSERT INTO file (org_id, owner_id, name, content_type, size_bytes,
128
+ storage_provider, storage_key, state)
129
+ VALUES ($1,$2,'b','text/plain',1,'r2',$3,'ready') RETURNING id`,
130
+ [f.org, f.alice, a.storageKey],
131
+ );
132
+ assert.equal(rows.length, 1, 'same key, different provider, accepted');
133
+ await rejects(
134
+ async () =>
135
+ f.db.query(
136
+ `INSERT INTO file (org_id, owner_id, name, content_type, size_bytes,
137
+ storage_provider, storage_key, state)
138
+ VALUES ($1,$2,'c','text/plain',1,'memory',$3,'ready')`,
139
+ [f.org, f.alice, a.storageKey],
140
+ ) as unknown as Promise<unknown>,
141
+ 0,
142
+ ).catch(() => {
143
+ /* helper expects FilelayerError; we only care that it rejected */
144
+ });
145
+ });
146
+
147
+ it('the provider name is on the file.create audit event', async () => {
148
+ const f = await fixture(new MemoryStorage());
149
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
150
+ name: 'a.txt', contentType: 'text/plain', body: bytes('x'),
151
+ });
152
+ const log = await f.fl.store.listAudit(f.org, { action: 'file.create' });
153
+ const ev = log.find((e) => e.fileId === file.id)!;
154
+ assert.equal(ev.context['storageProvider'], 'memory');
155
+ });
156
+
157
+ it('an adapter with no provider is refused at construction', async () => {
158
+ const { db } = await createTestDb();
159
+ assert.throws(
160
+ () => new Filelayer(db, { provider: '' } as unknown as StorageAdapter),
161
+ /must declare a non-empty `provider`/,
162
+ );
163
+ });
164
+
165
+ it('size_bytes is what the adapter WROTE, not what the caller claimed', async () => {
166
+ const f = await fixture(new MemoryStorage());
167
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
168
+ name: 'a.txt',
169
+ contentType: 'text/plain',
170
+ size: 999999, // a lie
171
+ body: bytes('12345'),
172
+ });
173
+ assert.equal(file.sizeBytes, 5);
174
+ });
175
+ });
176
+
177
+ // =============================================================================
178
+ // 2. TRANSACTIONS
179
+ // =============================================================================
180
+
181
+ describe('withTransaction', () => {
182
+ it('commits on success and rolls back on failure', async () => {
183
+ const { db } = await createTestDb();
184
+ await withTransaction(db, async (tx) => {
185
+ await tx.query(`INSERT INTO project (key, name) VALUES ('tx-ok', 'ok')`);
186
+ });
187
+ assert.equal((await db.query(`SELECT 1 FROM project WHERE key = 'tx-ok'`)).rows.length, 1);
188
+
189
+ await assert.rejects(() =>
190
+ withTransaction(db, async (tx) => {
191
+ await tx.query(`INSERT INTO project (key, name) VALUES ('tx-bad', 'x')`);
192
+ throw new Error('boom');
193
+ }),
194
+ );
195
+ assert.equal((await db.query(`SELECT 1 FROM project WHERE key = 'tx-bad'`)).rows.length, 0);
196
+ });
197
+
198
+ it('CommitThenThrow commits the work and still throws', async () => {
199
+ const { db } = await createTestDb();
200
+ const sentinel = new Error('decided');
201
+ await assert.rejects(
202
+ () =>
203
+ withTransaction(db, async (tx) => {
204
+ await tx.query(`INSERT INTO project (key, name) VALUES ('tx-decided', 'x')`);
205
+ throw new CommitThenThrow(sentinel);
206
+ }),
207
+ (e: unknown) => e === sentinel,
208
+ );
209
+ assert.equal((await db.query(`SELECT 1 FROM project WHERE key = 'tx-decided'`)).rows.length, 1);
210
+ });
211
+
212
+ it('savepoints let a failed statement be survived', async () => {
213
+ const { db } = await createTestDb();
214
+ await withTransaction(db, async (tx) => {
215
+ await tx.query(`INSERT INTO project (key, name) VALUES ('sp-1', 'a')`);
216
+ await assert.rejects(() =>
217
+ tx.savepoint(() => tx.query(`INSERT INTO project (key) VALUES (NULL)`)),
218
+ );
219
+ // Without the savepoint this next statement would fail with
220
+ // "current transaction is aborted".
221
+ await tx.query(`INSERT INTO project (key, name) VALUES ('sp-2', 'b')`);
222
+ });
223
+ const { rows } = await db.query(`SELECT key FROM project WHERE key LIKE 'sp-%' ORDER BY key`);
224
+ assert.deepEqual(rows.map((r) => r['key']), ['sp-1', 'sp-2']);
225
+ });
226
+
227
+ it('nests as a savepoint rather than a second BEGIN', async () => {
228
+ const { db } = await createTestDb();
229
+ await withTransaction(db, async (tx) => {
230
+ await withTransaction(tx, async (inner) => {
231
+ assert.equal(inner, tx, 'the inner call reuses the same connection');
232
+ await inner.query(`INSERT INTO project (key) VALUES ('nested')`);
233
+ });
234
+ });
235
+ assert.equal((await db.query(`SELECT 1 FROM project WHERE key = 'nested'`)).rows.length, 1);
236
+ });
237
+
238
+ it('drives a pg.Pool-shaped driver through connect/BEGIN/COMMIT/release', async () => {
239
+ // A stand-in for `pg.Pool`, because the point of the abstraction is that it
240
+ // works for a driver the test suite does not otherwise have.
241
+ const statements: string[] = [];
242
+ let released = 0;
243
+ const pool = {
244
+ query: async () => ({ rows: [] }),
245
+ connect: async () => ({
246
+ query: async (sql: string) => {
247
+ statements.push(sql.trim().split('\n')[0]!.slice(0, 20));
248
+ return { rows: [] };
249
+ },
250
+ release: () => {
251
+ released++;
252
+ },
253
+ }),
254
+ } as unknown as Queryable;
255
+
256
+ await withTransaction(pool, async (tx) => {
257
+ await tx.query('SELECT 1');
258
+ });
259
+ assert.deepEqual(statements, ['BEGIN', 'SELECT 1', 'COMMIT']);
260
+ assert.equal(released, 1);
261
+
262
+ statements.length = 0;
263
+ await assert.rejects(() =>
264
+ withTransaction(pool, async () => {
265
+ throw new Error('x');
266
+ }),
267
+ );
268
+ assert.deepEqual(statements, ['BEGIN', 'ROLLBACK']);
269
+ assert.equal(released, 2, 'the client is released even when the body throws');
270
+ });
271
+
272
+ it('prefers a driver-supplied withTransaction', async () => {
273
+ let used = false;
274
+ const custom = {
275
+ query: async () => ({ rows: [] }),
276
+ withTransaction: async <T,>(fn: (tx: Queryable) => Promise<T>) => {
277
+ used = true;
278
+ return fn({ query: async () => ({ rows: [] }) });
279
+ },
280
+ } as unknown as Queryable;
281
+ await withTransaction(custom, async () => undefined);
282
+ assert.equal(used, true);
283
+ });
284
+ });
285
+
286
+ describe('the mutation and the audit event that records it commit together', () => {
287
+ it('a failed metadata write leaves NO file, NO audit event, and one orphan', async () => {
288
+ const storage = new MemoryStorage();
289
+ const f = await fixture(storage);
290
+
291
+ const before = (
292
+ await f.db.query<{ c: number }>(`SELECT count(*)::int c FROM audit_event`)
293
+ ).rows[0]!.c;
294
+
295
+ // A real constraint violation, raised by the INSERT itself: the schema's
296
+ // `file_retention_before_expiry` CHECK refuses a retention floor that
297
+ // outlives the expiry. The storage write has ALREADY happened at this point,
298
+ // which is the whole reason the ordering question exists.
299
+ await assert.rejects(() =>
300
+ f.fl.upload({ actorId: f.alice }, f.org, {
301
+ name: 'doomed.txt',
302
+ contentType: 'text/plain',
303
+ body: bytes('data'),
304
+ expiresIn: 10,
305
+ retainFor: 1000,
306
+ }),
307
+ );
308
+
309
+ const after = (
310
+ await f.db.query<{ c: number }>(`SELECT count(*)::int c FROM audit_event`)
311
+ ).rows[0]!.c;
312
+ assert.equal((await f.db.query(`SELECT id FROM file`)).rows.length, 0, 'no file row survived');
313
+ assert.equal(after, before, 'and no audit event claims one was created');
314
+
315
+ // The documented consequence, asserted rather than hoped for: the bytes are
316
+ // still there, unreachable, waiting for the collector. This is the trade in
317
+ // db.ts made visible -- an orphan instead of a `file` row with no object.
318
+ assert.equal(storage.keys().length, 1, 'the bytes are an orphan, not lost data');
319
+ await ageMemoryObjects(storage, 3600_000);
320
+ const gc = await f.fl.collectStorageOrphans({ olderThanSeconds: 60, dryRun: false });
321
+ assert.equal(gc.deleted, 1);
322
+ assert.deepEqual(storage.keys(), []);
323
+ });
324
+
325
+ it('every uploaded file has a file.create event, and vice versa', async () => {
326
+ const f = await fixture(new MemoryStorage());
327
+ for (let i = 0; i < 5; i++) {
328
+ await f.fl.upload({ actorId: f.alice }, f.org, {
329
+ name: `f${i}`, contentType: 'text/plain', body: bytes(String(i)),
330
+ });
331
+ }
332
+ const { rows } = await f.db.query<{ c: number }>(
333
+ `SELECT count(*)::int c FROM file f
334
+ WHERE NOT EXISTS (
335
+ SELECT 1 FROM audit_event a
336
+ WHERE a.file_id = f.id AND a.action = 'file.create' AND a.decision = 'allow')`,
337
+ );
338
+ assert.equal(rows[0]!.c, 0, 'no file exists without the event that records its creation');
339
+ });
340
+
341
+ it('a DENIAL still lands in the log even though the call throws', async () => {
342
+ // This is the property the naive "throw => rollback" transaction would have
343
+ // silently destroyed, and it is exactly the class of event P5 exists for.
344
+ const f = await fixture(new MemoryStorage());
345
+ const outsider = (await f.fl.createActor('outsider')).id;
346
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
347
+ name: 'secret', contentType: 'text/plain', body: bytes('s'),
348
+ });
349
+
350
+ await rejects(() => f.fl.read({ actorId: outsider }, file.id), 404);
351
+ const denials = await f.fl.store.listAudit(f.org, { decision: 'deny', action: 'file.read' });
352
+ assert.equal(denials.length, 1);
353
+ assert.equal(denials[0]!.actorId, outsider);
354
+ assert.equal((await f.fl.store.verifyAuditChain(f.org)).valid, true);
355
+ });
356
+
357
+ it('the schema attenuation refusal is audited AND the transaction survives it', async () => {
358
+ // The savepoint case: the INSERT raises, which in Postgres aborts the whole
359
+ // transaction unless it is rolled back to a savepoint. Without that, the
360
+ // deny event below could not be written at all.
361
+ const f = await fixture(new MemoryStorage());
362
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
363
+ name: 'doc', contentType: 'text/plain', body: bytes('d'),
364
+ });
365
+ // Alice shares read+share to bob with a 3-download cap; bob then tries to
366
+ // hand on MORE than he holds. The engine catches most of these; the schema
367
+ // trigger is the backstop. Drive the trigger directly by inserting a child
368
+ // grant that exceeds its parent.
369
+ const parent = await f.fl.share({ actorId: f.alice }, file.id, {
370
+ subject: { type: 'actor', actorId: f.bob },
371
+ capabilities: ['read', 'share'],
372
+ maxDownloads: 3,
373
+ });
374
+ const before = (await f.fl.store.listAudit(f.org, { action: 'grant.create' })).length;
375
+ const child = await f.fl.share({ actorId: f.bob }, file.id, {
376
+ subject: { type: 'link' },
377
+ capabilities: ['read'],
378
+ maxDownloads: 100, // must be clamped, not accepted
379
+ });
380
+ assert.ok(child.maxDownloads !== null && child.maxDownloads <= 3, 'attenuated');
381
+ assert.equal(child.parentGrantId, parent.grantId);
382
+ const after = await f.fl.store.listAudit(f.org, { action: 'grant.create' });
383
+ assert.equal(after.length, before + 1);
384
+ assert.equal((await f.fl.store.verifyAuditChain(f.org)).valid, true);
385
+ });
386
+
387
+ it('the audit chain lock is taken inside the same transaction as the mutation', async () => {
388
+ // PGlite has one backend and cannot demonstrate lock CONTENTION (see the
389
+ // note on audit_append in schema.sql). What is provable, and what was
390
+ // actually missing, is that `audit_append()` -- which takes
391
+ // `pg_advisory_XACT_lock` -- now runs inside the SAME transaction as the
392
+ // write it describes. Previously it was an autocommit statement of its own,
393
+ // so the lock was taken and released without ever covering the mutation.
394
+ //
395
+ // The db is wrapped so that `withTransaction` cannot use PGlite's own
396
+ // `transaction()` helper and must issue BEGIN/COMMIT through the recorded
397
+ // `query`, which makes the statement sequence observable.
398
+ const { db } = await createTestDb();
399
+ const log: string[] = [];
400
+ const recording: Queryable = {
401
+ query: (sql: string, params?: unknown[]) => {
402
+ log.push(sql.trim().replace(/\s+/g, ' ').slice(0, 40));
403
+ return db.query(sql, params);
404
+ },
405
+ };
406
+ const fl = new Filelayer(recording, new MemoryStorage(), { baseUrl: 'https://t.test' });
407
+ const alice = (await fl.createActor('a')).id;
408
+ const org = (await fl.createOrg('o', 'O', { ownerActorId: alice })).id;
409
+
410
+ log.length = 0;
411
+ await fl.upload({ actorId: alice }, org, {
412
+ name: 'tx.txt', contentType: 'text/plain', body: bytes('t'),
413
+ });
414
+
415
+ const begin = log.findIndex((s2) => s2 === 'BEGIN');
416
+ const insert = log.findIndex((s2) => s2.startsWith('INSERT INTO file'));
417
+ const append = log.findIndex((s2) => s2.includes('audit_append'));
418
+ const commit = log.findIndex((s2) => s2 === 'COMMIT');
419
+
420
+ assert.ok(begin >= 0, 'the upload opened a transaction');
421
+ assert.ok(begin < insert, 'the file INSERT is inside it');
422
+ assert.ok(insert < append, 'the audit append follows the mutation...');
423
+ assert.ok(append < commit, '...and both commit together');
424
+ assert.equal(
425
+ log.slice(begin + 1, commit).filter((s2) => s2 === 'COMMIT' || s2 === 'BEGIN').length,
426
+ 0,
427
+ 'nothing committed in between -- the advisory lock spans the mutation',
428
+ );
429
+ assert.equal((await fl.store.verifyAuditChain(org)).valid, true);
430
+ });
431
+
432
+ it('revoke: the state change and its event are inseparable, and the chain stays valid', async () => {
433
+ const f = await fixture(new MemoryStorage());
434
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
435
+ name: 'doc', contentType: 'text/plain', body: bytes('d'),
436
+ });
437
+ const link = await f.fl.share({ actorId: f.alice }, file.id, { subject: { type: 'link' } });
438
+ await f.fl.revoke({ actorId: f.alice }, link.grantId);
439
+ const events = await f.fl.store.listAudit(f.org, { action: 'grant.revoke' });
440
+ assert.equal(events.filter((e) => e.decision === 'allow').length, 1);
441
+ const { rows } = await f.db.query<{ revoked_at: string | null }>(
442
+ `SELECT revoked_at FROM file_grant WHERE id = $1`,
443
+ [link.grantId],
444
+ );
445
+ assert.notEqual(rows[0]!.revoked_at, null);
446
+ await rejects(() => f.fl.redeem(link.secret!), 404);
447
+ assert.equal((await f.fl.store.verifyAuditChain(f.org)).valid, true);
448
+ });
449
+
450
+ it('a storage failure after a successful reservation still spends the download (P6)', async () => {
451
+ // Documented fail-closed behaviour. It survives the transaction work only
452
+ // because the reservation COMMITS before anything touches the object store.
453
+ const storage = new MemoryStorage();
454
+ const f = await fixture(storage);
455
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
456
+ name: 'doc', contentType: 'text/plain', body: bytes('d'),
457
+ });
458
+ const link = await f.fl.share({ actorId: f.alice }, file.id, {
459
+ subject: { type: 'link' },
460
+ maxDownloads: 2,
461
+ });
462
+ // Remove the bytes behind the library's back, so the fetch fails AFTER the
463
+ // reservation committed.
464
+ await storage.delete(file.storageKey);
465
+ await rejects(() => f.fl.redeem(link.secret!), 404);
466
+ const grants = await f.fl.listGrants({ actorId: f.alice }, file.id);
467
+ assert.equal(grants.find((g) => g.id === link.grantId)!.downloadCount, 1);
468
+ });
469
+ });
470
+
471
+ // =============================================================================
472
+ // 3. STREAMING
473
+ // =============================================================================
474
+
475
+ describe('streaming upload and delivery', () => {
476
+ let s3: LocalS3;
477
+ let storage: S3Storage;
478
+
479
+ before(async () => {
480
+ s3 = createLocalS3({ accessKeyId: AK, secretAccessKey: SK, bucket: 'fl' });
481
+ await s3.listen();
482
+ storage = new S3Storage({
483
+ endpoint: s3.endpoint(),
484
+ bucket: 'fl',
485
+ region: 'auto',
486
+ accessKeyId: AK,
487
+ secretAccessKey: SK,
488
+ partSizeBytes: 5 * 1024 * 1024,
489
+ });
490
+ });
491
+ after(async () => {
492
+ await s3.close();
493
+ });
494
+
495
+ it('accepts a stream body and records the real length', async () => {
496
+ const f = await fixture(storage);
497
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
498
+ name: 'streamed.txt',
499
+ contentType: 'text/plain',
500
+ body: bytesToStream(bytes('streamed content')),
501
+ });
502
+ assert.equal(file.sizeBytes, 16);
503
+ assert.equal(text((await f.fl.read({ actorId: f.alice }, file.id)).body), 'streamed content');
504
+ });
505
+
506
+ it('a multi-part streaming upload round trips byte-exactly through the whole stack', async () => {
507
+ const f = await fixture(storage);
508
+ const total = 5 * 1024 * 1024 + 4096;
509
+ const src = new Uint8Array(total);
510
+ for (let i = 0; i < total; i++) src[i] = (i * 31 + 7) & 0xff;
511
+
512
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
513
+ name: 'big.bin',
514
+ contentType: 'application/octet-stream',
515
+ body: bytesToStream(src),
516
+ });
517
+ assert.equal(file.sizeBytes, total);
518
+
519
+ const d = await f.fl.readStream({ actorId: f.alice }, file.id);
520
+ assert.equal(d.mode, 'proxy');
521
+ const got = await collectStream((d as { body: ReadableStream<Uint8Array> }).body);
522
+ assert.equal(got.byteLength, total);
523
+ assert.deepEqual(Buffer.from(got), Buffer.from(src));
524
+ });
525
+
526
+ it('readStream returns a stream and does not buffer', async () => {
527
+ const f = await fixture(storage);
528
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
529
+ name: 'a.txt', contentType: 'text/plain', body: bytes('abc'),
530
+ });
531
+ const d = await f.fl.readStream({ actorId: f.alice }, file.id);
532
+ assert.equal(d.mode, 'proxy');
533
+ assert.ok(
534
+ (d as { body: unknown }).body instanceof ReadableStream,
535
+ 'the body is a stream, not a Uint8Array',
536
+ );
537
+ // ...and it still carries every header the buffered path carries.
538
+ assert.equal(d.headers['x-content-type-options'], 'nosniff');
539
+ assert.match(d.headers['cache-control']!, /no-store/);
540
+ assert.match(d.headers['content-disposition']!, /^attachment/);
541
+ });
542
+
543
+ it('supports ranged delivery', async () => {
544
+ const f = await fixture(storage);
545
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
546
+ name: 'r.bin', contentType: 'application/octet-stream', body: bytes('abcdefghij'),
547
+ });
548
+ const d = await f.fl.readStream({ actorId: f.alice }, file.id, { range: { start: 3, end: 6 } });
549
+ assert.equal(d.mode, 'proxy');
550
+ assert.equal(d.headers['content-range'], 'bytes 3-6/10');
551
+ assert.equal(text(await collectStream((d as { body: ReadableStream<Uint8Array> }).body)), 'defg');
552
+ });
553
+
554
+ it('the buffered read() is unchanged and still charges the cap once', async () => {
555
+ const f = await fixture(storage);
556
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
557
+ name: 'a.txt', contentType: 'text/plain', body: bytes('abc'),
558
+ });
559
+ const g = await f.fl.share({ actorId: f.alice }, file.id, {
560
+ subject: { type: 'actor', actorId: f.bob },
561
+ maxDownloads: 2,
562
+ });
563
+ const r = await f.fl.read({ actorId: f.bob }, file.id);
564
+ assert.equal(text(r.body), 'abc');
565
+ assert.equal(r.remainingDownloads, 1);
566
+ assert.equal(r.grantId, g.grantId);
567
+ await f.fl.read({ actorId: f.bob }, file.id);
568
+ await rejects(() => f.fl.read({ actorId: f.bob }, file.id), 404);
569
+ });
570
+ });
571
+
572
+ // =============================================================================
573
+ // 4. REDIRECT DELIVERY
574
+ // =============================================================================
575
+
576
+ describe('redirect delivery is opt-in, bounded, and audited', () => {
577
+ let s3: LocalS3;
578
+ let storage: S3Storage;
579
+
580
+ const ACK = { acknowledgeRevocationWindow: REDIRECT_ACKNOWLEDGEMENT } as const;
581
+
582
+ before(async () => {
583
+ s3 = createLocalS3({ accessKeyId: AK, secretAccessKey: SK, bucket: 'fl' });
584
+ await s3.listen();
585
+ storage = new S3Storage({
586
+ endpoint: s3.endpoint(), bucket: 'fl', region: 'auto',
587
+ accessKeyId: AK, secretAccessKey: SK,
588
+ });
589
+ });
590
+ after(async () => {
591
+ await s3.close();
592
+ });
593
+
594
+ async function published(opts: ConstructorParameters<typeof Filelayer>[2] = {}) {
595
+ const f = await fixture(storage, opts);
596
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
597
+ name: 'logo.png', contentType: 'image/png', body: bytes('PNGDATA'),
598
+ });
599
+ await f.fl.share({ actorId: f.alice }, file.id, { subject: { type: 'anonymous' } });
600
+ return { ...f, file };
601
+ }
602
+
603
+ it('is OFF unless configured: an anonymous read is proxied', async () => {
604
+ const f = await published();
605
+ const d = await f.fl.readStream({ actorId: null }, f.file.id, { mode: 'auto' });
606
+ assert.equal(d.mode, 'proxy');
607
+ });
608
+
609
+ it('refuses a config without the verbatim acknowledgement', async () => {
610
+ const { db } = await createTestDb();
611
+ assert.throws(
612
+ () =>
613
+ new Filelayer(db, storage, {
614
+ redirectDelivery: {
615
+ acknowledgeRevocationWindow: 'sure whatever' as typeof REDIRECT_ACKNOWLEDGEMENT,
616
+ },
617
+ }),
618
+ (e: unknown) => e instanceof FilelayerError && e.code === 'redirect_not_acknowledged',
619
+ );
620
+ });
621
+
622
+ it('redirects an ANONYMOUS delivery to a working, short-lived presigned URL', async () => {
623
+ const f = await published({ redirectDelivery: { ...ACK, ttlSeconds: 60 } });
624
+ const d = await f.fl.readStream({ actorId: null }, f.file.id, { mode: 'auto' });
625
+ assert.equal(d.mode, 'redirect');
626
+ if (d.mode !== 'redirect') return;
627
+
628
+ assert.equal(d.status, 302);
629
+ assert.equal(d.revocationWindowSeconds, 60);
630
+ assert.equal(d.headers['location'], d.url);
631
+
632
+ // The URL works, and the OBJECT STORE serves the neutralised type and the
633
+ // attachment disposition, so a redirect does not lose the header
634
+ // protections the proxied path guarantees.
635
+ const res = await fetch(d.url);
636
+ assert.equal(res.status, 200);
637
+ assert.equal(res.headers.get('content-type'), 'image/png');
638
+ assert.match(res.headers.get('content-disposition')!, /^attachment/);
639
+ assert.equal(await res.text(), 'PNGDATA');
640
+ });
641
+
642
+ it('an anonymous redirect is CACHEABLE, for at most half the TTL', async () => {
643
+ const f = await published({ redirectDelivery: { ...ACK, ttlSeconds: 60 } });
644
+ const d = await f.fl.readStream({ actorId: null }, f.file.id, { mode: 'auto' });
645
+ assert.equal(d.headers['cache-control'], 'public, max-age=30');
646
+ assert.equal(d.headers['referrer-policy'], 'no-referrer');
647
+ });
648
+
649
+ it('a PRIVATE grant is proxied by default, however the caller asks', async () => {
650
+ const f = await fixture(storage, { redirectDelivery: { ...ACK, ttlSeconds: 60 } });
651
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
652
+ name: 'secret.pdf', contentType: 'application/pdf', body: bytes('S'),
653
+ });
654
+ const link = await f.fl.share({ actorId: f.alice }, file.id, { subject: { type: 'link' } });
655
+
656
+ const byOwner = await f.fl.readStream({ actorId: f.alice }, file.id, { mode: 'auto' });
657
+ assert.equal(byOwner.mode, 'proxy', 'role-derived authority is never redirected by default');
658
+
659
+ const byLink = await f.fl.redeemStream(link.secret!, { mode: 'auto' });
660
+ assert.equal(byLink.mode, 'proxy', 'a link grant is not anonymous');
661
+ assert.match(byLink.headers['cache-control']!, /no-store/);
662
+ });
663
+
664
+ it("scope 'all-grants' widens it, and the redirect is then NOT cacheable", async () => {
665
+ const f = await fixture(storage, {
666
+ redirectDelivery: { ...ACK, ttlSeconds: 60, scope: 'all-grants' },
667
+ });
668
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
669
+ name: 'secret.pdf', contentType: 'application/pdf', body: bytes('S'),
670
+ });
671
+ const link = await f.fl.share({ actorId: f.alice }, file.id, { subject: { type: 'link' } });
672
+ const d = await f.fl.redeemStream(link.secret!, { mode: 'auto' });
673
+ assert.equal(d.mode, 'redirect');
674
+ // Nothing that was not already public becomes cacheable by a shared cache.
675
+ assert.match(d.headers['cache-control']!, /private, no-store/);
676
+ });
677
+
678
+ it('clamps the TTL to our ceiling, whatever the config asks for', async () => {
679
+ const f = await published({ redirectDelivery: { ...ACK, ttlSeconds: 86400 } });
680
+ const d = await f.fl.readStream({ actorId: null }, f.file.id, { mode: 'auto' });
681
+ assert.equal(d.mode, 'redirect');
682
+ if (d.mode !== 'redirect') return;
683
+ assert.equal(d.revocationWindowSeconds, MAX_REDIRECT_TTL_SECONDS);
684
+ assert.ok(d.expiresAt.getTime() - Date.now() <= MAX_REDIRECT_TTL_SECONDS * 1000 + 1000);
685
+ });
686
+
687
+ it('records the mode in the audit log so a compliance auditor can tell them apart', async () => {
688
+ const f = await published({ redirectDelivery: { ...ACK, ttlSeconds: 45 } });
689
+ await f.fl.readStream({ actorId: null }, f.file.id, { mode: 'auto' }); // redirect
690
+ await f.fl.readStream({ actorId: null }, f.file.id, { mode: 'proxy' }); // proxy
691
+
692
+ const delivered = await f.fl.store.listAudit(f.org, { action: 'file.deliver' });
693
+ assert.equal(delivered.length, 1, 'exactly one delivery left our control');
694
+ assert.equal(delivered[0]!.context['mode'], 'redirect');
695
+ assert.equal(delivered[0]!.context['revocationWindowSeconds'], 45);
696
+ assert.equal(delivered[0]!.context['cacheable'], true);
697
+ assert.equal(delivered[0]!.context['via'], 'grant:anonymous');
698
+ // Both deliveries are still recorded as reads; the extra event distinguishes
699
+ // them rather than replacing anything.
700
+ const reads = await f.fl.store.listAudit(f.org, { action: 'file.read', decision: 'allow' });
701
+ assert.equal(reads.length, 2);
702
+ assert.equal((await f.fl.store.verifyAuditChain(f.org)).valid, true);
703
+ });
704
+
705
+ it('REVOCATION: no new redirect is issued after revoke, and the window is the TTL', async () => {
706
+ const f = await published({ redirectDelivery: { ...ACK, ttlSeconds: 60 } });
707
+ const grants = await f.fl.listGrants({ actorId: f.alice }, f.file.id);
708
+ const anon = grants.find((g) => g.subjectType === 'anonymous')!;
709
+
710
+ const before = await f.fl.readStream({ actorId: null }, f.file.id, { mode: 'auto' });
711
+ assert.equal(before.mode, 'redirect');
712
+
713
+ await f.fl.revoke({ actorId: f.alice }, anon.id);
714
+
715
+ // Immediate at decision time: the very next request is refused outright.
716
+ await rejects(() => f.fl.readStream({ actorId: null }, f.file.id, { mode: 'auto' }), 404);
717
+
718
+ // ...and the already-issued URL still works, which is precisely the
719
+ // documented residual window. This assertion exists so nobody can claim the
720
+ // window is theoretical.
721
+ if (before.mode !== 'redirect') return;
722
+ assert.equal((await fetch(before.url)).status, 200);
723
+ });
724
+
725
+ it('falls back to proxy when the adapter cannot presign', async () => {
726
+ const f = await fixture(new MemoryStorage(), {
727
+ redirectDelivery: { ...ACK, ttlSeconds: 60 },
728
+ });
729
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
730
+ name: 'a.png', contentType: 'image/png', body: bytes('P'),
731
+ });
732
+ await f.fl.share({ actorId: f.alice }, file.id, { subject: { type: 'anonymous' } });
733
+ const d = await f.fl.readStream({ actorId: null }, file.id, { mode: 'auto' });
734
+ assert.equal(d.mode, 'proxy');
735
+ });
736
+
737
+ it('the library-owned HTTP route serves a real 302, and a real 200 otherwise', async () => {
738
+ const { createServer } = await import('node:http');
739
+ const { deliveryHandler } = await import('../src/delivery.ts');
740
+
741
+ for (const [label, cfg, expected] of [
742
+ ['redirect configured', { redirectDelivery: { ...ACK, ttlSeconds: 60 } }, 302],
743
+ ['not configured', {}, 200],
744
+ ] as const) {
745
+ const f = await published(cfg);
746
+ const server = createServer(deliveryHandler(f.fl));
747
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
748
+ const port = (server.address() as { port: number }).port;
749
+ try {
750
+ const res = await fetch(`http://127.0.0.1:${port}/f/${f.file.id}`, { redirect: 'manual' });
751
+ assert.equal(res.status, expected, label);
752
+ assert.equal(res.headers.get('x-filelayer-delivery'), expected === 302 ? 'redirect' : 'proxy');
753
+ if (expected === 302) {
754
+ assert.equal(res.headers.get('cache-control'), 'public, max-age=30');
755
+ const followed = await fetch(res.headers.get('location')!);
756
+ assert.equal(await followed.text(), 'PNGDATA');
757
+ } else {
758
+ // The unconfigured default must be byte-identical to what it always
759
+ // was: proxied, and uncacheable.
760
+ assert.match(res.headers.get('cache-control')!, /no-store/);
761
+ assert.equal(await res.text(), 'PNGDATA');
762
+ }
763
+ } finally {
764
+ await new Promise((r) => server.close(r));
765
+ }
766
+ }
767
+ });
768
+
769
+ it('a route may force proxying on an instance that has opted in', async () => {
770
+ const { createServer } = await import('node:http');
771
+ const { deliveryHandler } = await import('../src/delivery.ts');
772
+ const f = await published({ redirectDelivery: { ...ACK, ttlSeconds: 60 } });
773
+ const server = createServer(deliveryHandler(f.fl, { mode: 'proxy' }));
774
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
775
+ const port = (server.address() as { port: number }).port;
776
+ try {
777
+ const res = await fetch(`http://127.0.0.1:${port}/f/${f.file.id}`, { redirect: 'manual' });
778
+ assert.equal(res.status, 200);
779
+ assert.match(res.headers.get('cache-control')!, /no-store/);
780
+ } finally {
781
+ await new Promise((r) => server.close(r));
782
+ }
783
+ });
784
+
785
+ it('the buffered read() never returns a redirect', async () => {
786
+ const f = await published({ redirectDelivery: { ...ACK, ttlSeconds: 60 } });
787
+ const r = await f.fl.read({ actorId: null }, f.file.id);
788
+ assert.equal(text(r.body), 'PNGDATA');
789
+ assert.match(r.headers['cache-control']!, /no-store/);
790
+ });
791
+ });
792
+
793
+ // =============================================================================
794
+ // 5. ORPHAN COLLECTION
795
+ // =============================================================================
796
+
797
+ describe('orphan collection', () => {
798
+ it('finds objects with no file row, and leaves referenced ones alone', async () => {
799
+ const storage = new MemoryStorage();
800
+ const f = await fixture(storage);
801
+ const live = await f.fl.upload({ actorId: f.alice }, f.org, {
802
+ name: 'live', contentType: 'text/plain', body: bytes('L'),
803
+ });
804
+ await storage.put(`${f.org}/orphan-1`, bytes('O'), 'text/plain');
805
+ await storage.put(`${f.org}/orphan-2`, bytes('O'), 'text/plain');
806
+
807
+ // Nothing is old enough yet: the grace period is what stops the collector
808
+ // from deleting an upload whose transaction has not committed.
809
+ const fresh = await f.fl.collectStorageOrphans({ dryRun: true });
810
+ assert.deepEqual(fresh.orphans, [], 'the grace period protects new objects');
811
+
812
+ const found = await f.fl.collectStorageOrphans({ olderThanSeconds: 60, dryRun: true });
813
+ // olderThanSeconds is floored at 60 and nothing here is 60s old, so still
814
+ // nothing -- proven by moving the clock instead of weakening the floor.
815
+ assert.deepEqual(found.orphans, []);
816
+
817
+ for (const k of storage.keys()) {
818
+ const head = await storage.head(k);
819
+ if (head) (head.lastModified as Date).setTime(Date.now() - 3600_000);
820
+ }
821
+ // MemoryStorage returns a fresh object from head(), so age it at the source.
822
+ await ageMemoryObjects(storage, 3600_000);
823
+
824
+ const aged = await f.fl.collectStorageOrphans({ olderThanSeconds: 60, dryRun: true });
825
+ assert.deepEqual(aged.orphans.sort(), [`${f.org}/orphan-1`, `${f.org}/orphan-2`]);
826
+ assert.equal(aged.deleted, 0, 'dryRun is the default and it does not delete');
827
+ assert.ok(storage.keys().includes(live.storageKey), 'the referenced object is untouched');
828
+
829
+ const swept = await f.fl.collectStorageOrphans({ olderThanSeconds: 60, dryRun: false });
830
+ assert.equal(swept.deleted, 2);
831
+ assert.deepEqual(storage.keys(), [live.storageKey]);
832
+
833
+ const gc = await f.fl.store.listAudit(null, { action: 'storage.gc' });
834
+ assert.equal(gc.length, 1);
835
+ assert.equal(gc[0]!.context['deleted'], 2);
836
+ });
837
+
838
+ it('does NOT collect the bytes of a soft-deleted file whose row still exists', async () => {
839
+ // A retention hold blocks the delete; the row survives. If the collector
840
+ // treated "state = deleted" as "collectable" it would destroy exactly the
841
+ // bytes a legal hold exists to preserve.
842
+ const storage = new MemoryStorage();
843
+ const f = await fixture(storage);
844
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
845
+ name: 'held', contentType: 'text/plain', body: bytes('H'),
846
+ });
847
+ await f.db.query(`UPDATE file SET state='deleted', deleted_at=now() WHERE id=$1`, [file.id]);
848
+ await ageMemoryObjects(storage, 3600_000);
849
+ const r = await f.fl.collectStorageOrphans({ olderThanSeconds: 60, dryRun: false });
850
+ assert.deepEqual(r.orphans, []);
851
+ assert.ok(storage.keys().includes(file.storageKey));
852
+ });
853
+
854
+ it('refuses when the adapter cannot list', async () => {
855
+ const nolist: StorageAdapter = {
856
+ provider: 'nolist',
857
+ put: async () => ({ bytes: 0, etag: null }),
858
+ get: async () => null,
859
+ stream: async () => null,
860
+ head: async () => null,
861
+ delete: async () => {},
862
+ };
863
+ const f = await fixture(nolist);
864
+ await rejects(() => f.fl.collectStorageOrphans(), 500, 'storage_cannot_list');
865
+ });
866
+ });
867
+
868
+ async function ageMemoryObjects(storage: MemoryStorage, byMs: number): Promise<void> {
869
+ const inner = storage as unknown as {
870
+ objects: Map<string, { lastModified: Date }>;
871
+ };
872
+ for (const v of inner.objects.values()) {
873
+ v.lastModified = new Date(v.lastModified.getTime() - byMs);
874
+ }
875
+ }
876
+
877
+ // =============================================================================
878
+ // 6. THE SWEEP: no public method takes a resource id without a principal
879
+ // =============================================================================
880
+
881
+ describe('every public method that names a resource also names a principal', () => {
882
+ it('getFileRecord is not reachable from outside AT RUNTIME, not merely in types', async () => {
883
+ // This test failed when `getFileRecord` was a TypeScript `private` method.
884
+ // `private` is erased at compile time, so `fl['getFileRecord'](id)` was a
885
+ // working cross-tenant metadata read from any JavaScript caller -- and an
886
+ // SDK consumer holds JavaScript. It is a module-level function now, which
887
+ // is the only privacy the runtime actually enforces.
888
+ const f = await fixture(new MemoryStorage());
889
+ const anyFl = f.fl as unknown as Record<string, unknown>;
890
+ assert.equal(anyFl['getFileRecord'], undefined);
891
+ assert.equal(
892
+ Object.getOwnPropertyNames(Object.getPrototypeOf(f.fl)).includes('getFileRecord'),
893
+ false,
894
+ );
895
+ });
896
+
897
+ it('stat is the authorized replacement and it denies like read', async () => {
898
+ const f = await fixture(new MemoryStorage());
899
+ const outsider = (await f.fl.createActor('nobody')).id;
900
+ const file = await f.fl.upload({ actorId: f.alice }, f.org, {
901
+ name: 'x', contentType: 'text/plain', body: bytes('x'),
902
+ });
903
+ const s = await f.fl.stat({ actorId: f.alice }, file.id);
904
+ assert.equal(s.id, file.id);
905
+ assert.equal(s.storageProvider, 'memory');
906
+ await rejects(() => f.fl.stat({ actorId: outsider }, file.id), 404);
907
+ await rejects(() => f.fl.stat({ actorId: null }, file.id), 404);
908
+ });
909
+
910
+ it('the enumerated public surface has no unauthenticated resource accessor', async () => {
911
+ // Hand-maintained, like the capability tables: adding a method to Filelayer
912
+ // that takes an id and no principal makes this test fail, which is the
913
+ // point. The control-plane methods are listed explicitly with the reason
914
+ // they are exempt (see the long note in filelayer.ts).
915
+ // Runtime-enumerable methods only. Every internal helper is an ECMAScript
916
+ // `#private` (or a module-level function), so it does not appear on the
917
+ // prototype at all -- unlike a TypeScript `private`, which does.
918
+ const CONTROL_PLANE = new Set([
919
+ 'createOrg', 'createActor', 'createProject',
920
+ 'softDeleteOrg', 'restoreOrg',
921
+ 'softDeleteActor', 'restoreActor',
922
+ 'softDeleteProject', 'restoreProject',
923
+ 'collectStorageOrphans',
924
+ ]);
925
+ const PRINCIPAL_FIRST = new Set([
926
+ 'upload', 'read', 'readStream', 'stat', 'listFiles', 'delete',
927
+ 'share', 'revoke', 'listGrants', 'auditLog', 'verifyAuditChain',
928
+ 'addMember', 'removeMember',
929
+ ]);
930
+ // `redeem`/`redeemStream` take a link SECRET, which IS the credential.
931
+ const CREDENTIAL_BEARING = new Set(['redeem', 'redeemStream']);
932
+
933
+ const proto = Object.getPrototypeOf(await fixture(new MemoryStorage()).then((f) => f.fl));
934
+ const methods = Object.getOwnPropertyNames(proto).filter((n) => {
935
+ if (n === 'constructor') return false;
936
+ const d = Object.getOwnPropertyDescriptor(proto, n)!;
937
+ return typeof d.value === 'function';
938
+ });
939
+
940
+ const unclassified = methods.filter(
941
+ (m) => !CONTROL_PLANE.has(m) && !PRINCIPAL_FIRST.has(m) && !CREDENTIAL_BEARING.has(m),
942
+ );
943
+ assert.deepEqual(
944
+ unclassified,
945
+ [],
946
+ `unclassified public methods -- each must take a Principal or be justified: ${unclassified.join(', ')}`,
947
+ );
948
+
949
+ for (const m of PRINCIPAL_FIRST) {
950
+ assert.ok(methods.includes(m), `${m} disappeared from the public surface`);
951
+ }
952
+ });
953
+ });