@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/SEMANTICS.md ADDED
@@ -0,0 +1,729 @@
1
+ # Filelayer semantics
2
+
3
+ What deletion, expiry, revocation and metering mean — precisely enough to
4
+ predict behaviour without reading the code.
5
+
6
+ Every rule here is enforced in `schema.sql` or `src/authz.ts` and asserted in
7
+ `test/semantics.test.ts`. Where a rule was a judgement call, the call and the
8
+ reasoning are stated. Nothing here is aspirational.
9
+
10
+ ---
11
+
12
+ ## 1. The object model
13
+
14
+ ```
15
+ project → org → file → grant
16
+ ↑ ↑ ↑
17
+ └── actor ─┘ (subject / issuer)
18
+ ```
19
+
20
+ - **project** — one customer application. Authenticated by an API key at the
21
+ ingest boundary. The scope in which `external_id` is unique.
22
+ - **org** — one tenant inside a customer's application.
23
+ - **actor** — one identity inside a customer's application. May hold grants in
24
+ orgs it is not a member of.
25
+ - **file** — bytes plus lifecycle.
26
+ - **grant** — an explicit, revocable, listable, auditable row conferring
27
+ capabilities on one file. Grants may be delegated; a delegated grant records
28
+ its parent.
29
+
30
+ There is **no ambient authority and no public flag.** Access exists only as a
31
+ `membership` row or a `grant` row. Absence of a row is denial.
32
+
33
+ ### 1a. A grant's subject is a principal set
34
+
35
+ ```
36
+ grant_subject := actor | org | role | link | anonymous
37
+ ```
38
+
39
+ | subject | the set it denotes | columns |
40
+ |---|---|---|
41
+ | `actor` | exactly one principal | `subject_id` |
42
+ | `role` | every member of an org at role **≥** the floor | `subject_org_id`, `subject_min_role` |
43
+ | `org` | every member of an org, at any role | `subject_org_id` |
44
+ | `link` | whoever holds the secret (bearer, not identity) | `secret_hash` |
45
+ | `anonymous` | everyone | — |
46
+
47
+ Breadth ordering, which invariant **I6** below attenuates over:
48
+
49
+ ```
50
+ actor ⊂ role ⊆ org ⊂ anonymous link — orthogonal (bearer)
51
+ ```
52
+
53
+ `org` is `role` with the floor at `viewer`. Both exist because the common case
54
+ should not require naming a role.
55
+
56
+ **Resolution is a JOIN, never a materialization.** A group grant matches a
57
+ principal iff that principal has a live `membership` row in `subject_org_id` at
58
+ a sufficient role, evaluated on the request. There is no fan-out table and no
59
+ per-member row, so:
60
+
61
+ > **Adding or removing a member changes access on the very next request, with
62
+ > no recomputation and no write to any grant.**
63
+
64
+ That is the same property that makes revocation immediate, and it is the reason
65
+ fan-out was rejected: fan-out makes membership *eventually consistent* with
66
+ access. `test/group-subjects.test.ts` asserts it by fingerprinting every column
67
+ of every `file_grant` row before and after a join, a leave and a role change.
68
+
69
+ **What group subjects deliberately are not.** No custom groups — a group is an
70
+ org. No nested orgs. No configurable inheritance. No arbitrary permission sets.
71
+ No deny rules. `subject_min_role` is *only* a threshold over the existing
72
+ four-value `org_role` enum, and the roles remain exactly
73
+ `viewer | member | admin | owner`.
74
+
75
+ **Cross-org, not cross-project.** `subject_org_id` may name an org other than
76
+ the file's own — "the company that posted this job may read this CV" — but it
77
+ must be an org in the **same project**, enforced by composite foreign key
78
+ against `org(id, project_id)` where `project_id` is derived from the file. A
79
+ cross-project group grant is unrepresentable (P8), exactly as a cross-tenant
80
+ grant is (P3).
81
+
82
+ **`visibility = 'org'` is retained**, and is now describable as an *implicit org
83
+ grant*: a read grant whose subject org is the file's own. It stays as sugar for
84
+ that one case because it needs no vocabulary at all, and because keeping it a
85
+ column on `file` is what lets the role matrix remain a total function over
86
+ 4 roles × 2 ownerships × 2 visibilities — the enumeration the listing predicate
87
+ is derived from. It is not a boolean that opens a file to a population beyond
88
+ its own tenant, so P1 is unchanged.
89
+
90
+ ---
91
+
92
+ ## 2. Access is the conjunction of three things
93
+
94
+ A request is allowed only if **all** hold:
95
+
96
+ 1. **Standing** — the principal holds the capability, via an org role or a live
97
+ grant. (`fileCapabilities()` / `resolveStanding()`)
98
+ 2. **Grant liveness**, if standing came from a grant — see §3.
99
+ 3. **File lifecycle** — the file is not deleted, not expired, not `pending` for
100
+ a read, and not under a retention hold for a delete. (`lifecycleDenial()`)
101
+
102
+ Evaluation order is 1 → 2 → 3, and that order is a security property: steps 1
103
+ and 2 are indistinguishable to the caller (everything is `404`), so the
104
+ statuses that are *not* 404 (`410 Gone`, `409 retention_hold`) are only ever
105
+ reachable by someone who already proved they may perform the operation.
106
+
107
+ ---
108
+
109
+ ## 3. Grant liveness
110
+
111
+ A grant is **live** iff **it and every one of its ancestors** satisfies all of:
112
+
113
+ | dimension | dead when |
114
+ |---|---|
115
+ | revocation | `revoked_at` is set |
116
+ | expiry | `expires_at ≤ now()` |
117
+ | budget | `download_count ≥ max_downloads` |
118
+ | **scope** | its file, org, project, **subject**, **subject org** or **issuer** is deleted |
119
+
120
+ Evaluated by one predicate, `grant_is_live()`, which every grant lookup reads
121
+ through (`live_grant`). There is no query that can opt out of it, including a
122
+ `psql` session or a future endpoint.
123
+
124
+ Liveness is **derived, never cascaded**. Revoking a grant or deleting a scope
125
+ writes nothing to the grants below it; they simply stop being live. This is why
126
+ revocation is transitive at any depth with no second write to get wrong, and
127
+ why undelete is exact.
128
+
129
+ ---
130
+
131
+ ## 4. Deletion — the table
132
+
133
+ Everything below is **soft delete**: `deleted_at` is set, no data is erased.
134
+
135
+ | you delete | grants **to** that thing | grants **issued by** it | role-derived access | reversible |
136
+ |---|---|---|---|---|
137
+ | **project** | all, in every org | all | all | yes |
138
+ | **org** | all in the org, **plus every group grant naming it** | all in the org | all | yes |
139
+ | **file** | all on the file | — | n/a (file gate denies) | yes |
140
+ | **actor** | all where they are subject | **all they issued** | theirs | yes |
141
+ | **membership** (removal) | *unaffected* | *unaffected* | theirs — **including every group grant that reached them through it** | re-add |
142
+
143
+ ### Why "everything dies"
144
+
145
+ A tenant whose deletion leaves its share links serving bytes has not been
146
+ deleted. "We offboarded that customer" has exactly one honest meaning. The
147
+ previous behaviour — memberships died, grants did not — was nobody's decision;
148
+ it was an asymmetry between two store methods.
149
+
150
+ ### Why a deleted **issuer** takes their links with them
151
+
152
+ This is the judgement call, and the call is **P4**: *a signed URL may never
153
+ outlive the permission that created it.* A root grant is minted from the
154
+ issuer's role-derived authority. Delete the identity and that authority is gone,
155
+ so the grant goes with it. The alternative is "the intern left two years ago and
156
+ their link still works", which is the failure this product exists to remove.
157
+
158
+ Expect this to be **loud**: deleting a prolific sharer revokes many links at
159
+ once. That is the rule working.
160
+
161
+ ### Why a deleted **subject org** takes its members' access with it
162
+
163
+ The mirror of the rule above, and the same reading. A group grant's subject is
164
+ "the members of org O". Delete O and that set is not empty, it is **gone** —
165
+ there is no longer a tenant whose members the grant could mean. The rule already
166
+ says a deleted org kills the grants **on** its files; it now also kills the
167
+ grants **held by** its members. Without that, soft-deleting a partner
168
+ organization would leave its former members reading the other tenant's
169
+ documents, which is the org-deletion defect P7 exists to close, re-opened on a
170
+ new axis.
171
+
172
+ Nothing is written, so `restoreOrg` revives exactly what the deletion suspended
173
+ and nothing else — a group grant independently revoked beforehand stays revoked.
174
+
175
+ ### Why removing a **membership** does *not* do the same
176
+
177
+ Deliberate asymmetry. Membership removal is a role change inside a living tenant
178
+ — someone changed teams, duties were transferred. Mass-revoking a colleague's
179
+ customer-facing links as a side effect of a role edit would be an expensive
180
+ surprise. Identity **deletion** is the different, stronger statement: *this
181
+ person is gone.*
182
+
183
+ - To end one person's access: remove the membership (and `revoke` anything
184
+ specific — `listGrants` shows you what).
185
+ - To offboard them completely: `softDeleteActor`.
186
+
187
+ **Group grants sharpen this, and the asymmetry is unchanged.** Removing a
188
+ membership immediately ends every access that reached the person *through* that
189
+ membership — that is what a group subject means, and it takes effect on the next
190
+ request with no grant row touched. It still does **not** revoke grants issued
191
+ **to them by name**, nor grants **they issued**. So "remove them from the org"
192
+ now does considerably more than it used to, and it still is not the same
193
+ statement as "this person is gone".
194
+
195
+ ### Deletion vs. retention and legal hold
196
+
197
+ Soft delete **suspends access and preserves evidence.**
198
+
199
+ - It writes no row that a retention hold protects. `retain_until` still blocks
200
+ deletion of a file, including for org owners.
201
+ - The deleted tenant's audit chain remains intact and independently verifiable.
202
+ - The deletion itself is audited (`org.delete`, `actor.delete`,
203
+ `project.delete`).
204
+
205
+ Therefore soft delete **cannot** be used to destroy records under legal hold.
206
+ Erasure is a separate privileged operation gated on retention, and it is not
207
+ this one.
208
+
209
+ ### Restore
210
+
211
+ `restoreOrg` / `restoreActor` / `restoreProject` revive **exactly** what the
212
+ corresponding delete suspended, and nothing else. A grant that was independently
213
+ revoked before the delete stays revoked. The same secret keeps working; nothing
214
+ is re-issued.
215
+
216
+ ### Hard deletes
217
+
218
+ Not part of the model. `ON DELETE CASCADE` on `org` reaches `audit_event`, so a
219
+ hard org delete destroys that tenant's chain. Use soft delete.
220
+
221
+ ---
222
+
223
+ ## 5. Expiry is not deletion
224
+
225
+ `file.expires_at` and `grant.expires_at` are **time gates**, evaluated on every
226
+ access and extendable. `deleted_at` is **existence**, and it is what grant scope
227
+ liveness reads. Two different questions, two different mechanisms — which is why
228
+ an expired file does not make its grants non-live (the lifecycle gate denies the
229
+ access instead), and why extending an expiry restores access without touching a
230
+ grant.
231
+
232
+ ---
233
+
234
+ ## 6. `maxDownloads` counts byte deliveries
235
+
236
+ **A cap of *n* means the bytes leave at most *n* times, on any path, by any
237
+ principal, at any delegation depth.**
238
+
239
+ | operation | charges the cap? |
240
+ |---|---|
241
+ | `redeem()` (share link) | **yes** |
242
+ | `read()` authorized **via a grant** | **yes** |
243
+ | `read()` authorized via an **org role** or ownership | no |
244
+ | `stat()` / `listFiles()` | no |
245
+ | `authorize()` alone | no |
246
+
247
+ The cap binds the **credential**, not the file: an administrator doing their job
248
+ must not silently spend a contractor's budget. If a principal could have read
249
+ the file by role, the grant is not charged (standing resolution reaches the role
250
+ first).
251
+
252
+ A delivery charges **the entire ancestor chain**, so a parent's cap is a real
253
+ budget over its whole delegation tree — mint three children from a parent with 5
254
+ left and you have sold 5, not 15.
255
+
256
+ The reservation happens **before** the bytes are fetched, atomically
257
+ (`consume_download`). Two concurrent deliveries against a cap of 1 yield exactly
258
+ one delivery. A storage failure after a successful reservation still spends the
259
+ download; that is the fail-closed direction and it is deliberate.
260
+
261
+ `download_count` is incremented on every grant-authorized delivery whether or
262
+ not a cap is set, because it is also the answer to "how many times has this link
263
+ been downloaded", which `listGrants` reports. Cost of that choice: a
264
+ high-traffic public file makes its single anonymous grant row a write hotspot
265
+ (recorded in `Filelayer.deliver()`).
266
+
267
+ Previously the cap was charged only by `redeem()`, which made it a lie on
268
+ the path the SDK actually uses. Rejected alternatives: renaming it
269
+ `maxRedemptions` (moves the ambiguity, since it would still be settable on an
270
+ actor grant), and refusing the field on non-link grants (removes a capability
271
+ instead of defining one).
272
+
273
+ ---
274
+
275
+ ## 7. Attenuation — a delegated grant can never exceed its parent
276
+
277
+ Attenuation has **two dimensions**: what a delegate may *do*, and *who they may
278
+ reach*. Both are enforced by the same `BEFORE INSERT OR UPDATE` trigger, so both
279
+ bind every writer and not just the application.
280
+
281
+ ### 7a. Authority — what a delegate may do
282
+
283
+ - **capabilities** — must be a subset. Excess is **rejected**, not narrowed:
284
+ silently reducing an authority someone asked for hides a bug.
285
+ - **expiry** and **budget** — **clamped** to the parent's, and the effective
286
+ values are returned so the clamp is visible.
287
+ - **lineage is immutable** — `parent_grant_id` cannot be changed.
288
+ - **depth is bounded** (32), so liveness evaluation is bounded.
289
+ - You cannot delegate from a grant that is not live — including one whose file,
290
+ org, project, subject or issuer has been deleted.
291
+
292
+ Link and anonymous grants are **read-only by CHECK constraint**, so authority
293
+ cannot be passed on by a bearer credential: only a named, revocable, attributable
294
+ actor can delegate.
295
+
296
+ ### 7b. Breadth (I6) — who a delegate may reach
297
+
298
+ > **Delegation may attenuate authority, but it may never amplify subject
299
+ > breadth.**
300
+
301
+ | the issuer's authority came from | subject types they may mint |
302
+ |---|---|
303
+ | an **org role** (admin/owner of the file's org, or the file's owner) | **all five** |
304
+ | a **grant** (`parent_grant_id` is set) | **`actor` and `link` only** |
305
+
306
+ So, exhaustively:
307
+
308
+ | delegation | outcome |
309
+ |---|---|
310
+ | `org` grant → `actor` | allowed |
311
+ | `org` grant → `link` | allowed, if capability attenuation also holds |
312
+ | `org` grant → `org` | **denied** |
313
+ | `actor` grant → `org` / `role` | **denied** |
314
+ | `link` grant → `org` | **denied** |
315
+ | any grant-derived → `anonymous` | **denied** |
316
+
317
+ **Why.** Capability attenuation stops a delegate *doing more*; it says nothing
318
+ about *reaching more people*. Without I6, a contractor holding one `{read,
319
+ share}` grant — the narrowest useful authority we issue — could re-grant to an
320
+ entire organization, or publish the file anonymously, and every capability check
321
+ would still pass because the child's set is a subset of the parent's. One
322
+ consultant's read access becomes a public link with attenuation satisfied the
323
+ whole way. Both dimensions must be attenuated or neither is.
324
+
325
+ **Where it is enforced.** In `authorizeShare()`, which refuses with reason
326
+ `subject_breadth_amplification`, a `403`, and an audit event naming the
327
+ requested subject type; **and** in the `file_grant_attenuate()` trigger, which
328
+ raises `grant_subject_amplification` — so the invariant holds for a caller
329
+ issuing raw SQL, a migration, or an admin tool. The trigger fires on the subject
330
+ columns as well as on INSERT, so a delegated grant cannot be widened by a later
331
+ `UPDATE` either. Every case in the table above is asserted through both paths in
332
+ `test/group-subjects.test.ts`.
333
+
334
+ ---
335
+
336
+ ## 8. The tenant and project boundaries
337
+
338
+ - A grant's `org_id` must equal its file's `org_id` — composite foreign key.
339
+ Cross-tenant grants are **unrepresentable**, not merely prevented.
340
+ - A delegated grant must concern the same file as its parent — composite foreign
341
+ key.
342
+ - A group grant's `subject_org_id` must be in the same **project** as the file —
343
+ composite foreign key against `org(id, project_id)`, where `project_id` is
344
+ derived from the file's org by trigger and can never be supplied by a writer.
345
+ Cross-**org** group grants inside a project are legal and are the point;
346
+ cross-**project** ones are **unrepresentable**.
347
+ - Every table naming an actor carries `project_id` under a composite foreign key
348
+ to `actor(id, project_id)`. Cross-project memberships, file ownership, grant
349
+ subjects and grant issuers are **unrepresentable**.
350
+ - `external_id` is unique **per project**. Two customers may both call their
351
+ tenant `acme` and their user `alice`; those are four different rows.
352
+ - A `Filelayer` bound to a project cannot read, list, audit or address anything
353
+ outside it. Probes at another project's ids are `404` and are recorded on the
354
+ **system chain**.
355
+
356
+ Before this, `external_id` was globally unique, and the get-or-create in
357
+ the tiered API resolved one customer's `acme` to another customer's org row —
358
+ then added the caller's user to it. A common tenant name was a complete
359
+ cross-customer compromise from the most ergonomic entry point we ship.
360
+
361
+ ---
362
+
363
+ ## 9. The audit log
364
+
365
+ - **Every decision is recorded, including denials.** Denials are the
366
+ security-relevant events.
367
+ - **Every allow names the path it came from**, in `context.via`:
368
+ `owner | role | grant:actor | grant:org | grant:role | grant:link |
369
+ grant:anonymous`. For the two group paths the event also carries
370
+ `viaOrgId` (which org's membership conferred it), `viaMinRole` (the floor the
371
+ grant asked for) and `viaRole` (the role the caller actually held), so
372
+ "why did this succeed?" is answerable from one row without a join.
373
+ - **One event per decision.** `listFiles` emits **one** event carrying the
374
+ capability, the caller's role, the result count and the returned ids — not one
375
+ per file.
376
+ - **Decisions with no tenant to charge them to go to the SYSTEM chain**
377
+ (`org_id IS NULL`): probes at unknown file ids, sweeps against link secrets,
378
+ probes at orgs outside the caller's project, and project-level control-plane
379
+ actions. No tenant-facing API can read it. Attributing such events to a
380
+ guessed org would itself be an existence oracle; dropping them made
381
+ enumeration invisible.
382
+ - **Attempts against a suspended tenant's own files still land on that tenant's
383
+ chain.** The file exists and belongs to them; "who tried after we suspended
384
+ this org" must be answerable from their record.
385
+ - **Hash-chained per chain**, committing to every forensically relevant column.
386
+ Appended by `audit_append()`, which takes `pg_advisory_xact_lock` on the
387
+ chain, reads the predecessor and inserts — all in one statement, therefore one
388
+ transaction, therefore fork-proof under concurrent writers.
389
+ - The digest is computed in SQL on write and recomputed in TypeScript on read,
390
+ so the two implementations check each other on every verification.
391
+ - `UPDATE` and `DELETE` on `audit_event` are no-ops.
392
+ - Attribution survives deletion: `actor_id` carries no foreign key, so deleting
393
+ a user does not rewrite what they did.
394
+
395
+ **What PGlite cannot prove:** it has one backend. No test in this repository can
396
+ demonstrate lock contention, a waiting writer, or a fork actually prevented.
397
+ The tests prove the lock is taken before the predecessor is read, that the key
398
+ is per-chain, that the SQL and TypeScript digests agree, and that the fork the
399
+ old code would have produced is detectable. Proving serialization requires a
400
+ real multi-connection Postgres and belongs in the deployment suite.
401
+
402
+ ---
403
+
404
+ ## 10. The ingest boundary — operational requirements
405
+
406
+ An unauthenticated caller who guesses an org UUID can cause denials to be
407
+ appended to that tenant's chain. Under the per-chain lock this is a **latency
408
+ attack on that tenant's request path**, not merely log noise, because every one
409
+ of that tenant's own requests takes the same lock on its audit write.
410
+
411
+ This is **not fixable inside the engine**: P5 requires that every decision be
412
+ recorded, so any in-engine mitigation is a rule for dropping audit events; and
413
+ the engine cannot distinguish a flood from reconnaissance, because they are the
414
+ same request and only the rate differs.
415
+
416
+ **Fixed in core:** the blast radius. `orgExists` is project-scoped, so a caller
417
+ can only reach a tenant chain inside a project they are already authenticated
418
+ for. Everything else goes to the system chain.
419
+
420
+ **Required of the API layer** — these are requirements, not suggestions:
421
+
422
+ - **R1.** Every request carries a project credential. No project, no engine.
423
+ - **R2.** Rate limit per (project, source address) **before** the engine runs.
424
+ - **R3.** Cap per-project audit append rate; shed with `429`. Never by dropping
425
+ a decision that was actually made.
426
+ - **R4.** Alert on system-chain append rate. That chain is where unattributable
427
+ probes go, so its rate is the enumeration signal.
428
+
429
+ Related boundary: `PostgresStore` is the engine's dependency surface and **every
430
+ method on it is unauthorized by construction**. `@filelayer/sdk` must not
431
+ re-export `PostgresStore`, `Filelayer.store`, or `store.db`.
432
+
433
+ ---
434
+
435
+ ## 10a. Storage, transactions, and delivery modes
436
+
437
+ ### The object's identity is (provider, key)
438
+
439
+ `file.storage_provider` was written as the literal `'memory'` on every insert,
440
+ regardless of which adapter was configured. Against
441
+ `CREATE UNIQUE INDEX file_storage_key_idx ON file (storage_provider, storage_key)`
442
+ that means a production database recorded every object as living in an
443
+ in-process Map, and the one column that says *where the bytes are* was wrong for
444
+ every row.
445
+
446
+ It now comes from `StorageAdapter.provider`. An adapter with no provider name is
447
+ refused at construction. Conventional values: `memory`, `s3`, `r2`. The
448
+ `S3Storage` adapter defaults to `r2` for an R2 endpoint and `s3` otherwise, and
449
+ takes an explicit override.
450
+
451
+ **This is part of an object's identity, not a label.** Changing it for an
452
+ existing deployment makes every existing row point at a store the bytes are not
453
+ in. Pin it explicitly (`provider: 'r2'`) for anything long-lived.
454
+
455
+ `size_bytes` is likewise what the adapter reports it **wrote**, not what the
456
+ caller claimed. A caller-supplied `size` that disagrees with the object is how a
457
+ `content-length` ends up truncating a download.
458
+
459
+ ### What is in a transaction, and why
460
+
461
+ `Queryable` now carries a transaction abstraction (`withTransaction(db, fn)`)
462
+ that works for `pg.Pool` (checkout + `BEGIN`/`COMMIT`/`ROLLBACK` + `release`), a
463
+ single `pg.Client`, PGlite, and anything that supplies its own
464
+ `withTransaction`. Nesting becomes a `SAVEPOINT`.
465
+
466
+ **The rule: an audit event is written in the same transaction as the decision or
467
+ mutation it records.**
468
+
469
+ | operation | one transaction covers |
470
+ |---|---|
471
+ | `upload` | INSERT `file` · `file.create` event · usage · file-owner metering |
472
+ | `readStream` / `read` | the `authorize()` decision + its event · `consume_download` · the `file.deliver` event when redirected |
473
+ | `redeemStream` / `redeem` | secret resolution · unresolved-secret event · decision + event · `consume_download` |
474
+ | `share` | decision + event · INSERT `file_grant` (inside a SAVEPOINT) · `grant.create` event |
475
+ | `revoke` | decision + event · `UPDATE file_grant` · `grant.revoke` event |
476
+ | `delete` | decision + event · `UPDATE file` |
477
+ | `stat` | decision + event |
478
+
479
+ Two consequences worth stating explicitly.
480
+
481
+ **Denials commit.** A refusal writes an audit event and then throws. A naive
482
+ "throw ⇒ rollback" would silently destroy exactly the events P5 exists to keep
483
+ while the caller still saw their 403. So a `FilelayerError` — and only a
484
+ `FilelayerError` — is treated as a *decided* outcome: commit, then throw.
485
+ Everything else rolls back.
486
+
487
+ **The advisory lock now means something.** `audit_append()` takes
488
+ `pg_advisory_xact_lock`, which is held to the end of the *transaction*. Under
489
+ autocommit that was the end of one statement, so the lock serialized the append
490
+ but could not serialize it against the mutation it described. It now spans both.
491
+ The consequence is a lock-ordering rule that every method follows:
492
+
493
+ > **The audit chain lock is always taken before any row lock.**
494
+
495
+ `authorize()` audits before `consume_download()` touches the grant row;
496
+ `authorizeRevoke()` audits before the `UPDATE`. The revoke lookup deliberately
497
+ does **not** take `FOR UPDATE`, which would invert the order.
498
+
499
+ ### The storage write is not transactional. The ordering is the answer.
500
+
501
+ Object storage cannot join a Postgres transaction. There are two orderings:
502
+
503
+ - **commit metadata, then write bytes** — a crash between them leaves a `ready`
504
+ `file` row whose object does not exist. Every read 404s forever, the file is
505
+ listable, and the customer sees data loss.
506
+ - **write bytes, then commit metadata** — a crash between them leaves an object
507
+ no row points at. It is unreachable (every read path starts from a `file` row,
508
+ and keys are fresh UUIDs that are never reissued), so it costs storage and
509
+ nothing else.
510
+
511
+ **We take the second.** Deletion takes the mirror ordering: commit the metadata
512
+ delete first, remove the bytes after. Both failure modes produce an *orphan*
513
+ rather than data loss.
514
+
515
+ ### Orphan collection is a REQUIRED operational job
516
+
517
+ `Filelayer.collectStorageOrphans({ olderThanSeconds, limit, dryRun })`. It is
518
+ not automatic and nothing calls it for you. Run it on a schedule (hourly is
519
+ ample).
520
+
521
+ - `dryRun` defaults to **true**.
522
+ - `olderThanSeconds` defaults to 3600 and is floored at 60. **The grace period
523
+ is load-bearing**: an object written seconds ago may belong to an upload whose
524
+ transaction has not committed, and deleting it would turn a successful upload
525
+ into permanent data loss.
526
+ - An object is collected only when **nothing** references `(provider, key)`.
527
+ Soft-deleted files still have rows, so a file under a retention hold whose
528
+ bytes were never removed is never collected.
529
+ - Needs a `list()`-capable adapter. `MemoryStorage` and `S3Storage` have one.
530
+ - Sweeps are audited to the system chain as `storage.gc`.
531
+
532
+ ### Delivery modes
533
+
534
+ Two, with different security properties.
535
+
536
+ **`proxy` — the default, and the only mode available unless you opt in.**
537
+ Bytes flow through the process. Every request is authorized. Responses carry
538
+ `Cache-Control: private, no-store, …`. **Revocation is immediate, unqualified.**
539
+ Delivery is now *streamed*: `readStream()` / `redeemStream()` return a
540
+ `ReadableStream`, and the library's own HTTP routes use them, so a large file is
541
+ never resident. `read()` / `redeem()` remain the buffered convenience form for
542
+ small files and are unchanged.
543
+
544
+ **`redirect` — opt-in, bounded, audited.** Authorize → audit → `302` to a
545
+ short-lived presigned URL. No egress through the process, no heap, and for a
546
+ public asset a CDN-cacheable response.
547
+
548
+ > **The guarantee, stated the way a compliance auditor needs it:
549
+ > revocation is immediate at decision time, plus up to `ttlSeconds` of in-flight
550
+ > window.**
551
+
552
+ If a grant is revoked at *T*, no new redirect is issued from *T* onward — that
553
+ half is as immediate as the proxied path. A redirect issued at *T−1* hands out a
554
+ URL the object store honours until *T−1+ttlSeconds*, and the object store has
555
+ never heard of a grant. AWS's own documented answer to recalling one is "rotate
556
+ the signing credential", which kills every URL for every tenant at once and is
557
+ not a per-grant control. The window is real; it is bounded; the bound is ours.
558
+
559
+ Enforced, not documented:
560
+
561
+ - The config does not typecheck without the verbatim `REDIRECT_ACKNOWLEDGEMENT`
562
+ string, and is rejected at runtime too.
563
+ - `ttlSeconds` is clamped to `MAX_REDIRECT_TTL_SECONDS` (300). Clamped, not
564
+ rejected — rejecting invites someone to "fix" it by removing the bound.
565
+ - Default scope is `anonymous-grants-only`: only a delivery authorized by an
566
+ **anonymous grant** — something the customer deliberately published — may be
567
+ redirected. `via` is the engine's own account of where authority came from, so
568
+ "public" means "published", not "the request looked public".
569
+ `scope: 'all-grants'` widens it and is never the default.
570
+ - Only an anonymous redirect is **cacheable** (`public, max-age=ttl/2` — half,
571
+ so a cached redirect always has at least half its life left). A widened-scope
572
+ redirect gets `private, no-store`. **Nothing that was not already public
573
+ becomes cacheable by a shared cache.**
574
+ - The presigned URL pins `response-content-type` and
575
+ `response-content-disposition`, so the object store serves the same
576
+ neutralised type and `attachment` disposition the proxied path would have. A
577
+ redirect does not lose the response-header protections.
578
+ - Every redirected delivery writes a **`file.deliver`** audit event with
579
+ `mode: 'redirect'`, `ttlSeconds`, `revocationWindowSeconds`, `via` and
580
+ `cacheable`. A compliance auditor answering *"which deliveries left our control?"*
581
+ filters on that action; everything else was proxied.
582
+
583
+ ```ts
584
+ import { REDIRECT_ACKNOWLEDGEMENT } from '@filelayer/core';
585
+
586
+ new Filelayer(pool, new S3Storage({ … }), {
587
+ baseUrl,
588
+ redirectDelivery: {
589
+ acknowledgeRevocationWindow: REDIRECT_ACKNOWLEDGEMENT,
590
+ ttlSeconds: 60, // clamped to 300
591
+ // scope: 'all-grants', // opt in again to redirect private grants
592
+ },
593
+ });
594
+ ```
595
+
596
+ Per-route override: `deliveryHandler(fl, { mode: 'proxy' })` forces proxying on
597
+ an instance that has opted in. There is no `mode: 'redirect'` — a route cannot
598
+ demand a mode the instance was not configured for.
599
+
600
+ ### Testing the storage adapter
601
+
602
+ `test/storage.test.ts` runs `S3Storage` against `test/local-s3.mjs`, a local
603
+ S3-protocol server that **recomputes every SigV4 signature** from the request as
604
+ received and rejects a mismatch. It proves the wire format: canonical URI
605
+ encoding, canonical query strings, header canonicalisation, payload hashes,
606
+ multipart sequencing and part-size rules, ranges, HEAD, DELETE, list pagination
607
+ and presigning.
608
+
609
+ `test/s3-live.test.ts` runs the same operations against a **real bucket**. It is
610
+ skipped unless credentials are present and runs automatically when they are:
611
+
612
+ | variable | required | notes |
613
+ |---|---|---|
614
+ | `FILELAYER_TEST_S3_ENDPOINT` | yes | `https://<account>.r2.cloudflarestorage.com` or `https://s3.<region>.amazonaws.com` |
615
+ | `FILELAYER_TEST_S3_BUCKET` | yes | **use a dedicated test bucket** |
616
+ | `FILELAYER_TEST_S3_REGION` | yes | `auto` for R2 |
617
+ | `FILELAYER_TEST_S3_ACCESS_KEY_ID` | yes | |
618
+ | `FILELAYER_TEST_S3_SECRET_ACCESS_KEY` | yes | |
619
+ | `FILELAYER_TEST_S3_SESSION_TOKEN` | no | STS / temporary credentials |
620
+ | `FILELAYER_TEST_S3_PATH_STYLE` | no | `false` for virtual-hosted addressing |
621
+ | `FILELAYER_TEST_S3_PREFIX` | no | default `filelayer-ci/`; everything written is deleted after |
622
+ | `FILELAYER_TEST_S3_MULTIPART` | no | `1` to run the ~11 MB multipart test; enable in the nightly job |
623
+
624
+ Bucket permissions: `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`,
625
+ `s3:ListBucket`, `s3:AbortMultipartUpload` (+
626
+ `s3:ListBucketMultipartUploads` if multipart is enabled).
627
+
628
+ **Until that suite has run green against a real bucket, the S3 adapter is
629
+ "wire-correct", not "proven".** See the report accompanying this change for the
630
+ explicit list of what remains unverified.
631
+
632
+ ---
633
+
634
+ ## 11. Control-plane operations
635
+
636
+ These take no `Principal`, by design, and are authenticated by the customer's
637
+ project credential at the API boundary:
638
+
639
+ `createProject` · `createOrg` · `createActor` ·
640
+ `softDeleteOrg` / `restoreOrg` · `softDeleteActor` / `restoreActor` ·
641
+ `softDeleteProject` / `restoreProject` · `collectStorageOrphans`
642
+
643
+ Org and actor **creation** are control-plane because there is no principal
644
+ inside the system yet who could be authorized to perform them. Org and actor
645
+ **lifecycle** is control-plane for a sharper reason: deleting an org kills
646
+ membership-derived access, so the instant it succeeds nobody holds a role in
647
+ that org and nobody could ever restore it. An authorization rule that makes its
648
+ own inverse unreachable is a one-way door, not a rule.
649
+
650
+ `collectStorageOrphans` is control-plane for a different reason: it operates on
651
+ the object store, which has no tenant of its own, and it is the operational job
652
+ that pays for the storage-write ordering described in §10a. Its sweeps are
653
+ audited to the system chain.
654
+
655
+ All of them are audited.
656
+
657
+ **Everything else that names a resource takes a `Principal`.** The exceptions
658
+ are enumerated and asserted by a test (`test/persistence.test.ts`, "the
659
+ enumerated public surface has no unauthenticated resource accessor"), which fails
660
+ when a method is added to `Filelayer` that takes an id and no principal. Internal
661
+ helpers are ECMAScript `#private` or module-level functions, so they do not
662
+ appear on the prototype at all — unlike a TypeScript `private`, which does.
663
+
664
+ ---
665
+
666
+ ## 12. Errors
667
+
668
+ Internally we record precisely why access was denied. Externally:
669
+
670
+ | condition | response |
671
+ |---|---|
672
+ | absent / not yours / no standing / dead grant | `404 not_found` |
673
+ | file expired | `410 gone` |
674
+ | deletion blocked by retention | `409 retention_hold` |
675
+ | share link needs a password | `401 password_required` |
676
+ | attenuation, role escalation, superior target, last owner | `403 forbidden` |
677
+
678
+ `404` covers everything that would otherwise confirm existence. `410` and `409`
679
+ are reachable only by a caller who already proved they may perform the
680
+ operation. `403` is answered only to a caller who has already established
681
+ standing.
682
+
683
+ ---
684
+
685
+ ## 13. Migration (per-project `external_id` is breaking)
686
+
687
+ There are no customers, so the migration is a drop and recreate. For a
688
+ deployment with data:
689
+
690
+ ```sql
691
+ ALTER TABLE org DROP CONSTRAINT org_external_id_key;
692
+ ALTER TABLE actor DROP CONSTRAINT actor_external_id_key;
693
+
694
+ INSERT INTO project (id, key, name)
695
+ VALUES ('00000000-0000-0000-0000-0000000f11e1', '__filelayer_default_project__', 'Default project');
696
+
697
+ ALTER TABLE org ADD COLUMN project_id uuid NOT NULL
698
+ DEFAULT '00000000-0000-0000-0000-0000000f11e1' REFERENCES project(id) ON DELETE CASCADE;
699
+ ALTER TABLE actor ADD COLUMN project_id uuid NOT NULL
700
+ DEFAULT '00000000-0000-0000-0000-0000000f11e1' REFERENCES project(id) ON DELETE CASCADE;
701
+
702
+ ALTER TABLE org ADD UNIQUE (project_id, external_id), ADD UNIQUE (id, project_id);
703
+ ALTER TABLE actor ADD UNIQUE (project_id, external_id), ADD UNIQUE (id, project_id);
704
+ -- then project_id + the composite foreign keys on membership, file, file_grant,
705
+ -- and the project_from_org() trigger on each. See schema.sql.
706
+ ```
707
+
708
+ Every pre-existing row lands in one project, which is exactly what a
709
+ pre-hosted deployment was.
710
+
711
+ Other breaking changes in this pass:
712
+
713
+ - `Filelayer.getFileRecord()` is gone from the class entirely. It is a
714
+ module-level function now, because TypeScript's `private` is erased at compile
715
+ time — `fl['getFileRecord'](id)` was a working cross-tenant metadata read from
716
+ any JavaScript caller, and an SDK consumer holds JavaScript. Module scope is
717
+ the only privacy the runtime enforces. Use `stat(principal, fileId)`.
718
+ - `Filelayer.read()` now returns `remainingDownloads` and **charges the download
719
+ cap** when authority came from a grant.
720
+ - `Filelayer` constructor options are now `FilelayerOptions` and accept
721
+ `projectId` and `redirectDelivery`.
722
+ - `StorageAdapter` gained `provider` (required), `head()`, a streaming `put()`,
723
+ a richer `stream()` with range support, and optional `presignGet()` / `list()`.
724
+ `put()` returns `{ bytes, etag }`; `stream()` returns an object rather than a
725
+ bare `ReadableStream`.
726
+ - `UploadInput.body` accepts a `ReadableStream<Uint8Array>`.
727
+ - `FileRecord` and `ListedFile` gained `storageProvider`.
728
+ - New: `readStream()`, `redeemStream()`, `collectStorageOrphans()`,
729
+ `withTransaction()`, `sendNodeStream()`, `toStreamResponse()`.