@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.
- package/CHANGELOG.md +338 -0
- package/LICENSE +202 -0
- package/MIGRATIONS.md +328 -0
- package/NOTICE +37 -0
- package/README.md +343 -0
- package/SEMANTICS.md +729 -0
- package/dist/authz.d.ts +524 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +889 -0
- package/dist/authz.js.map +1 -0
- package/dist/db.d.ts +145 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +217 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.d.ts +293 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +519 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/filelayer.d.ts +542 -0
- package/dist/filelayer.d.ts.map +1 -0
- package/dist/filelayer.js +1360 -0
- package/dist/filelayer.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/simple.d.ts +297 -0
- package/dist/simple.d.ts.map +1 -0
- package/dist/simple.js +492 -0
- package/dist/simple.js.map +1 -0
- package/dist/storage.d.ts +269 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +700 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +432 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +862 -0
- package/dist/store.js.map +1 -0
- package/package.json +77 -0
- package/schema.sql +1190 -0
- package/src/authz.ts +1398 -0
- package/src/db.ts +271 -0
- package/src/delivery.ts +737 -0
- package/src/errors.ts +24 -0
- package/src/filelayer.ts +1836 -0
- package/src/index.ts +7 -0
- package/src/simple.ts +666 -0
- package/src/storage.ts +917 -0
- package/src/store.ts +1072 -0
- package/test/delivery.test.ts +0 -0
- package/test/group-subjects.test.ts +1072 -0
- package/test/helpers.ts +65 -0
- package/test/listing.test.ts +689 -0
- package/test/local-s3.d.mts +33 -0
- package/test/local-s3.mjs +400 -0
- package/test/persistence.test.ts +953 -0
- package/test/regression.test.ts +619 -0
- package/test/s3-live.test.ts +322 -0
- package/test/security.test.ts +1652 -0
- package/test/semantics.test.ts +888 -0
- package/test/storage.test.ts +437 -0
- package/test/tiers.test.ts +432 -0
- package/test/vault-example.test.ts +302 -0
- package/tsconfig.build.json +29 -0
- 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()`.
|