@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
package/dist/store.js ADDED
@@ -0,0 +1,862 @@
1
+ /**
2
+ * STORE LAYER
3
+ *
4
+ * Implements `AuthzDeps` (see authz.ts) against PostgreSQL.
5
+ *
6
+ * The authorization grant lookups below MUST read through the `live_grant`
7
+ * view, never `file_grant`. The liveness predicate -- not revoked, not expired,
8
+ * under cap, AND the same for every ancestor of the grant -- is expressed
9
+ * exactly once, in the schema, and every caller inherits it. If a future query
10
+ * here reaches for `file_grant` directly it has silently opted out of
11
+ * revocation, expiry and delegation control, which is P4, which is the product.
12
+ * The view is the enforcement point, not a convenience.
13
+ *
14
+ * The one deliberate exception is `findGrantBySecret`, which reads
15
+ * `file_grant`. It exists so that a denial can be ATTRIBUTED and EXPLAINED in
16
+ * the audit log, and the engine never uses its result to allow anything. It is
17
+ * named to make that obvious and it is the only such query in the file.
18
+ *
19
+ * ------------------------------------------------------------------------
20
+ * TRUST BOUNDARY. EVERY METHOD ON `PostgresStore` IS UNAUTHORIZED.
21
+ * ------------------------------------------------------------------------
22
+ * This class is the engine's DEPENDENCY surface (`AuthzDeps`), not an API.
23
+ * `getFile`, `getActorGrants`, `listAuthorizedFiles`, `listAudit`,
24
+ * `verifyAuditChain` and `consumeDownload` all take resource ids and no
25
+ * principal, because the engine has already established standing before it
26
+ * calls them -- that is the division of labour. They are the same shape as the
27
+ * defect fixed in `Filelayer.getFileRecord`, and they are safe only because
28
+ * they are not reachable from the developer's surface.
29
+ *
30
+ * The rule that keeps that true, stated so it can be enforced in review:
31
+ * `@filelayer/sdk` -- the only thing a customer touches in a hosted deployment
32
+ * -- MUST NOT re-export `PostgresStore`, `Filelayer.store`, or `store.db`. A
33
+ * developer holding `store.db` holds arbitrary SQL over every tenant in the
34
+ * hosted database, which is precisely the connection a hosted deployment exists
35
+ * to take away from them.
36
+ */
37
+ import { createHash, randomBytes, scrypt as _scrypt, timingSafeEqual } from 'node:crypto';
38
+ import { promisify } from 'node:util';
39
+ import { membershipCells } from "./authz.js";
40
+ const scrypt = promisify(_scrypt);
41
+ const SCRYPT_KEYLEN = 32;
42
+ /**
43
+ * PGlite returns text[] as a JS array already; node-postgres does too. This
44
+ * guard exists because a raw text round-trip ('{read,write}') would otherwise
45
+ * silently produce a single-element array containing the literal braces, and a
46
+ * capability check against that string would deny everything -- fail closed,
47
+ * but confusingly.
48
+ */
49
+ export function toCapabilities(v) {
50
+ if (Array.isArray(v))
51
+ return v;
52
+ return String(v)
53
+ .replace(/^\{|\}$/g, '')
54
+ .split(',')
55
+ .filter(Boolean);
56
+ }
57
+ function toGrantRow(r) {
58
+ return {
59
+ id: r.id,
60
+ fileId: r.file_id,
61
+ orgId: r.org_id,
62
+ parentGrantId: r.parent_grant_id ?? null,
63
+ subjectType: r.subject_type,
64
+ subjectOrgId: r.subject_org_id ?? null,
65
+ subjectMinRole: r.subject_min_role ?? null,
66
+ ...(r.matched_role !== undefined && r.matched_role !== null
67
+ ? { matchedRole: r.matched_role }
68
+ : {}),
69
+ capabilities: toCapabilities(r.capabilities),
70
+ passwordHash: r.password_hash,
71
+ expiresAt: r.expires_at ? new Date(r.expires_at) : null,
72
+ maxDownloads: r.max_downloads,
73
+ downloadCount: r.download_count,
74
+ revokedAt: r.revoked_at ? new Date(r.revoked_at) : null,
75
+ };
76
+ }
77
+ // -----------------------------------------------------------------------------
78
+ // Store
79
+ // -----------------------------------------------------------------------------
80
+ /**
81
+ * The project every row belongs to when nobody named one. Mirrors the row
82
+ * inserted by schema.sql; see the PROJECT section there for why it exists.
83
+ */
84
+ export const DEFAULT_PROJECT_ID = '00000000-0000-0000-0000-0000000f11e1';
85
+ export class PostgresStore {
86
+ db;
87
+ /** Null means unscoped. See StoreOptions. */
88
+ projectId;
89
+ constructor(db, opts = {}) {
90
+ this.db = db;
91
+ this.projectId = opts.projectId === undefined ? DEFAULT_PROJECT_ID : opts.projectId;
92
+ }
93
+ /**
94
+ * The same store, bound to a different connection -- in practice, to an open
95
+ * transaction.
96
+ *
97
+ * This is what puts the audit write in the same transaction as the mutation
98
+ * it records. The engine calls `deps.audit()`; if `deps` is a store bound to
99
+ * the transaction, the event and the mutation commit or roll back together,
100
+ * and `audit_append()`'s advisory lock is held across BOTH rather than across
101
+ * one autocommit statement. `projectId` rides along, so a transactional store
102
+ * cannot accidentally become an unscoped one.
103
+ */
104
+ withDb(db) {
105
+ return new PostgresStore(db, { projectId: this.projectId });
106
+ }
107
+ now() {
108
+ return new Date();
109
+ }
110
+ // --- AuthzDeps ------------------------------------------------------------
111
+ async getFile(fileId) {
112
+ if (!isUuid(fileId))
113
+ return null; // a malformed id is "not found", not a 500
114
+ const { rows } = await this.db.query(`SELECT id, org_id, owner_id, state, visibility, expires_at, retain_until, deleted_at
115
+ FROM file WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)`, [fileId, this.projectId]);
116
+ const r = rows[0];
117
+ if (!r)
118
+ return null;
119
+ return {
120
+ id: r.id,
121
+ orgId: r.org_id,
122
+ ownerId: r.owner_id,
123
+ // A soft-deleted row is 'deleted' regardless of the state column, so a
124
+ // half-applied delete cannot leave a readable file behind.
125
+ state: r.deleted_at ? 'deleted' : r.state,
126
+ visibility: r.visibility,
127
+ expiresAt: r.expires_at ? new Date(r.expires_at) : null,
128
+ retainUntil: r.retain_until ? new Date(r.retain_until) : null,
129
+ };
130
+ }
131
+ /**
132
+ * "Is there an org here that this store may speak about?"
133
+ *
134
+ * A soft-deleted org is not one, and neither is an org whose PROJECT is
135
+ * soft-deleted -- deleting a customer must not leave their tenants
136
+ * addressable. Nor is an org in a different project.
137
+ *
138
+ * The return value decides which audit chain a denial is written to, so this
139
+ * predicate is also the thing that keeps a probe at another project's org id
140
+ * off that org's chain.
141
+ */
142
+ async orgExists(orgId) {
143
+ if (!isUuid(orgId))
144
+ return false;
145
+ const { rows } = await this.db.query(`SELECT 1 FROM org o
146
+ JOIN project p ON p.id = o.project_id
147
+ WHERE o.id = $1
148
+ AND o.deleted_at IS NULL
149
+ AND p.deleted_at IS NULL
150
+ AND ($2::uuid IS NULL OR o.project_id = $2::uuid)`, [orgId, this.projectId]);
151
+ return rows.length > 0;
152
+ }
153
+ /**
154
+ * Membership confers nothing when the org, the project, or the ACTOR
155
+ * has been soft-deleted (P7).
156
+ *
157
+ * The actor clause is the one that was missing. `deleted_at` existed on
158
+ * `actor` and nothing read it, so a "deleted" user kept every role-derived
159
+ * capability they had -- which made the column decorative and made the answer
160
+ * to "what does deleting a user do?" depend on which method you asked.
161
+ */
162
+ async getMembership(orgId, actorId) {
163
+ if (!isUuid(orgId) || !isUuid(actorId))
164
+ return null;
165
+ const { rows } = await this.db.query(`SELECT m.role FROM membership m
166
+ JOIN org o ON o.id = m.org_id
167
+ JOIN project p ON p.id = o.project_id
168
+ JOIN actor a ON a.id = m.actor_id
169
+ WHERE m.org_id = $1 AND m.actor_id = $2
170
+ AND o.deleted_at IS NULL
171
+ AND p.deleted_at IS NULL
172
+ AND a.deleted_at IS NULL
173
+ AND ($3::uuid IS NULL OR o.project_id = $3::uuid)`, [orgId, actorId, this.projectId]);
174
+ return rows[0]?.role ?? null;
175
+ }
176
+ async countOwners(orgId) {
177
+ if (!isUuid(orgId))
178
+ return 0;
179
+ const { rows } = await this.db.query(`SELECT count(*)::int AS c FROM membership WHERE org_id = $1 AND role = 'owner'`, [orgId]);
180
+ return Number(rows[0]?.c ?? 0);
181
+ }
182
+ /**
183
+ * live_grant only.
184
+ *
185
+ * THE ORDER BY IS LOAD-BEARING, and its absence was a latent defect found
186
+ * while adding the group subject types. `resolveStanding` attributes an allow
187
+ * to the FIRST grant in this result that supplies the requested capability,
188
+ * and on the delegation path that grant becomes the new grant's
189
+ * `parent_grant_id` -- so it decides the ceiling the attenuation trigger will
190
+ * measure the child against. With no ORDER BY, Postgres returned heap order,
191
+ * which is not a defined order at all: it changes with page layout, with
192
+ * VACUUM, and (this is how it surfaced) with the width of the row. A
193
+ * principal holding several grants on one file could therefore have a
194
+ * `share()` call succeed or fail depending on physical storage.
195
+ *
196
+ * Oldest-first makes the choice deterministic and makes it the sensible one:
197
+ * the authority you were given first is the one you delegate from, and it is
198
+ * the same rule `getAnonymousGrant` already applies. See the note on the
199
+ * residual union-vs-parent gap in `authorizeShare`.
200
+ */
201
+ async getActorGrants(fileId, actorId) {
202
+ if (!isUuid(fileId) || !isUuid(actorId))
203
+ return [];
204
+ const { rows } = await this.db.query(`SELECT * FROM live_grant
205
+ WHERE file_id = $1 AND subject_type = 'actor' AND subject_id = $2
206
+ ORDER BY created_at ASC, id ASC`, [fileId, actorId]);
207
+ return rows.map(toGrantRow);
208
+ }
209
+ /**
210
+ * live_grant only. THE GROUP-GRANT PATH (RFC-001).
211
+ *
212
+ * ONE JOIN. Not a fan-out table, not a cached member list, not a
213
+ * materialized view -- a join against `membership`, evaluated on this
214
+ * request. That is the whole implementation of "membership changes must
215
+ * change access on the next request, without recomputation": there is
216
+ * nothing to recompute, because nothing was ever computed. Adding a member
217
+ * makes the join match; removing one makes it stop matching; no `file_grant`
218
+ * row is read, written, or even looked at during either operation. The test
219
+ * that asserts this counts grant rows before and after a join and a leave.
220
+ *
221
+ * The membership half carries the same three liveness clauses `getMembership`
222
+ * applies -- org, project and actor not soft-deleted -- because a group grant
223
+ * must not confer through a membership that confers nothing on its own. The
224
+ * SUBJECT ORG's own deletion is handled one level down, inside
225
+ * `grant_scope_is_live` (I2), so it binds `consume_download` and the
226
+ * attenuation trigger as well and not merely this query.
227
+ *
228
+ * `subject_min_role` is compared against the held role by tuple membership
229
+ * in the DERIVED cell list -- see `membershipCellSql` -- so the threshold is
230
+ * not restated in SQL here or in `listAuthorizedFiles`.
231
+ */
232
+ async getGroupGrants(fileId, actorId) {
233
+ if (!isUuid(fileId) || !isUuid(actorId))
234
+ return [];
235
+ const { rows } = await this.db.query(`SELECT g.*, m.role AS matched_role
236
+ FROM live_grant g
237
+ JOIN membership m ON m.org_id = g.subject_org_id AND m.actor_id = $2::uuid
238
+ JOIN org so ON so.id = m.org_id
239
+ JOIN project sp ON sp.id = so.project_id
240
+ JOIN actor sa ON sa.id = m.actor_id
241
+ WHERE g.file_id = $1::uuid
242
+ AND g.subject_type IN ('org', 'role')
243
+ AND so.deleted_at IS NULL
244
+ AND sp.deleted_at IS NULL
245
+ AND sa.deleted_at IS NULL
246
+ AND ($3::uuid IS NULL OR so.project_id = $3::uuid)
247
+ AND ${membershipCellSql(membershipCells())}
248
+ ORDER BY g.created_at ASC, g.id ASC`, [fileId, actorId, this.projectId]);
249
+ return rows.map(toGrantRow);
250
+ }
251
+ /** live_grant only. Lookup is by hash; the plaintext never reaches the DB. */
252
+ async findLiveGrantBySecret(secretHash) {
253
+ const { rows } = await this.db.query(`SELECT * FROM live_grant
254
+ WHERE subject_type = 'link' AND secret_hash = $1
255
+ LIMIT 1`, [secretHash]);
256
+ return rows[0] ? toGrantRow(rows[0]) : null;
257
+ }
258
+ /**
259
+ * ATTRIBUTION ONLY -- never an authorization input.
260
+ *
261
+ * Reads `file_grant`, so it sees revoked, expired, exhausted and
262
+ * ancestor-dead grants. The engine calls it exclusively on paths that have
263
+ * already denied, in order to name the grant in the audit event and record
264
+ * WHY the link stopped working. Any future use of this result to grant
265
+ * access is a P4 violation and should fail review.
266
+ */
267
+ async findGrantBySecret(secretHash) {
268
+ const { rows } = await this.db.query(`SELECT * FROM file_grant
269
+ WHERE subject_type = 'link' AND secret_hash = $1
270
+ LIMIT 1`, [secretHash]);
271
+ return rows[0] ? toGrantRow(rows[0]) : null;
272
+ }
273
+ async isDescendantOf(ancestorId, grantId) {
274
+ if (!isUuid(ancestorId) || !isUuid(grantId))
275
+ return false;
276
+ const { rows } = await this.db.query(`SELECT 1 FROM grant_ancestry($1) WHERE id = $2`, [grantId, ancestorId]);
277
+ return rows.length > 0;
278
+ }
279
+ /** live_grant only. */
280
+ async getAnonymousGrant(fileId) {
281
+ if (!isUuid(fileId))
282
+ return null;
283
+ const { rows } = await this.db.query(`SELECT * FROM live_grant
284
+ WHERE file_id = $1 AND subject_type = 'anonymous'
285
+ ORDER BY created_at ASC
286
+ LIMIT 1`, [fileId]);
287
+ return rows[0] ? toGrantRow(rows[0]) : null;
288
+ }
289
+ /**
290
+ * THE SET FORM OF THE DECISION.
291
+ *
292
+ * One query, one round trip, for the same predicate `authorize()` evaluates
293
+ * per file. Read it against `resolveStanding()` in authz.ts -- the three OR
294
+ * branches are the three sources of authority, in the same order, with the
295
+ * same conditions:
296
+ *
297
+ * 1. org role -> membership x the DERIVED role-cell list
298
+ * 2. actor grant -> live_grant, subject_type='actor', subject_id=caller
299
+ * 3. anonymous -> the EARLIEST live anonymous grant, read only
300
+ *
301
+ * ...and the lifecycle gate is the DERIVED lifecycle-cell list.
302
+ *
303
+ * The role and lifecycle tuples are not written here. They are generated by
304
+ * `listPredicate()` from `fileCapabilities()` and `lifecycleDenial()`, so
305
+ * changing the role model changes this query automatically. The tuples are
306
+ * still validated against the enum vocabulary before interpolation, because a
307
+ * generated string that reaches SQL unchecked is a generated string that can
308
+ * be made to reach SQL unchecked.
309
+ *
310
+ * `now` is passed in rather than read from the database so that the point
311
+ * check and the set query cannot disagree about what time it is. Grant
312
+ * liveness still uses the database clock, via `live_grant`, in BOTH paths.
313
+ *
314
+ * Ordering is (created_at, id) ascending -- total, because id is unique --
315
+ * and pagination is keyset, not OFFSET, so a concurrent insert cannot make a
316
+ * row skip a page.
317
+ */
318
+ async listAuthorizedFiles(q) {
319
+ if (!isUuid(q.orgId))
320
+ return [];
321
+ if (q.actorId !== null && !isUuid(q.actorId))
322
+ return [];
323
+ const lifecycle = lifecycleSql(q.predicate);
324
+ const roles = roleCellSql(q.predicate);
325
+ if (lifecycle === null)
326
+ return []; // no state is usable for this capability
327
+ // The GROUP branch (RFC-001). Structurally identical to `getGroupGrants`:
328
+ // the same join, the same three membership-liveness clauses, the same
329
+ // derived threshold cells. It is written twice only in the sense that the
330
+ // whole union is -- which is exactly what the differential test in
331
+ // test/listing.test.ts exists to police, and why that test's corpus now
332
+ // contains org and role grants.
333
+ //
334
+ // No capability branch, mirroring `resolveStanding`: a group grant may
335
+ // carry any capability, so it is consulted for all of them.
336
+ const group = `OR EXISTS (
337
+ SELECT 1
338
+ FROM live_grant g
339
+ JOIN membership m ON m.org_id = g.subject_org_id AND m.actor_id = $2::uuid
340
+ JOIN org so ON so.id = m.org_id
341
+ JOIN project sp ON sp.id = so.project_id
342
+ JOIN actor sa ON sa.id = m.actor_id
343
+ WHERE g.file_id = f.id
344
+ AND g.subject_type IN ('org', 'role')
345
+ AND so.deleted_at IS NULL
346
+ AND sp.deleted_at IS NULL
347
+ AND sa.deleted_at IS NULL
348
+ AND ($6::uuid IS NULL OR so.project_id = $6::uuid)
349
+ AND ${membershipCellSql(q.predicate.membershipCells ?? [])}
350
+ AND $4::grant_capability = ANY (g.capabilities)
351
+ )`;
352
+ const anon = q.predicate.anonymousEligible
353
+ ? `OR EXISTS (
354
+ SELECT 1 FROM (
355
+ SELECT g2.capabilities
356
+ FROM live_grant g2
357
+ WHERE g2.file_id = f.id AND g2.subject_type = 'anonymous'
358
+ ORDER BY g2.created_at ASC
359
+ LIMIT 1
360
+ ) ag
361
+ WHERE $4::grant_capability = ANY (ag.capabilities)
362
+ )`
363
+ : '';
364
+ const params = [
365
+ q.orgId,
366
+ q.actorId,
367
+ q.now.toISOString(),
368
+ q.predicate.capability,
369
+ Math.max(1, Math.min(q.limit, LIST_MAX_LIMIT)),
370
+ this.projectId,
371
+ ];
372
+ let cursorClause = '';
373
+ if (q.cursor) {
374
+ params.push(q.cursor.createdAt.toISOString(), q.cursor.id);
375
+ cursorClause = `AND (f.created_at, f.id) > ($7::timestamptz, $8::uuid)`;
376
+ }
377
+ const { rows } = await this.db.query(`SELECT f.id, f.org_id, f.owner_id, f.name, f.content_type, f.size_bytes,
378
+ f.storage_provider, f.storage_key, f.state, f.visibility,
379
+ f.expires_at, f.retain_until,
380
+ f.created_at, f.deleted_at
381
+ FROM file f
382
+ WHERE f.org_id = $1::uuid
383
+ AND ($6::uuid IS NULL OR f.project_id = $6::uuid)
384
+ AND ${lifecycle}
385
+ AND (
386
+ ${roles === null
387
+ ? 'false'
388
+ : `EXISTS (
389
+ SELECT 1
390
+ FROM membership m
391
+ JOIN org o ON o.id = m.org_id
392
+ JOIN project p ON p.id = o.project_id
393
+ JOIN actor a ON a.id = m.actor_id
394
+ WHERE m.org_id = f.org_id
395
+ AND m.actor_id = $2::uuid
396
+ AND o.deleted_at IS NULL
397
+ AND p.deleted_at IS NULL
398
+ AND a.deleted_at IS NULL
399
+ AND (m.role::text,
400
+ coalesce(f.owner_id = $2::uuid, false),
401
+ f.visibility::text) IN ${roles}
402
+ )`}
403
+ OR EXISTS (
404
+ SELECT 1 FROM live_grant g
405
+ WHERE g.file_id = f.id
406
+ AND g.subject_type = 'actor'
407
+ AND g.subject_id = $2::uuid
408
+ AND $4::grant_capability = ANY (g.capabilities)
409
+ )
410
+ ${group}
411
+ ${anon}
412
+ )
413
+ ${cursorClause}
414
+ ORDER BY f.created_at ASC, f.id ASC
415
+ LIMIT $5`, params);
416
+ return rows.map(toListedFile);
417
+ }
418
+ /**
419
+ * Link secrets are 256 bits of CSPRNG output, so they are not guessable and
420
+ * not enumerable; an unsalted SHA-256 is the correct primitive here (it must
421
+ * be deterministic to be indexable, and there is no low-entropy input to
422
+ * protect). Passwords are the opposite case and use scrypt below.
423
+ */
424
+ async hashSecret(secret) {
425
+ return createHash('sha256').update(secret, 'utf8').digest('hex');
426
+ }
427
+ async hashPassword(password) {
428
+ const salt = randomBytes(16);
429
+ const key = await scrypt(password, salt, SCRYPT_KEYLEN);
430
+ return `scrypt$${salt.toString('base64')}$${key.toString('base64')}`;
431
+ }
432
+ async verifyPassword(password, hash) {
433
+ const parts = hash.split('$');
434
+ if (parts.length !== 3 || parts[0] !== 'scrypt')
435
+ return false;
436
+ const salt = Buffer.from(parts[1], 'base64');
437
+ const expected = Buffer.from(parts[2], 'base64');
438
+ const actual = await scrypt(password, salt, expected.length);
439
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
440
+ }
441
+ // --- Audit (P5) -----------------------------------------------------------
442
+ /**
443
+ * Hash chain. Each event commits to its predecessor in the same chain, where
444
+ * a chain is an org -- or the SYSTEM chain, `org_id IS NULL`, for decisions
445
+ * with no tenant to attribute them to.
446
+ *
447
+ * The digest now covers EVERY forensically relevant column, not the
448
+ * seven it used to cover. `reason`, `grant_id`, `ip`, `user_agent` and
449
+ * `context` are exactly the fields an incident responder relies on and
450
+ * exactly the fields an attacker would rewrite; leaving them outside the
451
+ * commitment made the chain decorative for the questions that matter.
452
+ *
453
+ * JSON encoding rather than string concatenation because concatenation is
454
+ * ambiguous: ('ab','c') and ('a','bc') would hash identically and a forger
455
+ * could shift field boundaries. `context` is canonicalised with sorted keys
456
+ * at every level, because jsonb does not preserve key order and the digest
457
+ * must survive the round trip through the database.
458
+ *
459
+ * CONCURRENCY -- fixed. This used to be a SELECT of the last hash
460
+ * followed by an INSERT, from here, in two statements -- atomic only under a
461
+ * single writer, and a chain FORK under two. It is now one call to
462
+ * `audit_append()`, which takes `pg_advisory_xact_lock` on the chain, reads
463
+ * the predecessor and inserts, all inside one statement and therefore one
464
+ * transaction. See the long comment on that function in schema.sql, including
465
+ * what PGlite can and cannot prove about it.
466
+ *
467
+ * The canonical encoding still lives here and only here: this method computes
468
+ * everything in the digest input EXCEPT the predecessor hash (which it cannot
469
+ * know without racing) and hands it over as `hash_tail`. The database
470
+ * prepends the predecessor it read under the lock. `verifyAuditChain` then
471
+ * recomputes the entire digest in TypeScript on read, so the SQL side and the
472
+ * TypeScript side are checked against each other by every chain verification
473
+ * in the suite.
474
+ *
475
+ * CHAIN FLOODING -- AND WHY IT IS NOT FIXED HERE.
476
+ *
477
+ * An unauthenticated caller who guesses an org UUID can cause denials to be
478
+ * appended to that tenant's chain. Under the per-chain lock above that is
479
+ * worse than storage growth: the tenant's own requests each take the same
480
+ * lock on the way through their audit write, so a flood is a latency attack
481
+ * on that tenant's whole request path, not merely noise in their log. (That
482
+ * interaction is new with the per-chain lock and is worth stating on its own.)
483
+ *
484
+ * It still does not belong in this function:
485
+ *
486
+ * - P5 says every decision is recorded, INCLUDING denials, because denials
487
+ * are the security-relevant events. Any in-engine mitigation is a rule for
488
+ * DROPPING audit events, and a log with a "we stop recording under load"
489
+ * clause is worthless in exactly the incident it exists for.
490
+ * - The engine cannot distinguish a flood from reconnaissance. They are the
491
+ * same request; only the rate differs, and rate is not observable from
492
+ * inside a single decision.
493
+ * - Admission control is the layer that can see it, and it belongs above the
494
+ * engine.
495
+ *
496
+ * WHAT IS FIXED HERE: the blast radius. `orgExists` is project-scoped, so a
497
+ * caller can only reach a tenant chain inside a project they are already
498
+ * authenticated for; probes at every other org id land on the system chain.
499
+ * The exposure goes from "any internet caller can degrade any tenant" to "an
500
+ * authenticated customer can degrade their own tenant" -- a quota question.
501
+ * That reduction is asserted in test/semantics.test.ts.
502
+ *
503
+ * WHAT REMAINS AN OPERATIONAL REQUIREMENT ON THE API LAYER. Stated as a
504
+ * requirement, not a hope, and repeated in SEMANTICS.md:
505
+ *
506
+ * R1. Every request carries a project credential; no project, no engine.
507
+ * R2. Rate limit per (project, source address) BEFORE the engine runs.
508
+ * R3. Cap per-project audit append rate and shed with 429 -- never by
509
+ * dropping a decision that was actually made.
510
+ * R4. Alert on SYSTEM-chain append rate. That chain is where unattributable
511
+ * probes go, so its rate is the enumeration signal.
512
+ */
513
+ async audit(event) {
514
+ const occurredAt = this.now();
515
+ const ip = normalizeIp(event.ip);
516
+ const context = { ...(event.context ?? {}) };
517
+ // An address we cannot store as `inet` is kept verbatim in `context` rather
518
+ // than thrown away or, worse, passed to the cast: a malformed
519
+ // X-Forwarded-For must not be able to abort the audit write, because an
520
+ // audit write that fails takes the whole request with it.
521
+ if (event.ip !== undefined && event.ip !== null && ip === null) {
522
+ context['rawIp'] = String(event.ip).slice(0, 64);
523
+ }
524
+ const fields = {
525
+ // The predecessor is supplied by the database, under the chain lock.
526
+ prevHash: null,
527
+ orgId: event.orgId,
528
+ occurredAt,
529
+ action: event.action,
530
+ decision: event.decision,
531
+ reason: event.reason ?? null,
532
+ actorId: event.actorId,
533
+ fileId: event.fileId,
534
+ grantId: event.grantId ?? null,
535
+ ip,
536
+ userAgent: event.userAgent ?? null,
537
+ context,
538
+ };
539
+ await this.db.query(`SELECT id FROM audit_append($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12)`, [
540
+ event.orgId,
541
+ occurredAt.toISOString(),
542
+ event.action,
543
+ event.decision,
544
+ fields.reason,
545
+ event.actorId,
546
+ event.fileId,
547
+ fields.grantId,
548
+ fields.ip,
549
+ fields.userAgent,
550
+ JSON.stringify(fields.context),
551
+ auditHashTail(fields),
552
+ ]);
553
+ }
554
+ async listAudit(orgId, filter = {}) {
555
+ const params = [orgId];
556
+ const clauses = ['org_id IS NOT DISTINCT FROM $1'];
557
+ if (filter.decision) {
558
+ params.push(filter.decision);
559
+ clauses.push(`decision = $${params.length}`);
560
+ }
561
+ if (filter.fileId) {
562
+ params.push(filter.fileId);
563
+ clauses.push(`file_id = $${params.length}`);
564
+ }
565
+ if (filter.actorId) {
566
+ params.push(filter.actorId);
567
+ clauses.push(`actor_id = $${params.length}`);
568
+ }
569
+ if (filter.action) {
570
+ params.push(filter.action);
571
+ clauses.push(`action = $${params.length}`);
572
+ }
573
+ params.push(Math.min(filter.limit ?? 500, 5000));
574
+ const { rows } = await this.db.query(`${AUDIT_COLUMNS}
575
+ WHERE ${clauses.join(' AND ')}
576
+ ORDER BY id ASC
577
+ LIMIT $${params.length}`, params);
578
+ return rows.map(mapAuditRow);
579
+ }
580
+ /**
581
+ * Full chain replay. Returns the first inconsistency found, if any.
582
+ * Pass `null` to verify the system chain.
583
+ */
584
+ async verifyAuditChain(orgId) {
585
+ const { rows } = await this.db.query(`${AUDIT_COLUMNS} WHERE org_id IS NOT DISTINCT FROM $1 ORDER BY id ASC`, [orgId]);
586
+ const events = rows.map(mapAuditRow);
587
+ let prev = null;
588
+ for (const e of events) {
589
+ if (e.prevHash !== prev) {
590
+ return {
591
+ valid: false,
592
+ checked: events.length,
593
+ brokenAt: e.id,
594
+ problem: 'prev_hash_mismatch',
595
+ };
596
+ }
597
+ const expected = auditHash({
598
+ prevHash: e.prevHash,
599
+ orgId: e.orgId,
600
+ occurredAt: e.occurredAt,
601
+ action: e.action,
602
+ decision: e.decision,
603
+ reason: e.reason,
604
+ actorId: e.actorId,
605
+ fileId: e.fileId,
606
+ grantId: e.grantId,
607
+ ip: e.ip,
608
+ userAgent: e.userAgent,
609
+ context: e.context,
610
+ });
611
+ if (expected !== e.hash) {
612
+ return { valid: false, checked: events.length, brokenAt: e.id, problem: 'hash_mismatch' };
613
+ }
614
+ prev = e.hash;
615
+ }
616
+ return { valid: true, checked: events.length };
617
+ }
618
+ // --- Counters (P6) --------------------------------------------------------
619
+ /**
620
+ * The conditional write *is* the reservation, and it charges the whole
621
+ * ancestor chain (see `consume_download` in schema.sql). Never read the
622
+ * counter and then decide -- see the "naive" comparison in the test suite for
623
+ * what that costs.
624
+ *
625
+ * The function now always returns exactly one row, and this reads
626
+ * `granted === true` rather than defaulting a missing value. Both halves of
627
+ * that had to change: an explicit `(false, 0)` row makes the SQL side
628
+ * unambiguous, and an explicit `=== true` makes the TypeScript side fail
629
+ * closed even if the SQL side is ever replaced with something that returns
630
+ * nothing at all.
631
+ */
632
+ async consumeDownload(grantId) {
633
+ if (!isUuid(grantId))
634
+ return { granted: false, remaining: 0 };
635
+ const { rows } = await this.db.query(`SELECT granted, remaining FROM consume_download($1)`, [grantId]);
636
+ const r = rows[0];
637
+ if (!r || r.granted !== true)
638
+ return { granted: false, remaining: 0 };
639
+ return { granted: true, remaining: r.remaining === null ? null : Number(r.remaining) };
640
+ }
641
+ // --- Metering -------------------------------------------------------------
642
+ async recordUsage(orgId, kind, bytes = 0) {
643
+ const col = kind === 'authz' ? 'authz_checks' : kind === 'read' ? 'file_reads' : 'file_writes';
644
+ await this.db.query(`INSERT INTO usage_daily (org_id, day, ${col}, bytes_egressed)
645
+ VALUES ($1, current_date, 1, $2)
646
+ ON CONFLICT (org_id, day) DO UPDATE
647
+ SET ${col} = usage_daily.${col} + 1,
648
+ bytes_egressed = usage_daily.bytes_egressed + EXCLUDED.bytes_egressed`, [orgId, bytes]);
649
+ }
650
+ /**
651
+ * Distinct actors who own a file on a given day. `usage_daily` counts events;
652
+ * this counts PEOPLE, which is the quantity authorization load actually
653
+ * tracks -- the graph walk behind every decision grows with the number of
654
+ * distinct holders, not with how many bytes they hold. Idempotent per
655
+ * (org, day, actor).
656
+ */
657
+ async recordFileOwner(orgId, actorId) {
658
+ if (!isUuid(orgId) || !isUuid(actorId))
659
+ return;
660
+ await this.db.query(`INSERT INTO file_owning_user_daily (org_id, day, actor_id)
661
+ VALUES ($1, current_date, $2)
662
+ ON CONFLICT (org_id, day, actor_id) DO NOTHING`, [orgId, actorId]);
663
+ }
664
+ }
665
+ /**
666
+ * A page is bounded so that one request cannot be turned into an unbounded
667
+ * scan, and so that the single audit event's `fileIds` array stays bounded too.
668
+ */
669
+ export const LIST_MAX_LIMIT = 200;
670
+ export const LIST_DEFAULT_LIMIT = 50;
671
+ /** Enum labels are `[a-z_]+` by construction. Assert it rather than assume it. */
672
+ function enumLiteral(v) {
673
+ if (!/^[a-z_]+$/.test(v))
674
+ throw new Error(`unexpected enum literal: ${v}`);
675
+ return `'${v}'`;
676
+ }
677
+ /**
678
+ * `(role, is_owner, visibility) IN ((...),(...))`, generated from the derived
679
+ * cells. Returns null when the capability is unreachable by any role, so the
680
+ * caller can substitute a constant `false` -- `IN ()` is a syntax error, and a
681
+ * generator that emits a syntax error under a fail-CLOSED input is a generator
682
+ * that will one day be "fixed" by removing the branch.
683
+ */
684
+ function roleCellSql(p) {
685
+ if (p.roleCells.length === 0)
686
+ return null;
687
+ return `(${p.roleCells
688
+ .map((c) => `(${enumLiteral(c.role)}, ${c.isOwner}, ${enumLiteral(c.visibility)})`)
689
+ .join(', ')})`;
690
+ }
691
+ /**
692
+ * `(floor, held role) IN ((...),(...))` -- the group-grant threshold, generated
693
+ * from the derived cells rather than written as `m.role >= g.subject_min_role`.
694
+ *
695
+ * WHY NOT JUST `>=`. Postgres would happily compare two `org_role` values by
696
+ * their DECLARATION ORDER in the enum, and it would be correct today. It would
697
+ * also be a second, independent statement of the role ordering, sitting in SQL,
698
+ * agreeing with `ROLE_RANK` in authz.ts only by coincidence -- and reordering
699
+ * the enum (or inserting a value into the middle of it, which
700
+ * `ALTER TYPE ... ADD VALUE BEFORE` permits) would silently change who can read
701
+ * what, with no test failing. Generating the pairs from `roleMeets()` keeps one
702
+ * definition of the ordering, in TypeScript, where the point check reads it.
703
+ *
704
+ * A `role` grant with no floor is not representable (the CHECK requires one)
705
+ * and an `org` grant's NULL floor reads as 'viewer', which is what the coalesce
706
+ * says.
707
+ *
708
+ * Returns `false` when the cell list is empty, for the same reason
709
+ * `roleCellSql` returns null: `IN ()` is a syntax error, and a generator that
710
+ * crashes on a fail-CLOSED input is a generator someone will "fix".
711
+ */
712
+ export function membershipCellSql(cells) {
713
+ if (cells.length === 0)
714
+ return 'false';
715
+ const tuples = cells
716
+ .map((c) => `(${enumLiteral(c.minRole)}, ${enumLiteral(c.role)})`)
717
+ .join(', ');
718
+ return `(coalesce(g.subject_min_role::text, 'viewer'), m.role::text) IN (${tuples})`;
719
+ }
720
+ /**
721
+ * The lifecycle gate. `state` is the EFFECTIVE state -- a soft-deleted row is
722
+ * 'deleted' whatever the state column says, exactly as `getFile()` maps it, so
723
+ * a half-applied delete cannot leave a listable file behind.
724
+ */
725
+ function lifecycleSql(p) {
726
+ if (p.lifecycleCells.length === 0)
727
+ return null;
728
+ const tuples = p.lifecycleCells
729
+ .map((c) => `(${enumLiteral(c.state)}, ${c.expired}, ${c.retained})`)
730
+ .join(', ');
731
+ return `(
732
+ CASE WHEN f.deleted_at IS NOT NULL THEN 'deleted' ELSE f.state::text END,
733
+ (f.expires_at IS NOT NULL AND f.expires_at <= $3::timestamptz),
734
+ (f.retain_until IS NOT NULL AND f.retain_until > $3::timestamptz)
735
+ ) IN (${tuples})`;
736
+ }
737
+ function toListedFile(r) {
738
+ return {
739
+ id: r['id'],
740
+ orgId: r['org_id'],
741
+ ownerId: r['owner_id'] ?? null,
742
+ name: r['name'],
743
+ contentType: r['content_type'],
744
+ sizeBytes: r['size_bytes'] == null ? null : Number(r['size_bytes']),
745
+ storageProvider: r['storage_provider'],
746
+ storageKey: r['storage_key'],
747
+ state: r['deleted_at'] ? 'deleted' : r['state'],
748
+ visibility: r['visibility'],
749
+ expiresAt: r['expires_at'] ? new Date(r['expires_at']) : null,
750
+ retainUntil: r['retain_until'] ? new Date(r['retain_until']) : null,
751
+ createdAt: new Date(r['created_at']),
752
+ };
753
+ }
754
+ const AUDIT_COLUMNS = `SELECT id, org_id, occurred_at, action, decision, reason, actor_id,
755
+ file_id, grant_id, host(ip) AS ip, user_agent, context, prev_hash, hash
756
+ FROM audit_event`;
757
+ /**
758
+ * Canonical JSON: object keys sorted at every depth, so a value that has been
759
+ * through jsonb (which does not preserve insertion order) hashes identically to
760
+ * the value that was written.
761
+ */
762
+ export function canonicalJson(v) {
763
+ if (Array.isArray(v))
764
+ return v.map(canonicalJson);
765
+ if (v && typeof v === 'object') {
766
+ const src = v;
767
+ const out = {};
768
+ for (const k of Object.keys(src).sort())
769
+ out[k] = canonicalJson(src[k]);
770
+ return out;
771
+ }
772
+ return v === undefined ? null : v;
773
+ }
774
+ /**
775
+ * Everything in the digest input EXCEPT the predecessor hash, as the tail of a
776
+ * JSON array -- i.e. `orgId,...,context]`, with no leading bracket.
777
+ *
778
+ * This split is what lets `audit_append()` (schema.sql) take the chain lock,
779
+ * read the predecessor and compute the digest in ONE statement without any of
780
+ * the canonical encoding being duplicated in SQL. The database performs one
781
+ * concatenation:
782
+ *
783
+ * '[' || to_json(prev_hash) || ',' || tail
784
+ *
785
+ * `prevHash` is the first element of the array for exactly this reason, and
786
+ * moving it would silently break the chain -- which is why the ordering is
787
+ * asserted by a test rather than left to a comment.
788
+ */
789
+ export function auditHashTail(input) {
790
+ return JSON.stringify([
791
+ input.orgId,
792
+ input.occurredAt.toISOString(),
793
+ input.action,
794
+ input.decision,
795
+ input.reason ?? null,
796
+ input.actorId,
797
+ input.fileId,
798
+ input.grantId ?? null,
799
+ input.ip ?? null,
800
+ input.userAgent ?? null,
801
+ canonicalJson(input.context ?? {}),
802
+ ]).slice(1); // drop the leading '['; the database supplies it with prev_hash
803
+ }
804
+ export function auditHash(input) {
805
+ const canonical = `[${JSON.stringify(input.prevHash)},${auditHashTail(input)}`;
806
+ return createHash('sha256').update(canonical, 'utf8').digest('hex');
807
+ }
808
+ function mapAuditRow(r) {
809
+ const rawContext = r['context'];
810
+ return {
811
+ id: Number(r['id']),
812
+ orgId: r['org_id'] ?? null,
813
+ occurredAt: new Date(r['occurred_at']),
814
+ action: r['action'],
815
+ decision: r['decision'],
816
+ reason: r['reason'] ?? null,
817
+ actorId: r['actor_id'] ?? null,
818
+ fileId: r['file_id'] ?? null,
819
+ grantId: r['grant_id'] ?? null,
820
+ ip: r['ip'] ?? null,
821
+ userAgent: r['user_agent'] ?? null,
822
+ context: typeof rawContext === 'string'
823
+ ? JSON.parse(rawContext)
824
+ : (rawContext ?? {}),
825
+ prevHash: r['prev_hash'] ?? null,
826
+ hash: r['hash'],
827
+ };
828
+ }
829
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
830
+ export function isUuid(v) {
831
+ return typeof v === 'string' && UUID_RE.test(v);
832
+ }
833
+ const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
834
+ const IPV6_RE = /^[0-9a-f:]{2,45}$/i;
835
+ /**
836
+ * Accept only what `inet` will take back out unchanged.
837
+ *
838
+ * Two reasons this is not paranoia. First, `host(inet)` is what the chain
839
+ * verifier reads, so any value Postgres would normalise (a CIDR suffix, for
840
+ * instance) would silently break tamper-evidence for every subsequent event.
841
+ * Second, an unparseable address would raise on INSERT, and since the audit
842
+ * write is on the critical path of every request, that turns a malformed
843
+ * `X-Forwarded-For` header into an outage.
844
+ */
845
+ export function normalizeIp(v) {
846
+ if (typeof v !== 'string')
847
+ return null;
848
+ const s = v.trim();
849
+ if (s.length === 0 || s.length > 45 || s.includes('/'))
850
+ return null;
851
+ if (IPV4_RE.test(s)) {
852
+ return s.split('.').every((o) => Number(o) <= 255 && String(Number(o)) === o) ? s : null;
853
+ }
854
+ if (!s.includes(':') || !IPV6_RE.test(s.replace(/\.\d{1,3}/g, '')))
855
+ return null;
856
+ // ::ffff:1.2.3.4 and friends: the tail must still be a legal dotted quad.
857
+ const tail = s.slice(s.lastIndexOf(':') + 1);
858
+ if (tail.includes('.') && !IPV4_RE.test(tail))
859
+ return null;
860
+ return s;
861
+ }
862
+ //# sourceMappingURL=store.js.map