@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,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
|
+
});
|