@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,1360 @@
1
+ /**
2
+ * FILELAYER -- the surface an application developer actually touches.
3
+ *
4
+ * Design rule for this file: every method that touches a file or an org calls
5
+ * into the authorization engine and does nothing before it. There is no
6
+ * "internal" variant that skips the check, because the moment such a variant
7
+ * exists someone will call it from a route handler at 6pm on a Friday.
8
+ *
9
+ * SECOND design rule, added after the security review: this file contains no
10
+ * security LOGIC, only security PLUMBING. Every rule that used to live here as
11
+ * a "patch at the wrong layer" has moved into `authz.ts` or `schema.sql`:
12
+ *
13
+ * - capability attenuation on share -> authorizeShare() + a BEFORE
14
+ * INSERT trigger on file_grant
15
+ * - the 410/409 existence-oracle downgrade
16
+ * and its `hasStanding()` helper -> evaluation order in authorize()
17
+ * - the membership / viewer checks in
18
+ * upload() -> authorizeOrg('create_file')
19
+ * - the admin check in auditLog() -> authorizeOrg('read_audit')
20
+ * - the unaudited early return in redeem()
21
+ * for unknown secrets -> the engine's system chain
22
+ *
23
+ * Errors are thrown as `FilelayerError`, already collapsed through
24
+ * `toPublicError`, so the developer cannot accidentally return our internal
25
+ * deny reason to an attacker. That collapsing is a security-sensitive decision
26
+ * we make once, here, instead of asking the developer to make it per-route.
27
+ */
28
+ import { randomBytes, randomUUID } from 'node:crypto';
29
+ import { auditUnresolvedSecret, authorize, authorizeList, authorizeMembershipChange, authorizeOrg, authorizeRevoke, authorizeShare, schemaRefusal, toPublicError, } from "./authz.js";
30
+ import { CommitThenThrow, withTransaction, } from "./db.js";
31
+ import { PostgresStore, DEFAULT_PROJECT_ID, isUuid, toCapabilities, LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT, } from "./store.js";
32
+ import { canList, canPresign, collectStream, } from "./storage.js";
33
+ import { FilesApi, OrgsApi, SharesApi } from "./simple.js";
34
+ import { contentDisposition, deliveryHeaders, isActiveContentType, redirectHeaders, resolveRedirectConfig, safeContentType, } from "./delivery.js";
35
+ import { FilelayerError } from "./errors.js";
36
+ // Declared in `errors.ts` so that `delivery.ts` can classify errors without
37
+ // importing this module (which imports `delivery.ts`). Re-exported here because
38
+ // this is where every existing caller imports it from.
39
+ export { FilelayerError };
40
+ export class Filelayer {
41
+ store;
42
+ db;
43
+ storage;
44
+ opts;
45
+ _files;
46
+ _orgs;
47
+ _shares;
48
+ /** Null unless redirect delivery was configured AND acknowledged. */
49
+ redirect;
50
+ constructor(db, storage, opts = {}) {
51
+ this.db = db;
52
+ this.storage = storage;
53
+ this.opts = opts;
54
+ // A provider name is half of an object's primary identity (see the UNIQUE
55
+ // index). An adapter that does not supply one is a configuration error, not
56
+ // a default to guess at -- guessing is how it became 'memory' in the first
57
+ // place.
58
+ if (typeof storage.provider !== 'string' || storage.provider.length === 0) {
59
+ throw new Error('storage adapter must declare a non-empty `provider`');
60
+ }
61
+ this.store = new PostgresStore(db, {
62
+ ...(opts.projectId !== undefined ? { projectId: opts.projectId } : {}),
63
+ });
64
+ this.redirect = opts.redirectDelivery ? resolveRedirectConfig(opts.redirectDelivery) : null;
65
+ }
66
+ /**
67
+ * Run a unit of work in one transaction, on one connection.
68
+ *
69
+ * `fn` receives a store bound to the transaction, so the audit events the
70
+ * engine writes and the mutation they describe commit together -- and so
71
+ * `audit_append()`'s advisory lock, which is a `pg_advisory_XACT_lock`, is
72
+ * held across the whole unit rather than across one autocommit statement.
73
+ *
74
+ * THE ONE SUBTLETY, AND IT IS THE IMPORTANT ONE.
75
+ *
76
+ * A DENIAL writes an audit event and then throws. If a throw always rolled
77
+ * back we would lose exactly the events P5 exists to keep, silently, while
78
+ * the caller still saw their 403 -- an audit log that omits refusals is worse
79
+ * than no audit log, because it looks complete.
80
+ *
81
+ * So `FilelayerError` -- and ONLY `FilelayerError` -- is treated as a DECIDED
82
+ * outcome: commit, then throw. Every `FilelayerError` this library raises is
83
+ * a decision or a lookup miss, never a half-applied mutation; the one place
84
+ * that could have been (the schema attenuation backstop in `share()`) uses a
85
+ * SAVEPOINT so the failed INSERT is undone before the deny event is written.
86
+ * Anything else -- a driver error, an unanticipated constraint, a bug --
87
+ * rolls the whole unit back.
88
+ */
89
+ #transaction(fn) {
90
+ return withTransaction(this.db, async (tx) => {
91
+ try {
92
+ return await fn(tx, this.store.withDb(tx));
93
+ }
94
+ catch (err) {
95
+ if (err instanceof FilelayerError)
96
+ throw new CommitThenThrow(err);
97
+ throw err;
98
+ }
99
+ });
100
+ }
101
+ /**
102
+ * A throwaway, in-process instance: PGlite + in-memory bytes.
103
+ *
104
+ * For a five-minute first run and for tests. **Everything is lost when the
105
+ * process exits** -- there is no file on disk and no bucket. Production is
106
+ * `new Filelayer(pgPool, new S3Storage({...}), { baseUrl })`; see
107
+ * docs/QUICKSTART.md, which does not hide the three configuration steps.
108
+ */
109
+ static async quickstart(opts = {}) {
110
+ const { createTestDb } = await import("./db.js");
111
+ const { MemoryStorage } = await import("./storage.js");
112
+ const { db } = await createTestDb();
113
+ return new Filelayer(db, new MemoryStorage(), {
114
+ baseUrl: opts.baseUrl ?? 'http://localhost:3000',
115
+ });
116
+ }
117
+ /** Where public URLs are rooted. Read-only; set once at construction. */
118
+ get baseUrl() {
119
+ return this.opts.baseUrl;
120
+ }
121
+ /** The project this instance is bound to. Null means unscoped. */
122
+ get projectId() {
123
+ return this.store.projectId;
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // The tiered surface (see src/simple.ts). Purely additive: every method below
127
+ // this line is unchanged, and the facade calls into it rather than around it.
128
+ // ---------------------------------------------------------------------------
129
+ get files() {
130
+ return (this._files ??= new FilesApi(this));
131
+ }
132
+ get orgs() {
133
+ return (this._orgs ??= new OrgsApi(this));
134
+ }
135
+ get shares() {
136
+ return (this._shares ??= new SharesApi(this));
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // Tenancy
140
+ // ---------------------------------------------------------------------------
141
+ /**
142
+ * Create an organization, optionally with its first owner.
143
+ *
144
+ * Creating a tenant is a control-plane operation: there is no principal
145
+ * inside the system yet who could be authorized to do it, and pretending
146
+ * otherwise would be theatre. Passing `ownerActorId` closes the bootstrap
147
+ * gap that would otherwise exist -- an org is never memberless, so there is
148
+ * never a "the org has no members yet, let anyone in" path for an attacker to
149
+ * find. Every subsequent membership change is authorized (see `addMember`).
150
+ */
151
+ async createOrg(externalId, name, opts = {}) {
152
+ const { rows } = await this.db.query(`INSERT INTO org (project_id, external_id, name)
153
+ VALUES (coalesce($3::uuid, '${DEFAULT_PROJECT_ID}'::uuid), $1, $2) RETURNING id`, [externalId, name ?? null, this.projectId]);
154
+ const id = rows[0].id;
155
+ if (opts.ownerActorId) {
156
+ await this.db.query(`INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,'owner')`, [id, opts.ownerActorId]);
157
+ await this.store.audit({
158
+ orgId: id,
159
+ action: 'member.bootstrap',
160
+ decision: 'allow',
161
+ actorId: opts.ownerActorId,
162
+ fileId: null,
163
+ context: { targetActorId: opts.ownerActorId, toRole: 'owner', via: 'org.create' },
164
+ });
165
+ }
166
+ return { id };
167
+ }
168
+ async createActor(externalId) {
169
+ const { rows } = await this.db.query(`INSERT INTO actor (project_id, external_id)
170
+ VALUES (coalesce($2::uuid, '${DEFAULT_PROJECT_ID}'::uuid), $1) RETURNING id`, [externalId, this.projectId]);
171
+ return { id: rows[0].id };
172
+ }
173
+ /**
174
+ * Register a customer application. Control plane; see the note on the
175
+ * lifecycle methods below for why these three take no principal.
176
+ */
177
+ async createProject(key, name) {
178
+ const { rows } = await this.db.query(`INSERT INTO project (key, name) VALUES ($1, $2)
179
+ ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key
180
+ RETURNING id`, [key, name ?? null]);
181
+ return { id: rows[0].id };
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Lifecycle: soft delete and restore (P7)
185
+ // ---------------------------------------------------------------------------
186
+ //
187
+ // WHY THESE ARE CONTROL-PLANE OPERATIONS AND TAKE NO `Principal`.
188
+ //
189
+ // It is tempting to require `owner` in the org to delete it. That design has
190
+ // a trap in it: deleting an org kills membership-derived access (that is the
191
+ // whole point), so the moment it succeeds NOBODY holds a role in that org and
192
+ // therefore nobody can ever restore it. An authorization rule that makes its
193
+ // own inverse unreachable is not a rule, it is a one-way door.
194
+ //
195
+ // So org and actor lifecycle sits where org and actor CREATION already sits:
196
+ // the control plane, authenticated by the customer's project credential at
197
+ // the API boundary rather than by an end-user principal inside the model.
198
+ // That boundary sits above the engine and is the same one that
199
+ // authenticates every other request. This is stated as an explicit
200
+ // operational requirement in SEMANTICS.md rather than left implicit.
201
+ //
202
+ // Every one of them is audited to the affected tenant's chain, so a
203
+ // control-plane action is as visible in the compliance record as a user one.
204
+ /**
205
+ * Soft-delete a tenant. Every grant on every file in it is dead on the next
206
+ * request; every membership stops conferring anything. Nothing is erased and
207
+ * no row a retention hold protects is touched, so this cannot be used to
208
+ * defeat retention -- see SEMANTICS.md.
209
+ */
210
+ async softDeleteOrg(orgId) {
211
+ await this.#setOrgDeleted(orgId, true);
212
+ }
213
+ /** Exactly reverses `softDeleteOrg`. Liveness is derived, so nothing is lost. */
214
+ async restoreOrg(orgId) {
215
+ await this.#setOrgDeleted(orgId, false);
216
+ }
217
+ async #setOrgDeleted(orgId, deleted) {
218
+ if (!isUuid(orgId))
219
+ throw new FilelayerError(404, 'not_found');
220
+ const { rows } = await this.db.query(`UPDATE org SET deleted_at = ${deleted ? 'now()' : 'NULL'}
221
+ WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)
222
+ RETURNING id`, [orgId, this.projectId]);
223
+ if (!rows[0])
224
+ throw new FilelayerError(404, 'not_found');
225
+ await this.store.audit({
226
+ orgId,
227
+ action: deleted ? 'org.delete' : 'org.restore',
228
+ decision: 'allow',
229
+ actorId: null,
230
+ fileId: null,
231
+ context: { via: 'control_plane' },
232
+ });
233
+ }
234
+ /**
235
+ * Soft-delete an identity.
236
+ *
237
+ * Three things die at once, and all three are derived rather than written:
238
+ * their role-derived access, every grant issued TO them, and every grant they
239
+ * ISSUED. The third is the judgement call; the reasoning is in schema.sql at
240
+ * `grant_scope_is_live` and in SEMANTICS.md. It is loud on purpose: deleting
241
+ * a prolific sharer revokes a lot of links, and that is the correct reading of
242
+ * P4, not a side effect.
243
+ */
244
+ async softDeleteActor(actorId) {
245
+ await this.#setActorDeleted(actorId, true);
246
+ }
247
+ async restoreActor(actorId) {
248
+ await this.#setActorDeleted(actorId, false);
249
+ }
250
+ async #setActorDeleted(actorId, deleted) {
251
+ if (!isUuid(actorId))
252
+ throw new FilelayerError(404, 'not_found');
253
+ const { rows } = await this.db.query(`UPDATE actor SET deleted_at = ${deleted ? 'now()' : 'NULL'}
254
+ WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)
255
+ RETURNING id`, [actorId, this.projectId]);
256
+ if (!rows[0])
257
+ throw new FilelayerError(404, 'not_found');
258
+ // Attributed to every org the identity is a member of: "who lost access
259
+ // here, and when" must be answerable from each affected tenant's own chain.
260
+ const { rows: orgs } = await this.db.query(`SELECT org_id FROM membership WHERE actor_id = $1`, [actorId]);
261
+ for (const o of orgs) {
262
+ await this.store.audit({
263
+ orgId: o.org_id,
264
+ action: deleted ? 'actor.delete' : 'actor.restore',
265
+ decision: 'allow',
266
+ actorId,
267
+ fileId: null,
268
+ context: { via: 'control_plane', targetActorId: actorId },
269
+ });
270
+ }
271
+ }
272
+ /**
273
+ * Soft-delete a customer application: every org, every file and every grant
274
+ * inside it stops working immediately. This is the "we terminated that
275
+ * customer" operation and it is the widest blast radius in the system.
276
+ */
277
+ async softDeleteProject(projectId) {
278
+ await this.#setProjectDeleted(projectId, true);
279
+ }
280
+ async restoreProject(projectId) {
281
+ await this.#setProjectDeleted(projectId, false);
282
+ }
283
+ async #setProjectDeleted(projectId, deleted) {
284
+ if (!isUuid(projectId))
285
+ throw new FilelayerError(404, 'not_found');
286
+ const { rows } = await this.db.query(`UPDATE project SET deleted_at = ${deleted ? 'now()' : 'NULL'}
287
+ WHERE id = $1 RETURNING id`, [projectId]);
288
+ if (!rows[0])
289
+ throw new FilelayerError(404, 'not_found');
290
+ // The system chain: a project is above every tenant, so there is no single
291
+ // tenant to charge the event to, and writing it to all of them would let a
292
+ // control-plane action inflate an arbitrary number of customer chains.
293
+ await this.store.audit({
294
+ orgId: null,
295
+ action: deleted ? 'project.delete' : 'project.restore',
296
+ decision: 'allow',
297
+ actorId: null,
298
+ fileId: null,
299
+ context: { chain: 'system', projectId, via: 'control_plane' },
300
+ });
301
+ }
302
+ /**
303
+ * Add a member, or change an existing member's role.
304
+ *
305
+ * This used to take no principal at all. Anyone who could reach it could
306
+ * make anyone an owner of any org, and nothing was written to the audit log.
307
+ * Membership is the privilege that confers every other privilege, so it is
308
+ * now authorized by the same engine as everything else and audited on every
309
+ * outcome.
310
+ */
311
+ async addMember(principal, orgId, actorId, role) {
312
+ const decision = await authorizeMembershipChange(this.store, principal, orgId, actorId, role);
313
+ this.#raise(decision);
314
+ await this.db.query(`INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,$3)
315
+ ON CONFLICT (org_id, actor_id) DO UPDATE SET role = EXCLUDED.role`, [orgId, actorId, role]);
316
+ }
317
+ async removeMember(principal, orgId, actorId) {
318
+ const decision = await authorizeMembershipChange(this.store, principal, orgId, actorId, null);
319
+ this.#raise(decision);
320
+ await this.db.query(`DELETE FROM membership WHERE org_id = $1 AND actor_id = $2`, [
321
+ orgId,
322
+ actorId,
323
+ ]);
324
+ }
325
+ // ---------------------------------------------------------------------------
326
+ // Files
327
+ // ---------------------------------------------------------------------------
328
+ /**
329
+ * Creation is the one file operation with no file to authorize against, so
330
+ * the question is org-scoped: does this actor hold `create_file` in this org?
331
+ * That is now asked of the engine rather than answered here.
332
+ *
333
+ * SIGNATURE CHANGE. This used to take a bare
334
+ * `actorId: string` while every other method took a `Principal`. That
335
+ * inconsistency was itself the defect: at the one call site in the example the
336
+ * developer passed `b.uploaderId` -- a value out of the REQUEST BODY -- rather
337
+ * than the authenticated actor, because the parameter's type did not tell them
338
+ * which one it wanted. Files are private-by-default AND owner-readable, so
339
+ * forging `owner_id` hands the wrong person permanent read access to the
340
+ * document, silently. A `Principal` is not confusable with a request field.
341
+ */
342
+ async upload(principal, orgId, input) {
343
+ const actorId = principal.actorId;
344
+ // DELIBERATELY OUTSIDE THE TRANSACTION, and the reason is the storage write
345
+ // that has to happen between this and the INSERT.
346
+ //
347
+ // With `emitAllow: false` this call writes AT MOST ONE STATEMENT: an audit
348
+ // event on the deny path, which `audit_append()` already makes atomic on its
349
+ // own. There is no mutation for it to be atomic *with*. Wrapping it would
350
+ // mean either holding a database connection open across the whole object
351
+ // upload -- minutes, for a large file, on a pooled connection -- or opening a
352
+ // second transaction anyway. The allow event is emitted below, inside the
353
+ // transaction, carrying the file id.
354
+ const decision = await authorizeOrg(this.store, principal, orgId, 'create_file', {
355
+ action: 'file.create',
356
+ emitAllow: false, // the allow event is emitted below, with the file id on it
357
+ });
358
+ this.#raise(decision);
359
+ // `authorizeOrg` denies an anonymous principal before we get here, so the
360
+ // uploader is known. The engine is the thing that established that, which
361
+ // is the point: ownership is derived from the authorized identity and can
362
+ // no longer be supplied alongside it.
363
+ const uploaderId = actorId;
364
+ const id = randomUUID();
365
+ const storageKey = `${orgId}/${id}`;
366
+ const now = Date.now();
367
+ const expiresAt = input.expiresIn ? new Date(now + input.expiresIn * 1000) : null;
368
+ const retainUntil = input.retainFor ? new Date(now + input.retainFor * 1000) : null;
369
+ const visibility = input.visibility ?? 'private';
370
+ // ORDERING: BYTES FIRST, METADATA SECOND. See the long note in db.ts.
371
+ //
372
+ // The storage write cannot join the transaction, so one of the two possible
373
+ // orderings has to lose. Committing metadata first and crashing would leave
374
+ // a 'ready' file whose object does not exist -- permanent, customer-visible
375
+ // data loss on a row the customer can see in a listing. Writing bytes first
376
+ // and crashing leaves an object no row points at: unreachable (the key is a
377
+ // fresh UUID, never reissued, and every read path starts from a `file` row)
378
+ // and therefore purely a storage cost. That is the cheaper failure and it is
379
+ // the one we take. `collectStorageOrphans()` cleans up; running it is a
380
+ // required operational job, not an optional one.
381
+ //
382
+ // It also means the adapter, not the caller, reports how many bytes exist.
383
+ const put = await this.storage.put(storageKey, input.body, input.contentType, {
384
+ ...(input.size !== undefined ? { contentLength: input.size } : {}),
385
+ });
386
+ return this.#transaction(async (tx, store) => {
387
+ const { rows } = await tx.query(`INSERT INTO file
388
+ (id, org_id, owner_id, name, content_type, size_bytes, storage_provider,
389
+ storage_key, state, visibility, expires_at, retain_until, metadata)
390
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'ready',$9,$10,$11,$12::jsonb)
391
+ RETURNING id, org_id, owner_id, name, content_type, size_bytes,
392
+ storage_provider, storage_key, state, visibility, expires_at,
393
+ retain_until, created_at`, [
394
+ id,
395
+ orgId,
396
+ uploaderId,
397
+ input.name,
398
+ input.contentType,
399
+ // What was actually written, not what the caller claimed.
400
+ put.bytes,
401
+ // THE FIX. This was the literal 'memory'.
402
+ this.storage.provider,
403
+ storageKey,
404
+ visibility,
405
+ expiresAt?.toISOString() ?? null,
406
+ retainUntil?.toISOString() ?? null,
407
+ JSON.stringify(input.metadata ?? {}),
408
+ ]);
409
+ // Same transaction as the INSERT it describes. Before this, a failure
410
+ // between the two produced a file with no audit record -- in a product
411
+ // whose headline is a tamper-evident audit trail, an intact chain that
412
+ // simply does not mention the upload.
413
+ await store.audit({
414
+ orgId,
415
+ action: 'file.create',
416
+ decision: 'allow',
417
+ actorId: uploaderId,
418
+ fileId: id,
419
+ context: { visibility, storageProvider: this.storage.provider },
420
+ });
421
+ await store.recordUsage(orgId, 'write', put.bytes);
422
+ // Metering counts distinct FILE-OWNING USERS as well as bytes, because
423
+ // authorization load tracks people rather than volume. The write path is
424
+ // the only place a new owner can appear, so it is the only place this can
425
+ // be recorded.
426
+ await store.recordFileOwner(orgId, uploaderId);
427
+ return toFileRecord(rows[0]);
428
+ });
429
+ }
430
+ /**
431
+ * Read a file's bytes, WITH the headers required to serve them safely.
432
+ *
433
+ * The `headers` field closes a header-handling defect. Before it, this method returned a
434
+ * `Uint8Array` and the application decided what `Content-Type`,
435
+ * `Content-Disposition`, `X-Content-Type-Options` and `Cache-Control` to put
436
+ * on the response -- three security-sensitive decisions the library was
437
+ * handing back to the developer while claiming to have removed them, and
438
+ * which the example got wrong. They are computed here now, from the file
439
+ * record, with no option to disable them. See `delivery.ts`.
440
+ *
441
+ * This path now CHARGES the download cap. See `deliver()` below for the
442
+ * semantics and the reasoning.
443
+ */
444
+ async read(principal, fileId, opts = {}) {
445
+ // The buffered convenience form. It is `readStream()` plus a collect, so
446
+ // there is exactly one authorization path, one reservation and one audit
447
+ // event whichever form a caller uses. It FORCES proxy mode: a buffered read
448
+ // of a redirect is a contradiction, and silently fetching the presigned URL
449
+ // ourselves would spend the redirect's egress budget AND the proxy's.
450
+ const d = await this.readStream(principal, fileId, { ...opts, mode: 'proxy' });
451
+ if (d.mode !== 'proxy')
452
+ throw new FilelayerError(500, 'internal', 'unexpected_redirect');
453
+ const body = await collectStream(d.body);
454
+ return {
455
+ file: d.file,
456
+ body,
457
+ headers: d.headers,
458
+ ...(d.grantId ? { grantId: d.grantId } : {}),
459
+ remainingDownloads: d.remainingDownloads,
460
+ };
461
+ }
462
+ /**
463
+ * The streaming read. Same decision, same charge, same audit -- no buffer.
464
+ *
465
+ * Returns either a `ProxyDelivery` (bytes, as a stream) or, when redirect
466
+ * delivery is configured AND this delivery is eligible, a `RedirectDelivery`
467
+ * (a 302 to a short-lived presigned URL). The mode is on the returned object
468
+ * and in the audit log; nothing about it is implicit.
469
+ */
470
+ async readStream(principal, fileId,
471
+ /**
472
+ * `mode` defaults to 'auto', which means "apply this instance's redirect
473
+ * policy". On an instance that has not configured `redirectDelivery` -- the
474
+ * default -- that policy is "never redirect", so 'auto' and 'proxy' are the
475
+ * same thing and nothing becomes cacheable that was not before. Pass
476
+ * 'proxy' to force proxying on an instance that HAS opted in.
477
+ */
478
+ opts = {}) {
479
+ // THE DECISION AND THE CHARGE, IN ONE TRANSACTION.
480
+ //
481
+ // `authorize()` writes the access event and `consumeDownload()` spends the
482
+ // cap. Those two were separate autocommit statements, so a crash between
483
+ // them left an allow event for a delivery that was never charged, or -- on
484
+ // the redeem path -- a charge with no event. They now commit together.
485
+ const reserved = await this.#transaction(async (tx, store) => {
486
+ const decision = await authorize(store, principal, fileId, 'read');
487
+ this.#raise(decision);
488
+ return this.#reserve(tx, store, fileId, decision, principal, opts);
489
+ });
490
+ return this.#fetchDelivery(reserved, opts);
491
+ }
492
+ /**
493
+ * Metadata without bytes, authorized exactly like `read`.
494
+ *
495
+ * This exists because `getFileRecord()` used to be public and took no
496
+ * principal -- see the note on it below. Callers that wanted a file's
497
+ * metadata had an unauthorized way to get it; now they have an authorized one.
498
+ *
499
+ * It does NOT charge the download cap, and that asymmetry is the whole point
500
+ * of the delivery-cap rule: the cap counts BYTES LEAVING, and `stat` delivers
501
+ * none.
502
+ */
503
+ async stat(principal, fileId) {
504
+ return this.#transaction(async (tx, store) => {
505
+ const decision = await authorize(store, principal, fileId, 'read');
506
+ this.#raise(decision);
507
+ const file = await getFileRecord(tx, this.projectId, fileId);
508
+ if (!file)
509
+ throw new FilelayerError(404, 'not_found');
510
+ return file;
511
+ });
512
+ }
513
+ /**
514
+ * THE ONE PLACE BYTES LEAVE THE SYSTEM -- and therefore the one place the
515
+ * download cap is charged (P6).
516
+ *
517
+ * THE DEFECT. `max_downloads` was charged only by `redeem()`, the share-link
518
+ * path. An ACTOR grant carrying `maxDownloads: 3` permitted unlimited direct
519
+ * `read()` calls, because nothing on that path touched the counter. So the
520
+ * field meant "link redemptions" on one path and "nothing at all" on another,
521
+ * while being named, documented and billed as a download cap. In practice the
522
+ * direct path is the COMMON one -- the SDK calls `read()` --
523
+ * so the dimension was a lie on the path most customers use.
524
+ *
525
+ * THE DECISION, of the three that were on the table:
526
+ *
527
+ * (a) rename it `maxRedemptions`. Rejected: it would still be settable on
528
+ * an actor grant, where it would then mean nothing, so the ambiguity
529
+ * moves rather than closes.
530
+ * (b) refuse `maxDownloads` on non-link grants. Rejected: "you may read
531
+ * this three times" is a thing customers legitimately want to say about
532
+ * a named person, and refusing it removes a capability to avoid
533
+ * defining one.
534
+ * (c) CHARGE ON EVERY DELIVERY. Chosen. A cap of 3 means the bytes leave at
535
+ * most 3 times, through any path, by any principal, at any delegation
536
+ * depth. It is the reading a customer already has, it is the only one
537
+ * that is true on every path, and it makes the cap enforceable rather
538
+ * than advisory.
539
+ *
540
+ * THE RULE, precisely: a delivery is charged when, and only when, the
541
+ * authorization decision was reached VIA A GRANT. Authority from an org role
542
+ * is not a metered credential and is not charged -- an admin doing their job
543
+ * must not silently burn a contractor's link budget. `authorize()` alone does
544
+ * not charge (it is a decision, not a delivery) and neither does `stat()`.
545
+ *
546
+ * ORDERING: reserve BEFORE fetching bytes, exactly as `redeem()` does. The
547
+ * reservation is the write (P6), so two concurrent deliveries against a cap
548
+ * of 1 yield one delivery; doing it the other way round would let both read
549
+ * the object and only then discover one of them was over budget. A storage
550
+ * failure after a successful reservation therefore still spends a download.
551
+ * That is the fail-closed direction and it is deliberate.
552
+ *
553
+ * KNOWN COST, recorded rather than hidden. `consume_download` runs even
554
+ * when no grant in the chain carries a cap, because `download_count` is also
555
+ * the answer to "how many times has this link been downloaded", which
556
+ * `listGrants` reports and a compliance screen asks for. That makes every
557
+ * grant-authorized delivery a row UPDATE holding a row lock -- and for a
558
+ * TIER-1 PUBLIC ASSET, where one anonymous grant row serves every request,
559
+ * that single row becomes a write hotspot under load. It is a scalability
560
+ * problem, not a correctness one, and the fix (skip the write when no
561
+ * ancestor has a cap, and meter deliveries elsewhere) trades away the
562
+ * per-grant download count. Not taken here because that count is a shipped
563
+ * feature; flagged so the trade is made deliberately when volume forces it.
564
+ */
565
+ async #reserve(tx, store, fileId, decision, principal, opts) {
566
+ const grantId = decision.grantId ?? null;
567
+ let remainingDownloads = null;
568
+ if (grantId !== null) {
569
+ const consumed = await store.consumeDownload(grantId);
570
+ if (!consumed.granted) {
571
+ // Reachable only when the cap is hit between the decision and the
572
+ // reservation. The engine records the ordinary case; this records the
573
+ // race, so the two cannot silently become one.
574
+ const file = await getFileRecord(tx, this.projectId, fileId);
575
+ await store.audit({
576
+ orgId: file?.orgId ?? null,
577
+ action: 'file.read',
578
+ decision: 'deny',
579
+ reason: 'grant_exhausted',
580
+ actorId: principal.actorId,
581
+ fileId,
582
+ grantId,
583
+ ...(principal.ip !== undefined ? { ip: principal.ip } : {}),
584
+ context: { race: true, ...(file ? {} : { chain: 'system' }) },
585
+ });
586
+ throw new FilelayerError(404, 'not_found', 'grant_exhausted');
587
+ }
588
+ remainingDownloads = consumed.remaining;
589
+ }
590
+ const file = await getFileRecord(tx, this.projectId, fileId);
591
+ if (!file)
592
+ throw new FilelayerError(404, 'not_found');
593
+ const headers = deliveryHeaders(file, opts);
594
+ const mode = this.#redirectEligible(decision, opts.mode ?? 'auto');
595
+ if (mode === 'proxy') {
596
+ return { kind: 'proxy', file, headers, remainingDownloads, grantId };
597
+ }
598
+ // The presigned URL is minted INSIDE the transaction, before the audit
599
+ // event that records it. `presignGet` for the S3 adapter is local HMAC with
600
+ // no I/O; an adapter for which that is not true must still keep it cheap,
601
+ // because it sits inside an open transaction. Minting first means we never
602
+ // audit a redirect we then failed to produce.
603
+ const redirect = this.redirect;
604
+ const url = await this.storage.presignGet(file.storageKey, {
605
+ expiresInSeconds: redirect.ttlSeconds,
606
+ // Pin the SAME neutralised type and disposition the proxied path would
607
+ // have sent, so a redirect cannot be a way to lose them.
608
+ responseContentType: headers['content-type'],
609
+ responseContentDisposition: headers['content-disposition'],
610
+ });
611
+ const expiresAt = new Date(Date.now() + redirect.ttlSeconds * 1000);
612
+ // THE EVENT THAT MAKES THE MODE AUDITABLE. Written only for redirects, in
613
+ // the same transaction as the reservation. A compliance auditor asking "which
614
+ // deliveries left our control?" filters `action = 'file.deliver'`; every
615
+ // other delivery was proxied.
616
+ await store.audit({
617
+ orgId: file.orgId,
618
+ action: 'file.deliver',
619
+ decision: 'allow',
620
+ actorId: principal.actorId,
621
+ fileId,
622
+ grantId,
623
+ ...(principal.ip !== undefined ? { ip: principal.ip } : {}),
624
+ ...(principal.userAgent !== undefined ? { userAgent: principal.userAgent } : {}),
625
+ context: {
626
+ mode: 'redirect',
627
+ via: decision.via,
628
+ ttlSeconds: redirect.ttlSeconds,
629
+ // The number a compliance document quotes. Spelled out rather than
630
+ // derived, so it survives a change to how the TTL is computed.
631
+ revocationWindowSeconds: redirect.ttlSeconds,
632
+ expiresAt: expiresAt.toISOString(),
633
+ cacheable: decision.via === 'grant:anonymous',
634
+ },
635
+ });
636
+ return {
637
+ kind: 'redirect',
638
+ file,
639
+ headers,
640
+ remainingDownloads,
641
+ grantId,
642
+ url,
643
+ expiresAt,
644
+ ttlSeconds: redirect.ttlSeconds,
645
+ cacheable: decision.via === 'grant:anonymous',
646
+ };
647
+ }
648
+ /**
649
+ * Is this delivery allowed to be a redirect?
650
+ *
651
+ * Four conditions, all required, and the default answer is no:
652
+ * 1. the caller asked for 'auto' (routes default to 'proxy');
653
+ * 2. redirect delivery is configured -- which required the acknowledgement;
654
+ * 3. the adapter can actually mint a presigned URL;
655
+ * 4. the authority came from an ANONYMOUS grant, unless the scope was
656
+ * explicitly widened to 'all-grants'.
657
+ *
658
+ * Condition 4 is the one that matters. `via` is the engine's own account of
659
+ * where the authority came from, so "public" here means "the customer
660
+ * published this file", not "the request looked public".
661
+ */
662
+ #redirectEligible(decision, requested) {
663
+ if (requested !== 'auto')
664
+ return 'proxy';
665
+ if (this.redirect === null)
666
+ return 'proxy';
667
+ if (!canPresign(this.storage))
668
+ return 'proxy';
669
+ if (this.redirect.scope === 'anonymous-grants-only' && decision.via !== 'grant:anonymous') {
670
+ return 'proxy';
671
+ }
672
+ return 'redirect';
673
+ }
674
+ /**
675
+ * Turn a committed reservation into bytes (or a 302).
676
+ *
677
+ * Deliberately OUTSIDE the transaction. Fetching an object can take minutes;
678
+ * holding a database connection open for that is how a pool dies. It also
679
+ * preserves the documented P6 property that a storage failure after a
680
+ * successful reservation still spends a download -- the reservation is
681
+ * already committed, so nothing can give it back.
682
+ */
683
+ async #fetchDelivery(r, opts) {
684
+ if (r.kind === 'redirect') {
685
+ const d = {
686
+ mode: 'redirect',
687
+ file: r.file,
688
+ status: 302,
689
+ url: r.url,
690
+ expiresAt: r.expiresAt,
691
+ revocationWindowSeconds: r.ttlSeconds,
692
+ headers: redirectHeaders(r.url, { ttlSeconds: r.ttlSeconds, cacheable: r.cacheable }),
693
+ remainingDownloads: r.remainingDownloads,
694
+ ...(r.grantId ? { grantId: r.grantId } : {}),
695
+ };
696
+ return d;
697
+ }
698
+ const obj = await this.storage.stream(r.file.storageKey, {
699
+ ...(opts.range ? { range: opts.range } : {}),
700
+ });
701
+ if (!obj)
702
+ throw new FilelayerError(404, 'not_found');
703
+ // Metering is deliberately outside the transaction and best-effort: it is
704
+ // not a decision, and a metering failure must not fail a delivery the
705
+ // system already authorized, charged and audited.
706
+ const bytes = obj.size ?? r.file.sizeBytes ?? 0;
707
+ await this.store.recordUsage(r.file.orgId, 'read', bytes).catch(() => { });
708
+ const headers = { ...r.headers };
709
+ // Trust the store's length over the column: a `size_bytes` that disagrees
710
+ // with the object truncates or hangs the response.
711
+ if (obj.size !== null)
712
+ headers['content-length'] = String(obj.size);
713
+ else
714
+ delete headers['content-length'];
715
+ if (obj.range) {
716
+ headers['content-range'] = `bytes ${obj.range.start}-${obj.range.end}/${obj.range.total}`;
717
+ headers['accept-ranges'] = 'bytes';
718
+ }
719
+ const d = {
720
+ mode: 'proxy',
721
+ file: r.file,
722
+ headers,
723
+ body: obj.body,
724
+ bytes: obj.size,
725
+ remainingDownloads: r.remainingDownloads,
726
+ ...(r.grantId ? { grantId: r.grantId } : {}),
727
+ };
728
+ return d;
729
+ }
730
+ // ---------------------------------------------------------------------------
731
+ // Listing -- the authorized query surface
732
+ // ---------------------------------------------------------------------------
733
+ /**
734
+ * Which files in this org may this principal `capability`?
735
+ *
736
+ * This is the primitive that was missing, and its absence was
737
+ * the reason the "0 authorization lines" claim survived: the example simply
738
+ * did not have a listing screen, and building one meant hand-rolling the org
739
+ * filter, the visibility rule, the owner check, the role check and the union
740
+ * over `file_grant` in application SQL.
741
+ *
742
+ * WHY IT CANNOT BE GOT WRONG.
743
+ *
744
+ * - There is no filter parameter. The signature takes a principal, an org,
745
+ * a capability, a page size and an opaque cursor. There is nothing here to
746
+ * forget to pass and nothing that widens the result set.
747
+ * - The predicate is generated from the same role table and the same
748
+ * lifecycle gate `authorize()` uses (see `listPredicate` in authz.ts), so
749
+ * the two cannot drift by editing one of them.
750
+ * - `test/listing.test.ts` asserts set equality against `authorize()` over a
751
+ * randomized corpus, on every capability, on every run.
752
+ *
753
+ * WHAT IT COSTS. One SQL query and one audit event, independent of page size.
754
+ * A per-file `authorize()` loop would be 4 round trips x N.
755
+ *
756
+ * The empty page is a valid answer: a caller with no standing sees nothing,
757
+ * and so does a caller naming an org that does not exist. Neither is an error,
758
+ * because distinguishing them would rebuild the existence oracle.
759
+ */
760
+ async listFiles(principal, orgId, opts = {}) {
761
+ // A link secret is a bearer credential for exactly one file. Refused rather
762
+ // than ignored: a silently-dropped credential is how a caller ends up
763
+ // believing they listed something they did not.
764
+ if (principal.linkSecret !== undefined) {
765
+ throw new FilelayerError(400, 'link_principal_cannot_list', 'link_principal_cannot_list');
766
+ }
767
+ const capability = opts.capability ?? 'read';
768
+ const limit = Math.max(1, Math.min(opts.limit ?? LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT));
769
+ const { files, hasMore } = await authorizeList(this.store, principal, orgId, {
770
+ capability,
771
+ limit,
772
+ cursor: decodeCursor(opts.cursor),
773
+ });
774
+ const last = files[files.length - 1];
775
+ return {
776
+ // `ListedFile` and `FileRecord` are the same shape; the store returns the
777
+ // columns `getFileRecord` returns, so nothing is re-fetched per row.
778
+ files: files,
779
+ nextCursor: hasMore && last ? encodeCursor(last.createdAt, last.id) : null,
780
+ };
781
+ }
782
+ /**
783
+ * ORDERING, and it is the mirror image of `upload()`.
784
+ *
785
+ * The metadata delete COMMITS FIRST -- the decision, the audit event the
786
+ * engine wrote for it, and the state change, all in one transaction -- and
787
+ * only then are the bytes removed. A crash in between leaves an object no row
788
+ * points at, which is an orphan and therefore a garbage-collection problem.
789
+ * The other ordering would leave a live, listable, authorizable `file` row
790
+ * whose object is gone, which is data loss.
791
+ *
792
+ * The bytes are removed OUTSIDE the transaction for the same reason they are
793
+ * written outside it: object storage cannot roll back, so including it would
794
+ * mean a rolled-back transaction had already destroyed the object.
795
+ */
796
+ async delete(principal, fileId) {
797
+ const file = await this.#transaction(async (tx, store) => {
798
+ const decision = await authorize(store, principal, fileId, 'delete');
799
+ this.#raise(decision);
800
+ const f = await getFileRecord(tx, this.projectId, fileId);
801
+ if (!f)
802
+ throw new FilelayerError(404, 'not_found');
803
+ await tx.query(`UPDATE file SET state = 'deleted', deleted_at = now(), updated_at = now()
804
+ WHERE id = $1`, [fileId]);
805
+ return f;
806
+ });
807
+ await this.storage.delete(file.storageKey);
808
+ }
809
+ /**
810
+ * COLLECT ORPHANED OBJECTS. A REQUIRED OPERATIONAL JOB.
811
+ *
812
+ * An orphan is an object in the store with no `file` row pointing at
813
+ * (provider, key). Two things produce them, both of them by design:
814
+ *
815
+ * - a crash between `storage.put()` and the metadata commit in `upload()`;
816
+ * - a crash between the metadata commit and `storage.delete()` in
817
+ * `delete()`.
818
+ *
819
+ * Neither is a correctness problem -- an orphan is unreachable, because every
820
+ * read path in the system starts from a `file` row, and keys are fresh UUIDs
821
+ * that are never reissued -- but both cost money, and an uncollected orphan
822
+ * from a delete is a compliance problem: the customer was told the bytes were
823
+ * gone.
824
+ *
825
+ * WHAT MAKES THIS SAFE. Two things, and they are both load-bearing:
826
+ *
827
+ * 1. `olderThanSeconds` (default 1 hour, minimum 60s). An object written
828
+ * seconds ago may belong to an upload whose transaction has not committed
829
+ * yet. Deleting it would turn a successful upload into permanent data
830
+ * loss -- the exact failure this whole ordering exists to avoid. The grace
831
+ * period must exceed the longest plausible upload-plus-commit.
832
+ * 2. The `file` lookup is by (storage_provider, storage_key), the pair the
833
+ * UNIQUE index is on, and it is NOT project-scoped and NOT filtered on
834
+ * `deleted_at`. A soft-deleted file whose bytes were never removed still
835
+ * has a row; this job must not race the delete path into removing bytes a
836
+ * retention hold is protecting. It only removes what NOTHING references.
837
+ *
838
+ * Control plane: it takes no principal for the same reason the other
839
+ * lifecycle operations do not (see above). `dryRun` is the default.
840
+ */
841
+ async collectStorageOrphans(opts = {}) {
842
+ if (!canList(this.storage)) {
843
+ throw new FilelayerError(500, 'storage_cannot_list', 'orphan collection needs a storage adapter that implements list()');
844
+ }
845
+ const grace = Math.max(60, opts.olderThanSeconds ?? 3600) * 1000;
846
+ const limit = Math.max(1, Math.min(opts.limit ?? 1000, 10_000));
847
+ const cutoff = Date.now() - grace;
848
+ const dryRun = opts.dryRun ?? true;
849
+ let cursor = null;
850
+ let scanned = 0;
851
+ const orphans = [];
852
+ let truncated = false;
853
+ do {
854
+ const page = await this.storage.list(opts.prefix ?? '', {
855
+ limit: Math.min(1000, limit),
856
+ cursor,
857
+ });
858
+ cursor = page.cursor;
859
+ for (const e of page.entries) {
860
+ scanned++;
861
+ // No timestamp means we cannot prove it is old. Fail closed: skip it.
862
+ if (e.lastModified === null || e.lastModified.getTime() > cutoff)
863
+ continue;
864
+ const { rows } = await this.db.query(`SELECT 1 FROM file WHERE storage_provider = $1 AND storage_key = $2`, [this.storage.provider, e.key]);
865
+ if (rows.length > 0)
866
+ continue;
867
+ orphans.push(e.key);
868
+ if (orphans.length >= limit) {
869
+ truncated = true;
870
+ break;
871
+ }
872
+ }
873
+ } while (cursor !== null && !truncated);
874
+ let deleted = 0;
875
+ if (!dryRun) {
876
+ for (const key of orphans) {
877
+ await this.storage.delete(key);
878
+ deleted++;
879
+ }
880
+ await this.store.audit({
881
+ orgId: null,
882
+ action: 'storage.gc',
883
+ decision: 'allow',
884
+ actorId: null,
885
+ fileId: null,
886
+ context: {
887
+ chain: 'system',
888
+ provider: this.storage.provider,
889
+ scanned,
890
+ deleted,
891
+ via: 'control_plane',
892
+ },
893
+ });
894
+ }
895
+ return { scanned, orphans, deleted, truncated };
896
+ }
897
+ // ---------------------------------------------------------------------------
898
+ // Grants
899
+ // ---------------------------------------------------------------------------
900
+ async share(principal, fileId, input) {
901
+ const capabilities = input.capabilities ?? ['read'];
902
+ // ONE TRANSACTION: the decision, the grant row, and the audit event that
903
+ // records both. Previously these were three autocommit statements, so a
904
+ // failure in the middle could leave a live grant that the audit log has no
905
+ // record of anyone creating -- a grant with no provenance, which for a
906
+ // capability system is the worst possible row to be missing.
907
+ return this.#transaction(async (tx, store) => {
908
+ // One call, two questions: may you share, and is what you are handing out a
909
+ // subset of what you hold? Both are answered by the engine. The
910
+ // engine also tells us which grant your authority came from, which becomes
911
+ // this grant's parent and is what makes revocation transitive.
912
+ // The subject type is part of the authorization question, not a detail of
913
+ // the row: I6 says a grant-derived issuer may not widen the population.
914
+ // Passing it here is what lets the ENGINE refuse -- with a reason and an
915
+ // audit event -- rather than leaving the trigger to raise at INSERT time.
916
+ const decision = await authorizeShare(store, principal, fileId, capabilities, {
917
+ subjectType: input.subject.type,
918
+ });
919
+ if (!decision.allow) {
920
+ const pub = toPublicError(decision.reason);
921
+ throw new FilelayerError(pub.status, pub.code, decision.reason);
922
+ }
923
+ const file = await getFileRecord(tx, this.projectId, fileId);
924
+ if (!file)
925
+ throw new FilelayerError(404, 'not_found');
926
+ const expiresAt = input.expiresIn ? new Date(Date.now() + input.expiresIn * 1000) : null;
927
+ let secret;
928
+ let secretHash = null;
929
+ let subjectId = null;
930
+ let subjectOrgId = null;
931
+ let subjectMinRole = null;
932
+ if (input.subject.type === 'link') {
933
+ // 256 bits from the CSPRNG. Base64url so it survives a URL path segment.
934
+ secret = randomBytes(32).toString('base64url');
935
+ secretHash = await this.store.hashSecret(secret);
936
+ }
937
+ else if (input.subject.type === 'actor') {
938
+ subjectId = input.subject.actorId;
939
+ }
940
+ else if (input.subject.type === 'org' || input.subject.type === 'role') {
941
+ // I1/P8. The composite FK already makes a cross-PROJECT subject org
942
+ // unrepresentable; this resolves it first so the caller gets the same
943
+ // uniform 404 they get for any other id they may not name, instead of a
944
+ // foreign-key violation that would confirm the id exists somewhere. An
945
+ // org in another project, an org that does not exist, and a malformed id
946
+ // are one answer -- the same symmetry the file paths keep.
947
+ subjectOrgId = await this.#resolveSubjectOrg(tx, input.subject.orgId);
948
+ if (input.subject.type === 'role')
949
+ subjectMinRole = input.subject.minRole;
950
+ }
951
+ const passwordHash = input.password ? await this.store.hashPassword(input.password) : null;
952
+ // org_id is taken from the FILE, never from the caller. The composite FK
953
+ // (file_id, org_id) -> file(id, org_id) makes a mismatch unrepresentable,
954
+ // but taking it from the caller at all would be an invitation.
955
+ //
956
+ // The RETURNING clause reads back expires_at and max_downloads because the
957
+ // attenuation trigger may have tightened them against the parent grant. We
958
+ // report what was actually stored, not what was asked for.
959
+ //
960
+ // THE SAVEPOINT IS NOT OPTIONAL. A statement that RAISES inside a Postgres
961
+ // transaction aborts the whole transaction: every subsequent statement
962
+ // fails with "current transaction is aborted". The attenuation trigger
963
+ // raising is precisely the case where we must keep going, because the
964
+ // refusal has to be AUDITED. Without the savepoint the audit write below
965
+ // would itself fail and the refusal would vanish -- the transaction work
966
+ // would have silently deleted a security event.
967
+ let rows;
968
+ try {
969
+ ({ rows } = await tx.savepoint(() => tx.query(`INSERT INTO file_grant
970
+ (file_id, org_id, parent_grant_id, subject_type, subject_id, subject_org_id,
971
+ subject_min_role, capabilities,
972
+ secret_hash, password_hash, expires_at, max_downloads, created_by)
973
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::grant_capability[],$9,$10,$11,$12,$13)
974
+ RETURNING id, expires_at, max_downloads`, [
975
+ fileId,
976
+ file.orgId,
977
+ decision.parentGrantId,
978
+ input.subject.type,
979
+ subjectId,
980
+ subjectOrgId,
981
+ subjectMinRole,
982
+ pgArrayLiteral(capabilities),
983
+ secretHash,
984
+ passwordHash,
985
+ expiresAt?.toISOString() ?? null,
986
+ input.maxDownloads ?? null,
987
+ principal.actorId,
988
+ ])));
989
+ }
990
+ catch (err) {
991
+ // The schema is the backstop for attenuation; if it fires, it has caught
992
+ // something the engine let through.
993
+ const refusal = schemaRefusal(err);
994
+ if (!refusal)
995
+ throw err;
996
+ await store.audit({
997
+ orgId: file.orgId,
998
+ action: 'grant.create',
999
+ decision: 'deny',
1000
+ reason: refusal,
1001
+ actorId: principal.actorId,
1002
+ fileId,
1003
+ grantId: decision.parentGrantId,
1004
+ context: { capabilities, subjectType: input.subject.type },
1005
+ });
1006
+ throw new FilelayerError(403, 'forbidden', refusal);
1007
+ }
1008
+ const grantId = rows[0].id;
1009
+ const effectiveExpiry = rows[0].expires_at ? new Date(rows[0].expires_at) : null;
1010
+ const effectiveCap = rows[0].max_downloads ?? null;
1011
+ await store.audit({
1012
+ orgId: file.orgId,
1013
+ action: 'grant.create',
1014
+ decision: 'allow',
1015
+ actorId: principal.actorId,
1016
+ fileId,
1017
+ grantId,
1018
+ context: {
1019
+ subjectType: input.subject.type,
1020
+ ...(subjectOrgId ? { subjectOrgId } : {}),
1021
+ ...(subjectMinRole ? { subjectMinRole } : {}),
1022
+ capabilities,
1023
+ parentGrantId: decision.parentGrantId,
1024
+ expiresAt: effectiveExpiry?.toISOString() ?? null,
1025
+ maxDownloads: effectiveCap,
1026
+ },
1027
+ });
1028
+ return {
1029
+ grantId,
1030
+ ...(secret ? { secret } : {}),
1031
+ ...(secret && this.opts.baseUrl ? { url: `${this.opts.baseUrl}/d/${secret}` } : {}),
1032
+ expiresAt: effectiveExpiry,
1033
+ maxDownloads: effectiveCap,
1034
+ parentGrantId: decision.parentGrantId,
1035
+ };
1036
+ });
1037
+ }
1038
+ /**
1039
+ * Resolve the org named by an `org` / `role` grant subject (RFC-001, I1).
1040
+ *
1041
+ * Three things have to be true and only one of them is about convenience:
1042
+ *
1043
+ * - the org must EXIST and not be soft-deleted (a grant naming a dead tenant
1044
+ * would be born non-live anyway -- see `grant_scope_is_live` -- so minting
1045
+ * one is a caller error worth reporting);
1046
+ * - it must be in THIS instance's project. That is the P8 boundary, and it
1047
+ * is the thing that makes a cross-project group grant unrepresentable. The
1048
+ * composite foreign key enforces it regardless; this exists so the answer
1049
+ * is a clean 404 rather than a constraint violation whose message would
1050
+ * itself confirm the id resolves to a row somewhere in the database.
1051
+ * - it need NOT be the file's own org. Cross-ORG group grants inside one
1052
+ * project are the whole point of the feature.
1053
+ */
1054
+ async #resolveSubjectOrg(tx, orgId) {
1055
+ if (!isUuid(orgId))
1056
+ throw new FilelayerError(404, 'not_found', 'unknown_subject_org');
1057
+ const { rows } = await tx.query(`SELECT o.id FROM org o
1058
+ JOIN project p ON p.id = o.project_id
1059
+ WHERE o.id = $1
1060
+ AND o.deleted_at IS NULL
1061
+ AND p.deleted_at IS NULL
1062
+ AND ($2::uuid IS NULL OR o.project_id = $2::uuid)`, [orgId, this.projectId]);
1063
+ if (!rows[0])
1064
+ throw new FilelayerError(404, 'not_found', 'unknown_subject_org');
1065
+ return rows[0].id;
1066
+ }
1067
+ /**
1068
+ * Revoke a grant.
1069
+ *
1070
+ * Nothing cascades, and nothing needs to: liveness is evaluated over the
1071
+ * ancestor chain, so every grant ever delegated from this one dies in the
1072
+ * same instant, at any depth, with no second write to get wrong (P4).
1073
+ */
1074
+ async revoke(principal, grantId) {
1075
+ if (!isUuid(grantId))
1076
+ throw new FilelayerError(404, 'not_found');
1077
+ // Revocation is the operation the product is sold on, so the state change
1078
+ // and the event proving it happened must not be separable. Both are in this
1079
+ // transaction.
1080
+ //
1081
+ // LOCK ORDERING, and it is not decorative. Because `audit_append()` takes a
1082
+ // `pg_advisory_XACT_lock` on the org's chain, a transaction now holds that
1083
+ // lock from its FIRST audit write until commit -- which it did not before,
1084
+ // when every statement was its own transaction. Two transactions that take
1085
+ // the chain lock and a `file_grant` row lock in OPPOSITE orders deadlock.
1086
+ // The rule, followed by every method here, is:
1087
+ //
1088
+ // THE AUDIT CHAIN LOCK IS ALWAYS TAKEN BEFORE ANY ROW LOCK.
1089
+ //
1090
+ // `authorizeRevoke` emits its allow event (chain lock) before the UPDATE
1091
+ // below (row lock), and the delivery path likewise audits in `authorize()`
1092
+ // before `consume_download()` touches the grant row. This SELECT therefore
1093
+ // deliberately does NOT take `FOR UPDATE`: that would grab the row lock
1094
+ // first and invert the order against every other path. Nothing is lost --
1095
+ // the UPDATE is `WHERE revoked_at IS NULL`, so a concurrent revoke is
1096
+ // idempotent rather than a lost update.
1097
+ await this.#transaction(async (tx, store) => {
1098
+ const { rows } = await tx.query(`SELECT file_id, org_id FROM file_grant WHERE id = $1`, [grantId]);
1099
+ const g = rows[0];
1100
+ // Unknown grant and "not yours" are the same answer, for the same reason
1101
+ // file ids are: otherwise this endpoint is a grant-id oracle.
1102
+ if (!g)
1103
+ throw new FilelayerError(404, 'not_found');
1104
+ const decision = await authorizeRevoke(store, principal, {
1105
+ id: grantId,
1106
+ fileId: g.file_id,
1107
+ orgId: g.org_id,
1108
+ });
1109
+ this.#raise(decision);
1110
+ await tx.query(`UPDATE file_grant SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, [grantId]);
1111
+ await store.audit({
1112
+ orgId: g.org_id,
1113
+ action: 'grant.revoke',
1114
+ decision: 'allow',
1115
+ actorId: principal.actorId,
1116
+ fileId: g.file_id,
1117
+ grantId,
1118
+ });
1119
+ });
1120
+ }
1121
+ async listGrants(principal, fileId) {
1122
+ const decision = await authorize(this.store, principal, fileId, 'share');
1123
+ this.#raise(decision);
1124
+ const { rows } = await this.db.query(`SELECT id, file_id, parent_grant_id, subject_type, subject_id,
1125
+ subject_org_id, subject_min_role, capabilities,
1126
+ (password_hash IS NOT NULL) AS has_password,
1127
+ expires_at, max_downloads, download_count, revoked_at,
1128
+ grant_is_live(id) AS live,
1129
+ created_by, created_at
1130
+ FROM file_grant WHERE file_id = $1 ORDER BY created_at ASC`, [fileId]);
1131
+ // Note what is NOT selected: secret_hash and password_hash. A "list what
1132
+ // we've shared" screen is exactly where a hash would leak into a log.
1133
+ return rows.map((r) => ({
1134
+ id: r['id'],
1135
+ fileId: r['file_id'],
1136
+ parentGrantId: r['parent_grant_id'] ?? null,
1137
+ subjectType: r['subject_type'],
1138
+ subjectId: r['subject_id'] ?? null,
1139
+ subjectOrgId: r['subject_org_id'] ?? null,
1140
+ subjectMinRole: r['subject_min_role'] ?? null,
1141
+ capabilities: toCapabilities(r['capabilities']),
1142
+ hasPassword: Boolean(r['has_password']),
1143
+ expiresAt: r['expires_at'] ? new Date(r['expires_at']) : null,
1144
+ maxDownloads: r['max_downloads'] ?? null,
1145
+ downloadCount: Number(r['download_count']),
1146
+ revokedAt: r['revoked_at'] ? new Date(r['revoked_at']) : null,
1147
+ live: Boolean(r['live']),
1148
+ createdBy: r['created_by'] ?? null,
1149
+ createdAt: new Date(r['created_at']),
1150
+ }));
1151
+ }
1152
+ /**
1153
+ * The share-link download path.
1154
+ *
1155
+ * Order matters: authorize FIRST (which re-validates the grant and its whole
1156
+ * ancestor chain against `live_grant` -- P4, revocation beats a live URL),
1157
+ * then consume the counter atomically (P6). Consuming before authorizing
1158
+ * would let a revoked link burn a download; authorizing without consuming
1159
+ * would make the cap a suggestion.
1160
+ */
1161
+ async redeem(linkSecret, opts = {}) {
1162
+ // Buffered convenience form of `redeemStream()`, exactly as `read()` is of
1163
+ // `readStream()`. Forces proxy mode for the same reason.
1164
+ const d = await this.redeemStream(linkSecret, { ...opts, mode: 'proxy' });
1165
+ if (d.mode !== 'proxy')
1166
+ throw new FilelayerError(500, 'internal', 'unexpected_redirect');
1167
+ return {
1168
+ file: d.file,
1169
+ body: await collectStream(d.body),
1170
+ headers: d.headers,
1171
+ remainingDownloads: d.remainingDownloads,
1172
+ };
1173
+ }
1174
+ /** The streaming share-link path. See `redeem()` for the ordering rationale. */
1175
+ async redeemStream(linkSecret, opts = {}) {
1176
+ const principal = {
1177
+ actorId: null,
1178
+ linkSecret,
1179
+ ...(opts.password !== undefined ? { password: opts.password } : {}),
1180
+ ...(opts.ip !== undefined ? { ip: opts.ip } : {}),
1181
+ ...(opts.userAgent !== undefined ? { userAgent: opts.userAgent } : {}),
1182
+ };
1183
+ const hash = await this.store.hashSecret(linkSecret);
1184
+ const reserved = await this.#transaction(async (tx, store) => {
1185
+ // Resolve the secret to a FILE, not to an authorization. This
1186
+ // deliberately reads through the non-live lookup: a revoked, expired or
1187
+ // exhausted link must still reach the engine so that the denial is
1188
+ // attributed to the right tenant and recorded with the right reason.
1189
+ // Nothing here grants anything -- `authorize` below re-resolves through
1190
+ // `live_grant`.
1191
+ const grant = await store.findGrantBySecret(hash);
1192
+ if (!grant) {
1193
+ // No file and no tenant: the system chain exists precisely so that a
1194
+ // brute-force sweep against the credential itself is not invisible.
1195
+ // In the transaction, so the sweep cannot be made invisible by a
1196
+ // failure on the way out either.
1197
+ await auditUnresolvedSecret(store, principal, hash);
1198
+ throw new FilelayerError(404, 'not_found', 'bad_link_secret');
1199
+ }
1200
+ const decision = await authorize(store, principal, grant.fileId, 'read');
1201
+ this.#raise(decision);
1202
+ // Same reservation path as `readStream()`: one place charges the cap, one
1203
+ // place decides the mode, one place computes the headers. `no-store` on
1204
+ // the proxied response is not cosmetic here -- immediate revocation is the
1205
+ // product's headline property, and a cacheable share response makes a
1206
+ // revoked link replayable from the recipient's disk cache or from any
1207
+ // intermediary.
1208
+ return this.#reserve(tx, store, grant.fileId, decision, principal, opts);
1209
+ });
1210
+ return this.#fetchDelivery(reserved, opts);
1211
+ }
1212
+ // ---------------------------------------------------------------------------
1213
+ // Audit
1214
+ // ---------------------------------------------------------------------------
1215
+ /** Requires `read_audit` in the org, which is admin+. Asked of the engine. */
1216
+ async auditLog(principal, orgId, filter = {}) {
1217
+ const decision = await authorizeOrg(this.store, principal, orgId, 'read_audit', {
1218
+ action: 'audit.read',
1219
+ emitAllow: false, // reading the log should not spam the log
1220
+ });
1221
+ this.#raise(decision);
1222
+ return this.store.listAudit(orgId, filter);
1223
+ }
1224
+ /**
1225
+ * Verify the tamper-evidence chain for an org.
1226
+ *
1227
+ * Authorized, because `checked` is a count of everything that has ever
1228
+ * happened in the org and an unauthenticated caller should not be able to
1229
+ * measure another tenant's activity.
1230
+ */
1231
+ async verifyAuditChain(principal, orgId) {
1232
+ const decision = await authorizeOrg(this.store, principal, orgId, 'read_audit', {
1233
+ action: 'audit.verify',
1234
+ emitAllow: false,
1235
+ });
1236
+ this.#raise(decision);
1237
+ return this.store.verifyAuditChain(orgId);
1238
+ }
1239
+ // ---------------------------------------------------------------------------
1240
+ // Internals
1241
+ // ---------------------------------------------------------------------------
1242
+ /**
1243
+ * Turn a denial into an HTTP-shaped error.
1244
+ *
1245
+ * That is all it does now. It used to additionally re-run the whole
1246
+ * authorization question in order to downgrade 410/409 to 404 for callers
1247
+ * with no standing, because `authorize()` evaluated lifecycle gates before
1248
+ * establishing standing and therefore leaked existence. The engine
1249
+ * evaluates standing first now, so there is nothing left to compensate for --
1250
+ * and, not incidentally, one fewer place for a second entry point to forget.
1251
+ */
1252
+ #raise(decision) {
1253
+ if (decision.allow)
1254
+ return;
1255
+ const pub = toPublicError(decision.reason);
1256
+ throw new FilelayerError(pub.status, pub.code, decision.reason);
1257
+ }
1258
+ }
1259
+ /**
1260
+ * A file's full record from its id, with NO principal.
1261
+ *
1262
+ * IT IS A MODULE-LEVEL FUNCTION, NOT A PRIVATE METHOD, AND THAT IS THE POINT.
1263
+ *
1264
+ * This was once a PUBLIC method taking a file id and no principal, returning
1265
+ * name, content type, size, storage key, owner and org for any file in the
1266
+ * database. It sat under an `// Internals` comment, which binds nobody:
1267
+ * `fl.getFileRecord(anyUuid)` was a complete cross-tenant metadata read with no
1268
+ * decision, no denial and no audit event. A resource id without a principal is
1269
+ * not a question the system is allowed to answer.
1270
+ *
1271
+ * Making it `private` fixed the TYPE and not the RUNTIME. TypeScript's `private`
1272
+ * is erased at compile time: `(fl as any).getFileRecord(id)` still worked, and
1273
+ * so did `fl['getFileRecord'](id)` from plain JavaScript -- which is what an SDK
1274
+ * consumer actually holds. A test in test/persistence.test.ts caught exactly
1275
+ * that. Module scope is the only privacy JavaScript actually enforces, so the
1276
+ * function lives out here, where nothing outside this file can name it.
1277
+ *
1278
+ * Every caller inside the class reaches it only AFTER `authorize()` has
1279
+ * returned allow for the same file; the authorized replacement for external
1280
+ * callers is `stat()`. It is project-scoped, so even an internal caller cannot
1281
+ * read across a project boundary.
1282
+ */
1283
+ async function getFileRecord(db, projectId, fileId) {
1284
+ if (!isUuid(fileId))
1285
+ return null;
1286
+ const { rows } = await db.query(`SELECT id, org_id, owner_id, name, content_type, size_bytes,
1287
+ storage_provider, storage_key,
1288
+ state, visibility, expires_at, retain_until, created_at, deleted_at
1289
+ FROM file WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)`, [fileId, projectId]);
1290
+ return rows[0] ? toFileRecord(rows[0]) : null;
1291
+ }
1292
+ /**
1293
+ * Keyset pagination over (created_at, id).
1294
+ *
1295
+ * Keyset, not OFFSET: with OFFSET a row inserted or deleted between pages
1296
+ * shifts the window and a file silently skips a page, which on a compliance
1297
+ * listing screen is a file the reviewer never saw.
1298
+ *
1299
+ * The cursor is opaque but not authenticated, and it does not need to be: it
1300
+ * carries only a position, it is applied AFTER the authorization predicate, and
1301
+ * it is confined to the org named in the call. A forged cursor can move you
1302
+ * within your own authorized set and nowhere else. It is validated on the way
1303
+ * in so that a malformed one is a 400 rather than a silently-ignored filter.
1304
+ */
1305
+ function encodeCursor(createdAt, id) {
1306
+ return Buffer.from(`${createdAt.toISOString()}|${id}`, 'utf8').toString('base64url');
1307
+ }
1308
+ function decodeCursor(cursor) {
1309
+ if (cursor === undefined || cursor === null || cursor === '')
1310
+ return null;
1311
+ let decoded;
1312
+ try {
1313
+ decoded = Buffer.from(cursor, 'base64url').toString('utf8');
1314
+ }
1315
+ catch {
1316
+ throw new FilelayerError(400, 'bad_cursor');
1317
+ }
1318
+ const sep = decoded.lastIndexOf('|');
1319
+ if (sep < 0)
1320
+ throw new FilelayerError(400, 'bad_cursor');
1321
+ const createdAt = new Date(decoded.slice(0, sep));
1322
+ const id = decoded.slice(sep + 1);
1323
+ if (Number.isNaN(createdAt.getTime()) || !isUuid(id)) {
1324
+ throw new FilelayerError(400, 'bad_cursor');
1325
+ }
1326
+ return { createdAt, id };
1327
+ }
1328
+ /**
1329
+ * Postgres array literal. The driver will not serialize a JS array into an
1330
+ * enum[] parameter, and a silent misencoding here would either error loudly
1331
+ * (fine) or store a single bogus capability (not fine), so the encoding is
1332
+ * explicit. Enum labels are a closed set of [a-z]+ and cannot contain a
1333
+ * separator, so no quoting is required -- but we assert that rather than
1334
+ * assume it.
1335
+ */
1336
+ export function pgArrayLiteral(values) {
1337
+ for (const v of values) {
1338
+ if (!/^[a-z_]+$/.test(v))
1339
+ throw new Error(`unexpected capability literal: ${v}`);
1340
+ }
1341
+ return `{${values.join(',')}}`;
1342
+ }
1343
+ function toFileRecord(r) {
1344
+ return {
1345
+ id: r['id'],
1346
+ orgId: r['org_id'],
1347
+ ownerId: r['owner_id'] ?? null,
1348
+ name: r['name'],
1349
+ contentType: r['content_type'],
1350
+ sizeBytes: r['size_bytes'] == null ? null : Number(r['size_bytes']),
1351
+ storageProvider: r['storage_provider'],
1352
+ storageKey: r['storage_key'],
1353
+ state: r['deleted_at'] ? 'deleted' : r['state'],
1354
+ visibility: r['visibility'],
1355
+ expiresAt: r['expires_at'] ? new Date(r['expires_at']) : null,
1356
+ retainUntil: r['retain_until'] ? new Date(r['retain_until']) : null,
1357
+ createdAt: new Date(r['created_at']),
1358
+ };
1359
+ }
1360
+ //# sourceMappingURL=filelayer.js.map