@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,302 @@
1
+ /**
2
+ * End-to-end exercise of examples/vault over real HTTP.
3
+ *
4
+ * examples/vault/server.ts is the whole Vault integration, and it is short.
5
+ * This test exists so that claim is verifiable rather than asserted: the file
6
+ * is driven end to end here, including every security-relevant path.
7
+ */
8
+
9
+ import { describe, it, before, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import type { AddressInfo } from 'node:net';
12
+ import type { Server } from 'node:http';
13
+ import { createTestDb } from '../src/db.ts';
14
+ import { MemoryStorage } from '../src/storage.ts';
15
+ import { createVaultApp } from '../../../examples/vault/server.ts';
16
+
17
+ let server: Server;
18
+ let base: string;
19
+
20
+ before(async () => {
21
+ const { db } = await createTestDb();
22
+ server = createVaultApp(db, new MemoryStorage());
23
+ await new Promise<void>((r) => server.listen(0, r));
24
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
25
+ });
26
+
27
+ after(() => server.close());
28
+
29
+ async function call(
30
+ method: string,
31
+ path: string,
32
+ opts: { actor?: string; body?: unknown } = {},
33
+ ): Promise<{ status: number; json: any; text: string; headers: Headers }> {
34
+ const res = await fetch(base + path, {
35
+ method,
36
+ headers: {
37
+ ...(opts.actor ? { 'x-actor-id': opts.actor } : {}),
38
+ ...(opts.body ? { 'content-type': 'application/json' } : {}),
39
+ },
40
+ ...(opts.body ? { body: JSON.stringify(opts.body) } : {}),
41
+ });
42
+ const text = await res.text();
43
+ let parsed: unknown = null;
44
+ try {
45
+ parsed = JSON.parse(text);
46
+ } catch {
47
+ /* binary or empty body */
48
+ }
49
+ return { status: res.status, json: parsed, text, headers: res.headers };
50
+ }
51
+
52
+ describe('examples/vault: the whole B2B document workspace scenario over HTTP', () => {
53
+ it('runs the full Vault story end to end', async () => {
54
+ // --- setup: two tenants, four people ------------------------------------
55
+ // Actors come first, because an org is created together with its first
56
+ // owner. There is no window in which an org exists with nobody in it.
57
+ const cfo = (await call('POST', '/actors', { body: { externalId: 'cfo' } })).json.id;
58
+ const staff = (await call('POST', '/actors', { body: { externalId: 'staff' } })).json.id;
59
+ const auditorRole = (await call('POST', '/actors', { body: { externalId: 'auditor' } })).json.id;
60
+ const rival = (await call('POST', '/actors', { body: { externalId: 'rival' } })).json.id;
61
+
62
+ const acme = (
63
+ await call('POST', '/orgs', {
64
+ body: { externalId: 'acme', name: 'Acme', ownerActorId: cfo },
65
+ })
66
+ ).json.id;
67
+ const initech = (
68
+ await call('POST', '/orgs', { body: { externalId: 'initech', ownerActorId: rival } })
69
+ ).json.id;
70
+
71
+ // Membership changes are authorized like everything else: the org owner
72
+ // may make them, and nobody else may.
73
+ assert.equal(
74
+ (
75
+ await call('POST', `/orgs/${acme}/members`, {
76
+ actor: cfo,
77
+ body: { actorId: staff, role: 'member' },
78
+ })
79
+ ).status,
80
+ 204,
81
+ );
82
+ assert.equal(
83
+ (
84
+ await call('POST', `/orgs/${acme}/members`, {
85
+ actor: cfo,
86
+ body: { actorId: auditorRole, role: 'viewer' },
87
+ })
88
+ ).status,
89
+ 204,
90
+ );
91
+ // A member of the org cannot promote themselves...
92
+ assert.equal(
93
+ (
94
+ await call('POST', `/orgs/${acme}/members`, {
95
+ actor: staff,
96
+ body: { actorId: staff, role: 'owner' },
97
+ })
98
+ ).status,
99
+ 404,
100
+ );
101
+ // ...and neither can a rival tenant, nor an unauthenticated caller.
102
+ assert.equal(
103
+ (
104
+ await call('POST', `/orgs/${acme}/members`, {
105
+ actor: rival,
106
+ body: { actorId: rival, role: 'owner' },
107
+ })
108
+ ).status,
109
+ 404,
110
+ );
111
+ assert.equal(
112
+ (await call('POST', `/orgs/${acme}/members`, { body: { actorId: rival, role: 'owner' } }))
113
+ .status,
114
+ 404,
115
+ );
116
+
117
+ // --- upload -------------------------------------------------------------
118
+ // No `visibility` given, so the board deck is private to its owner and the
119
+ // org's admins. This is the default.
120
+ const up = await call('POST', `/orgs/${acme}/files`, {
121
+ actor: cfo,
122
+ body: {
123
+ name: 'board-deck.pdf',
124
+ contentType: 'application/pdf',
125
+ contentBase64: Buffer.from('BOARD DECK Q3').toString('base64'),
126
+ },
127
+ });
128
+ assert.equal(up.status, 201);
129
+ const fileId = up.json.id;
130
+
131
+ // ...and a document that is genuinely for the whole workspace says so.
132
+ const handbookId = (
133
+ await call('POST', `/orgs/${acme}/files`, {
134
+ actor: cfo,
135
+ body: {
136
+ name: 'handbook.pdf',
137
+ contentType: 'application/pdf',
138
+ contentBase64: Buffer.from('EMPLOYEE HANDBOOK').toString('base64'),
139
+ visibility: 'org',
140
+ },
141
+ })
142
+ ).json.id;
143
+
144
+ // --- per-role access ----------------------------------------------------
145
+ assert.equal((await call('GET', `/files/${fileId}`, { actor: cfo })).text, 'BOARD DECK Q3');
146
+ // The default really is deny: a member of the same org gets nothing.
147
+ assert.equal((await call('GET', `/files/${fileId}`, { actor: staff })).status, 404);
148
+ assert.equal((await call('GET', `/files/${fileId}`, { actor: auditorRole })).status, 404);
149
+ assert.equal((await call('GET', `/files/${fileId}`, { actor: rival })).status, 404);
150
+ assert.equal((await call('GET', `/files/${fileId}`)).status, 404);
151
+
152
+ // The org-visible document behaves the way the old default did.
153
+ assert.equal(
154
+ (await call('GET', `/files/${handbookId}`, { actor: staff })).text,
155
+ 'EMPLOYEE HANDBOOK',
156
+ );
157
+ assert.equal(
158
+ (await call('GET', `/files/${handbookId}`, { actor: auditorRole })).text,
159
+ 'EMPLOYEE HANDBOOK',
160
+ );
161
+ assert.equal((await call('GET', `/files/${handbookId}`, { actor: rival })).status, 404);
162
+
163
+ // A viewer can read it but cannot delete or share it.
164
+ assert.equal((await call('DELETE', `/files/${handbookId}`, { actor: auditorRole })).status, 404);
165
+ assert.equal(
166
+ (await call('POST', `/files/${handbookId}/shares`, { actor: auditorRole, body: {} })).status,
167
+ 404,
168
+ );
169
+
170
+ // --- the listing screen (R22) --------------------------------------------
171
+ // The endpoint a security review found missing. Every assertion below is a
172
+ // rule the application would otherwise have had to express in SQL.
173
+ const listOf = async (actor?: string) =>
174
+ (await call('GET', `/orgs/${acme}/files`, actor ? { actor } : {})).json.files.map(
175
+ (f: any) => f.name,
176
+ );
177
+
178
+ // The CFO owns the private deck and the org-visible handbook: both.
179
+ assert.deepEqual((await listOf(cfo)).sort(), ['board-deck.pdf', 'handbook.pdf']);
180
+ // A member sees only the org-visible one. The private deck is not merely
181
+ // unreadable, it is not enumerable.
182
+ assert.deepEqual(await listOf(staff), ['handbook.pdf']);
183
+ assert.deepEqual(await listOf(auditorRole), ['handbook.pdf']);
184
+ // The rival tenant sees nothing, and gets a 200 with an empty page rather
185
+ // than a 404 -- a 404 here would be an org-existence oracle.
186
+ const rivalList = await call('GET', `/orgs/${acme}/files`, { actor: rival });
187
+ assert.equal(rivalList.status, 200);
188
+ assert.deepEqual(rivalList.json.files, []);
189
+ assert.deepEqual(await listOf(), []); // anonymous
190
+ // An org that does not exist is indistinguishable from one you cannot see.
191
+ const ghost = await call('GET', `/orgs/00000000-0000-4000-8000-000000000000/files`, {
192
+ actor: cfo,
193
+ });
194
+ assert.equal(ghost.status, 200);
195
+ assert.deepEqual(ghost.json.files, []);
196
+
197
+ // A grant makes a private file appear -- and revoking it makes it vanish.
198
+ const direct = await call('POST', `/files/${fileId}/shares`, {
199
+ actor: cfo,
200
+ body: { actorId: staff },
201
+ });
202
+ assert.equal(direct.status, 201);
203
+ assert.deepEqual((await listOf(staff)).sort(), ['board-deck.pdf', 'handbook.pdf']);
204
+ assert.equal((await call('DELETE', `/shares/${direct.json.grantId}`, { actor: cfo })).status, 204);
205
+ assert.deepEqual(await listOf(staff), ['handbook.pdf']);
206
+
207
+ // Pagination is keyset and the order is total.
208
+ const page1 = (await call('GET', `/orgs/${acme}/files?limit=1`, { actor: cfo })).json;
209
+ assert.equal(page1.files.length, 1);
210
+ assert.ok(page1.nextCursor);
211
+ const page2 = (
212
+ await call('GET', `/orgs/${acme}/files?limit=1&cursor=${encodeURIComponent(page1.nextCursor)}`, {
213
+ actor: cfo,
214
+ })
215
+ ).json;
216
+ assert.equal(page2.files.length, 1);
217
+ assert.notEqual(page1.files[0].id, page2.files[0].id);
218
+ assert.equal(page2.nextCursor, null);
219
+
220
+ // --- authenticated read carries the security headers too -----------------
221
+ const readRes = await call('GET', `/files/${handbookId}`, { actor: staff });
222
+ assert.equal(readRes.headers.get('x-content-type-options'), 'nosniff');
223
+ assert.match(readRes.headers.get('content-disposition') ?? '', /^attachment;/);
224
+ assert.match(readRes.headers.get('cache-control') ?? '', /no-store/);
225
+
226
+ // --- share link with expiry + password + download cap --------------------
227
+ const share = await call('POST', `/files/${fileId}/shares`, {
228
+ actor: cfo,
229
+ body: { expiresInHours: 24, maxDownloads: 2, password: 'boardroom' },
230
+ });
231
+ assert.equal(share.status, 201);
232
+ const secret = share.json.secret;
233
+ assert.ok(secret);
234
+
235
+ assert.equal((await call('GET', `/d/${secret}`)).status, 401); // password required
236
+ // A credential in the URL is now impossible rather than merely discouraged: a
237
+ // credential in the query string is refused before any work is done, so it
238
+ // cannot reach an access log, a proxy log or browser history. It also does
239
+ // not consume a download.
240
+ const inQuery = await call('GET', `/d/${secret}?password=boardroom`);
241
+ assert.equal(inQuery.status, 400);
242
+ assert.equal(inQuery.json.error, 'credential_in_query');
243
+ assert.equal((await call('POST', `/d/${secret}`, { body: { password: 'nope' } })).status, 401);
244
+ const dl1 = await call('POST', `/d/${secret}`, { body: { password: 'boardroom' } });
245
+ assert.equal(dl1.status, 200);
246
+ assert.equal(dl1.text, 'BOARD DECK Q3');
247
+
248
+ // The library owns the response, so nosniff, no-store, an explicit
249
+ // disposition and no-referrer are present on the share path without the
250
+ // application naming any of them. That is the point of the route existing.
251
+ assert.equal(dl1.headers.get('x-content-type-options'), 'nosniff');
252
+ assert.match(dl1.headers.get('cache-control') ?? '', /no-store/);
253
+ assert.match(dl1.headers.get('content-disposition') ?? '', /^attachment;/);
254
+ assert.equal(dl1.headers.get('referrer-policy'), 'no-referrer');
255
+
256
+ // --- listing what has been shared ---------------------------------------
257
+ const allGrants = (await call('GET', `/files/${fileId}/shares`, { actor: cfo })).json;
258
+ // Two: the revoked actor grant from the listing section above, and the link.
259
+ // Revoked grants stay listed -- "what did we share, and is it still live" is
260
+ // the question a compliance screen asks.
261
+ assert.equal(allGrants.length, 2);
262
+ const grants = allGrants.filter((g: any) => g.subjectType === 'link');
263
+ assert.equal(grants.length, 1);
264
+ assert.equal(grants[0].hasPassword, true);
265
+ assert.equal(grants[0].downloadCount, 1);
266
+ assert.equal(JSON.stringify(allGrants).includes(secret), false);
267
+
268
+ // --- revocation beats the live URL --------------------------------------
269
+ assert.equal((await call('DELETE', `/shares/${grants[0].id}`, { actor: cfo })).status, 204);
270
+ assert.equal(
271
+ (await call('POST', `/d/${secret}`, { body: { password: 'boardroom' } })).status,
272
+ 404,
273
+ );
274
+
275
+ // --- retention ----------------------------------------------------------
276
+ const held = await call('POST', `/orgs/${acme}/files`, {
277
+ actor: cfo,
278
+ body: {
279
+ name: 'signed-contract.pdf',
280
+ contentType: 'application/pdf',
281
+ contentBase64: Buffer.from('CONTRACT').toString('base64'),
282
+ retainForDays: 2555,
283
+ },
284
+ });
285
+ assert.equal((await call('DELETE', `/files/${held.json.id}`, { actor: cfo })).status, 409);
286
+
287
+ // --- audit trail --------------------------------------------------------
288
+ const denials = (await call('GET', `/orgs/${acme}/audit?decision=deny`, { actor: cfo })).json;
289
+ assert.ok(denials.length >= 3, `expected the denials to be recorded, got ${denials.length}`);
290
+ assert.ok(denials.some((e: any) => e.reason === 'no_membership'));
291
+ assert.ok(denials.some((e: any) => e.reason === 'retention_hold'));
292
+
293
+ // A member cannot read the audit log.
294
+ assert.equal((await call('GET', `/orgs/${acme}/audit`, { actor: staff })).status, 404);
295
+ // Nor can the rival tenant.
296
+ assert.equal((await call('GET', `/orgs/${acme}/audit`, { actor: rival })).status, 404);
297
+
298
+ const integrity = (await call('GET', `/orgs/${acme}/audit-integrity`, { actor: cfo })).json;
299
+ assert.equal(integrity.valid, true);
300
+ assert.ok(integrity.checked > 5);
301
+ });
302
+ });
@@ -0,0 +1,29 @@
1
+ // Build configuration for the published package.
2
+ //
3
+ // The dev/test configuration (`tsconfig.json`) is `noEmit` and type-checks the
4
+ // examples too. This one exists for exactly one job: emit `dist/` — JavaScript
5
+ // plus declaration files — from `src/`.
6
+ //
7
+ // WHY THERE IS A BUILD AT ALL, given that the source runs unmodified on Node
8
+ // >= 22.18 via native type stripping: Node REFUSES to strip types from files
9
+ // under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). A
10
+ // package whose `exports` point at `.ts` is therefore installable but not
11
+ // importable. `dist/` is what makes `npm install @filelayer/core` work.
12
+ //
13
+ // `rewriteRelativeImportExtensions` is what lets the source keep its `./x.ts`
14
+ // specifiers (required by Node's loader when running from source) while the
15
+ // emitted JavaScript gets `./x.js`.
16
+ {
17
+ "extends": "./tsconfig.json",
18
+ "compilerOptions": {
19
+ "noEmit": false,
20
+ "outDir": "dist",
21
+ "rootDir": "src",
22
+ "declaration": true,
23
+ "declarationMap": true,
24
+ "sourceMap": true,
25
+ "removeComments": false
26
+ },
27
+ "include": ["src/**/*.ts"],
28
+ "exclude": ["test", "dev", "dist", "node_modules"]
29
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "nodenext",
7
+ "allowImportingTsExtensions": true,
8
+ "rewriteRelativeImportExtensions": true,
9
+ "verbatimModuleSyntax": true,
10
+ "erasableSyntaxOnly": true,
11
+ "noEmit": true,
12
+ "strict": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "noImplicitOverride": true,
15
+ "skipLibCheck": true,
16
+ "types": ["node"]
17
+ },
18
+ "include": ["src/**/*.ts", "test/**/*.ts", "../../examples/**/*.ts"]
19
+ }