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