@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/MIGRATIONS.md ADDED
@@ -0,0 +1,328 @@
1
+ # Migrations and the compatibility promise
2
+
3
+ How schema changes reach you, what upgrading costs, and what we do and do not
4
+ promise before 1.0.
5
+
6
+ ---
7
+
8
+ ## 1. The promise, stated narrowly
9
+
10
+ Filelayer is `0.x`. The version is `0.MINOR.PATCH` and it means:
11
+
12
+ | Change | Version bump | What you must do |
13
+ |---|---|---|
14
+ | Breaking API change, breaking schema change, or both | **MINOR** (`0.3.x` → `0.4.0`) | Read the entry in this file. Run its SQL. Possibly edit call sites. |
15
+ | Additive API, bug fix, doc fix, new optional column with a default | **PATCH** (`0.3.0` → `0.3.1`) | `npm update`. Nothing else. |
16
+
17
+ There is no long-term support branch, no backporting, and no deprecation period
18
+ before 1.0. A minor bump may remove a method in the same release that replaces
19
+ it. That is what `0.x` means and it is why this file exists: the compensation
20
+ for moving fast is that every break is written down, with the SQL, before it
21
+ ships.
22
+
23
+ At 1.0 this changes to ordinary semantic versioning, and schema changes become
24
+ additive-with-a-deprecation-window rather than replace-in-place.
25
+
26
+ **Nothing has been published to npm yet.** At the time of writing there are no
27
+ installs, so no migration in this file has ever been executed by anyone other
28
+ than us. Entry 1 below is written as if it had been, because the next one will
29
+ be.
30
+
31
+ ---
32
+
33
+ ## 2. How a schema change is delivered
34
+
35
+ There is no migration framework and there is not going to be one. Filelayer
36
+ owns a schema; your application owns a migration runner. Wrapping ours in a tool
37
+ that competes with yours would be the wrong kind of opinionated.
38
+
39
+ What we ship instead:
40
+
41
+ 1. **`schema.sql` is the whole, current, canonical schema.** It is idempotent
42
+ only in the sense that it creates a database from nothing. It is not a
43
+ sequence of migrations and it will not upgrade an existing database.
44
+ Programmatic access, so a runner does not hardcode a path:
45
+
46
+ ```ts
47
+ import { SCHEMA_PATH, loadSchemaSql } from '@filelayer/core';
48
+ ```
49
+
50
+ 2. **Every breaking schema change gets a numbered entry in this file** with the
51
+ forward SQL, written to be pasted into your own migration tool, plus what it
52
+ costs and what it breaks in the API.
53
+
54
+ 3. **The version in `package.json` is the contract.** If your installed schema
55
+ was applied from `0.3.x`, entries above `0.3` apply to you in order.
56
+
57
+ ### Recording which version your database is at
58
+
59
+ There is no `schema_version` table today, and that is a gap we are naming rather
60
+ than hiding: right now you have to know which release you applied. If you have
61
+ just deployed, record it yourself:
62
+
63
+ ```sql
64
+ COMMENT ON SCHEMA public IS 'filelayer schema 0.3.0';
65
+ ```
66
+
67
+ A real version table lands before 1.0.
68
+
69
+ ### The order that is safe
70
+
71
+ Schema changes below are written to be applied **before** the new library
72
+ version is deployed, not after. Every one of them is designed so that the
73
+ previous library version keeps working against the new schema for the length of
74
+ a deploy — which means the safe sequence is always:
75
+
76
+ 1. apply the SQL,
77
+ 2. verify the old processes are still healthy,
78
+ 3. roll the new library version out,
79
+ 4. run any backfill the entry mentions.
80
+
81
+ Where an entry cannot honour that, it says so in bold at the top.
82
+
83
+ ---
84
+
85
+ ## 3. Migrations
86
+
87
+ ### Entry 1 — `0.2.x` → `0.3.0`: identifiers become per-project
88
+
89
+ **Breaking. Schema and API. This is a security fix; do not skip it.**
90
+
91
+ #### What was wrong
92
+
93
+ `org.external_id` and `actor.external_id` were **globally unique**. That is
94
+ correct for a library where each customer runs their own database, and it is a
95
+ cross-customer data breach the moment more than one application shares one.
96
+
97
+ The failure was not a collision error. It was a silent success:
98
+
99
+ - The identity resolver upserts a customer's own org id with
100
+ `INSERT ... ON CONFLICT (external_id) DO UPDATE ... RETURNING id`. Under a
101
+ global unique index, application B calling `put({ org: 'acme' })` did not get
102
+ an error — it got application A's org id, and then the resolver helpfully
103
+ added B's user to A's tenant as a `member`.
104
+ - Actor resolution had the same shape, so `as: 'alice'` in application B
105
+ resolved to application A's Alice.
106
+
107
+ A complete cross-tenant compromise, reachable from the most ergonomic entry
108
+ point in the library, requiring no attacker skill beyond picking a common org
109
+ name.
110
+
111
+ #### What changed
112
+
113
+ A scope above the tenant: the **project**, which is one customer application.
114
+ `external_id` is the customer's id space, so it is unique *within* a project and
115
+ meaningless across projects.
116
+
117
+ - New table `project`, plus a default project row
118
+ (`00000000-0000-0000-0000-0000000f11e1`) so that a single-application
119
+ deployment needs no project vocabulary at all.
120
+ - `org` and `actor` gain `project_id`, and their unique constraints become
121
+ `UNIQUE (project_id, external_id)`.
122
+ - Every table that can name an actor carries `project_id` under a composite
123
+ foreign key, so a cross-project membership, ownership, grant subject or grant
124
+ issuer is *unrepresentable* — the same standard cross-tenant access is held
125
+ to, one level up.
126
+ - `project_id` is **derived, never supplied**: a trigger overwrites whatever a
127
+ writer passed with the value read from the owning org.
128
+
129
+ #### The migration
130
+
131
+ For a deployment that has data. Every existing row lands in one project, which
132
+ is exactly what a pre-project deployment was.
133
+
134
+ ```sql
135
+ BEGIN;
136
+
137
+ -- 1. The project table, and the default project every existing row belongs to.
138
+ CREATE TABLE project (
139
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
140
+ key text NOT NULL UNIQUE,
141
+ name text,
142
+ created_at timestamptz NOT NULL DEFAULT now(),
143
+ deleted_at timestamptz
144
+ );
145
+
146
+ INSERT INTO project (id, key, name)
147
+ VALUES ('00000000-0000-0000-0000-0000000f11e1',
148
+ '__filelayer_default_project__',
149
+ 'Default project');
150
+
151
+ -- 2. Drop the global uniqueness that was the defect.
152
+ ALTER TABLE org DROP CONSTRAINT org_external_id_key;
153
+ ALTER TABLE actor DROP CONSTRAINT actor_external_id_key;
154
+
155
+ -- 3. Scope both id spaces to a project.
156
+ ALTER TABLE org ADD COLUMN project_id uuid NOT NULL
157
+ DEFAULT '00000000-0000-0000-0000-0000000f11e1'
158
+ REFERENCES project(id) ON DELETE CASCADE;
159
+ ALTER TABLE actor ADD COLUMN project_id uuid NOT NULL
160
+ DEFAULT '00000000-0000-0000-0000-0000000f11e1'
161
+ REFERENCES project(id) ON DELETE CASCADE;
162
+
163
+ ALTER TABLE org ADD CONSTRAINT org_project_external_key UNIQUE (project_id, external_id);
164
+ ALTER TABLE actor ADD CONSTRAINT actor_project_external_key UNIQUE (project_id, external_id);
165
+
166
+ -- 4. The composite keys that make a cross-project row unrepresentable.
167
+ ALTER TABLE org ADD CONSTRAINT org_id_project_key UNIQUE (id, project_id);
168
+ ALTER TABLE actor ADD CONSTRAINT actor_id_project_key UNIQUE (id, project_id);
169
+
170
+ COMMIT;
171
+ ```
172
+
173
+ Then apply, from the `0.3.0` `schema.sql`, in this order:
174
+
175
+ 1. the `project_id` columns and composite foreign keys on `membership`, `file`
176
+ and `grant`;
177
+ 2. the `project_from_org()` trigger function and its triggers;
178
+ 3. the updated `grant_scope_is_live()` — it now also requires the owning
179
+ project to be live.
180
+
181
+ Copy those blocks verbatim from `schema.sql`; they are commented at the
182
+ constraint that enforces each property. Doing it by hand from this file would be
183
+ transcription, and transcription is how a security migration goes wrong.
184
+
185
+ #### If you would rather not migrate
186
+
187
+ There were no installs at `0.2.x`, so the supported answer is: **drop and
188
+ recreate.** If your data is disposable, that is one command and it is the path
189
+ we took.
190
+
191
+ #### What it costs
192
+
193
+ - Three new columns, three new unique indexes, one trigger per affected table.
194
+ Negligible at any size we can currently defend claims about.
195
+ - The composite foreign keys are validated on `ALTER TABLE`, which takes an
196
+ `ACCESS EXCLUSIVE` lock. On a large `grant` table, add them `NOT VALID` first
197
+ and `VALIDATE CONSTRAINT` afterwards.
198
+
199
+ #### What it breaks in the API
200
+
201
+ Nothing, for a single-application deployment. `Filelayer.quickstart()` and the
202
+ `fl.files` / `fl.orgs` / `fl.shares` facades resolve into the default project
203
+ and read exactly as before. If you run more than one application against one
204
+ database, you must name a project — and before this change you could not, which
205
+ was the whole problem.
206
+
207
+ #### The secondary effect worth knowing
208
+
209
+ Project scoping is also what bounds unauthenticated audit-chain growth. A caller
210
+ probing org ids can only reach a tenant chain inside a project they are already
211
+ authenticated for; probes at every other id land on the system chain. That turns
212
+ "any internet caller can degrade any tenant" into "an authenticated customer can
213
+ degrade their own tenant" — a quota question rather than a security one. Ingest
214
+ rate limiting is still required; see `SEMANTICS.md`.
215
+
216
+ ---
217
+
218
+ ### Entry 2 — `0.3.0` → `0.4.0`: group grant subjects (RFC-001)
219
+
220
+ #### What was missing
221
+
222
+ `grant_subject` was `actor | link | anonymous`. Org-wide access existed only as
223
+ `file.visibility = 'org'`, which applies only to the file's **own** org. So
224
+ "every member of *that* organization may read this file" had no representation
225
+ at all, and "all admins of this org" had none either. Both were being worked
226
+ around with per-user fan-out, which makes membership changes only *eventually*
227
+ consistent with access — the opposite of the property the product sells.
228
+
229
+ #### What changed
230
+
231
+ `grant_subject := actor | org | role | link | anonymous`, plus two columns on
232
+ `file_grant`. Resolution is a join against `membership`, never a
233
+ materialization. See `SEMANTICS.md` §1a and §7b.
234
+
235
+ **This is a breaking schema change and an additive API change.** No existing
236
+ call site changes; `ShareInput.subject` gains two variants and
237
+ `shares.create` gains `withOrg` / `minRole`.
238
+
239
+ #### The migration
240
+
241
+ ```sql
242
+ BEGIN;
243
+
244
+ -- 1. Two new subject types. They must be added in breadth order for the
245
+ -- enum's declaration order to remain meaningful to a reader; nothing in
246
+ -- the code depends on it (the role threshold is generated from
247
+ -- ROLE_RANK in authz.ts, never from the enum's ordinals).
248
+ ALTER TYPE grant_subject ADD VALUE IF NOT EXISTS 'org' AFTER 'actor';
249
+ ALTER TYPE grant_subject ADD VALUE IF NOT EXISTS 'role' AFTER 'org';
250
+
251
+ COMMIT; -- an added enum value must commit before it can be used
252
+
253
+ BEGIN;
254
+
255
+ -- 2. The subject columns. Both nullable; every existing row is unaffected.
256
+ ALTER TABLE file_grant ADD COLUMN subject_org_id uuid;
257
+ ALTER TABLE file_grant ADD COLUMN subject_min_role org_role;
258
+
259
+ -- 3. I1/P8: the subject org must be in the same PROJECT as the file.
260
+ -- `project_id` is derived from the file's org by trigger, so this is
261
+ -- what makes a cross-project group grant unrepresentable.
262
+ -- NOT VALID first on a large table, then VALIDATE, to avoid holding
263
+ -- ACCESS EXCLUSIVE for the scan.
264
+ ALTER TABLE file_grant
265
+ ADD CONSTRAINT file_grant_subject_org_id_project_id_fkey
266
+ FOREIGN KEY (subject_org_id, project_id) REFERENCES org (id, project_id)
267
+ ON DELETE CASCADE NOT VALID;
268
+ ALTER TABLE file_grant VALIDATE CONSTRAINT file_grant_subject_org_id_project_id_fkey;
269
+
270
+ -- 4. Subject coherence, replaced wholesale. Copy the new constraint body
271
+ -- verbatim from schema.sql rather than transcribing it.
272
+ ALTER TABLE file_grant DROP CONSTRAINT grant_subject_coherent;
273
+ -- ...then the five-branch CHECK from schema.sql.
274
+
275
+ CREATE INDEX CONCURRENTLY grant_subject_org_idx ON file_grant (file_id, subject_org_id)
276
+ WHERE revoked_at IS NULL AND subject_org_id IS NOT NULL;
277
+
278
+ COMMIT;
279
+ ```
280
+
281
+ Then replace, verbatim from the `0.4.0` `schema.sql`:
282
+
283
+ 1. `grant_scope_is_live()` — it takes a fourth argument, `p_subject_org_id`, and
284
+ gains the I2 term. **Its five call sites must be updated together**:
285
+ `grant_is_live()` (twice), `live_grant_recursive` (twice) and
286
+ `consume_download()`. Missing one leaves a group grant alive after its
287
+ subject org is deleted.
288
+ 2. `file_grant_attenuate()` — it gains the **I6** check, and its trigger's
289
+ `UPDATE OF` list gains the four subject columns. Without the trigger change,
290
+ a delegated grant can be widened to an entire organization by a second
291
+ `UPDATE` statement.
292
+
293
+ #### What it costs
294
+
295
+ Two nullable columns, one partial index, one foreign key. The added subject-org
296
+ liveness term costs **+6.5%** on an eight-deep grant lookup, and the two extra
297
+ columns account for a further low-single-digit percentage through row width.
298
+ A group-grant decision is **~1.9 ms** against **~1.3 ms** for an actor-grant
299
+ decision on PGlite — one extra query, one join, and **independent of the size of
300
+ the named org**: the figure is measured against a 200-member org holding two
301
+ grant rows in total. Reproduce the timings with `npm run dev:bench` from the
302
+ repository root of a clone.
303
+
304
+ Authorization via an **org role** is unchanged (0.61 ms before and after):
305
+ standing resolution reaches the role first and returns before the group lookup
306
+ is issued.
307
+
308
+ #### What it does not do
309
+
310
+ No custom groups, no nested orgs, no configurable inheritance, no deny rules.
311
+ `file.visibility = 'org'` is retained and is unchanged.
312
+
313
+ ---
314
+
315
+ ## 4. What is not covered here
316
+
317
+ - **Data migration between storage adapters.** Moving objects from one bucket to
318
+ another is outside the library. `file.storage_provider` and
319
+ `file.storage_key` form a unique pair and are half of an object's identity, so
320
+ changing `provider` on an existing deployment repoints every row at a store
321
+ that does not have the bytes. There is no supported path for this yet.
322
+ - **Downgrades.** None of the entries above has a reverse script. Restore from a
323
+ backup.
324
+ - **Audit chain rewriting.** By construction there is none: `audit_event` has
325
+ rules that make `UPDATE` and `DELETE` no-ops, and the chain is verified by
326
+ recomputation. A migration that needed to rewrite history would invalidate
327
+ every subsequent hash, and we would rather that be impossible than
328
+ documented.
package/NOTICE ADDED
@@ -0,0 +1,37 @@
1
+ Filelayer
2
+ Copyright 2026 Technology Pro Bono S.L.
3
+
4
+ This product includes software developed by the Filelayer project
5
+ (https://github.com/filelayer/filelayer).
6
+
7
+ Licensed under the Apache License, Version 2.0. See the LICENSE file in
8
+ this distribution, or http://www.apache.org/licenses/LICENSE-2.0.
9
+
10
+ --------------------------------------------------------------------------
11
+ THIRD-PARTY SOFTWARE (informational)
12
+ --------------------------------------------------------------------------
13
+
14
+ The following is recorded for the convenience of downstream redistributors
15
+ and anyone reviewing this distribution's licence obligations. It is
16
+ NOT a required attribution notice under Apache-2.0 section 4(d), and
17
+ redistributors of Filelayer do not inherit an obligation to reproduce it.
18
+
19
+ Filelayer has exactly one runtime dependency:
20
+
21
+ @electric-sql/pglite
22
+ PostgreSQL compiled to WebAssembly, used for the embedded database in
23
+ `Filelayer.quickstart()` and in the test suite.
24
+ Licensed under the Apache License, Version 2.0.
25
+ https://github.com/electric-sql/pglite
26
+
27
+ It is declared as a normal npm dependency and is installed from the
28
+ registry by the consumer. No part of it is vendored into, copied into, or
29
+ redistributed inside this repository or the published tarball, so this
30
+ distribution is not a Derivative Work of it. It ships no NOTICE file of
31
+ its own, so Apache-2.0 section 4(d) imposes no propagation requirement
32
+ even where a redistributor bundles it.
33
+
34
+ No other third-party code is vendored, copied or embedded in this
35
+ distribution. The comparison implementations under `benchmark/` are original
36
+ work written for this repository; they are not published to npm and are not
37
+ part of the distributed package.