@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/README.md ADDED
@@ -0,0 +1,343 @@
1
+ # Filelayer
2
+
3
+ [![CI](https://github.com/filelayer/filelayer/actions/workflows/ci.yml/badge.svg)](https://github.com/filelayer/filelayer/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@filelayer/core.svg)](https://www.npmjs.com/package/@filelayer/core)
5
+ [![node](https://img.shields.io/node/v/@filelayer/core.svg)](https://nodejs.org)
6
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/filelayer/filelayer/blob/main/LICENSE)
7
+
8
+ <!-- The first three badges render as "unknown" until the repository is pushed
9
+ and the package is published. Every URL on this page is rooted at
10
+ github.com/filelayer/filelayer and npmjs.com/package/@filelayer/core; both
11
+ namespaces are unclaimed and reserved for this project, so the badges
12
+ resolve on their own with no edit here. The licence badge is live now. -->
13
+
14
+ > ## ⚠️ Alpha — developer preview. Not production software.
15
+ >
16
+ > We would rather you trust us later for good reasons than trust us now for bad
17
+ > ones, so here is the honest state of this project.
18
+ >
19
+ > **What is tested.** The authorization engine is the part we stand behind. It
20
+ > carries a property suite covering cross-tenant isolation, revocation,
21
+ > delegation attenuation, download caps, audit tamper-evidence and the full role
22
+ > matrix, plus a differential test asserting that the set query and the point
23
+ > check agree exactly, plus an adversarial suite of attacks it must survive. All
24
+ > of it runs in CI on every commit, against real PostgreSQL. A release gate packs
25
+ > the tarball, installs it into an empty directory and drives the whole lifecycle
26
+ > as a stranger would.
27
+ >
28
+ > **What has never run against live infrastructure.** The S3/R2 storage adapter
29
+ > has **never been executed against real AWS or Cloudflare credentials.** It is
30
+ > exercised against a local implementation that verifies SigV4 signatures, which
31
+ > is not the same thing and we will not pretend it is. A live-credential suite
32
+ > exists and runs automatically once `FILELAYER_TEST_S3_*` is in the
33
+ > environment; nobody has supplied it yet.
34
+ >
35
+ > **What may break.** Nobody has deployed this. There is no production usage, no
36
+ > hosted service, no CLI, and no operational track record — so the failure modes
37
+ > that only appear under real traffic, real object stores and real connection
38
+ > pools are unmeasured. Expect to be the person who finds them.
39
+ >
40
+ > **The schema may change before 1.0.** It has already had one breaking change.
41
+ > Any `0.x` → `0.(x+1)` may break the API, the schema, or both. Every break is
42
+ > in the [changelog](https://github.com/filelayer/filelayer/blob/main/packages/core/CHANGELOG.md)
43
+ > and every schema break ships with SQL in
44
+ > [MIGRATIONS.md](https://github.com/filelayer/filelayer/blob/main/packages/core/MIGRATIONS.md),
45
+ > but there is no long-term support branch and no backporting.
46
+ >
47
+ > **Use it** to evaluate the model, to build something that is not yet carrying
48
+ > customer data, or to tell us where it breaks. **Do not use it** as the file
49
+ > layer under a production system you would be embarrassed to lose. The full
50
+ > list of known gaps is in [Limitations](#limitations) below; nothing there is
51
+ > hidden in an appendix.
52
+
53
+ **The file layer for SaaS applications.** Public files and private files, with
54
+ one authorization model behind both.
55
+
56
+ You tell Filelayer who the caller is. Filelayer decides what they may do with a
57
+ file, serves the bytes with the right headers, and writes the audit event. You
58
+ do not write authorization rules, RLS policies, bucket ACLs, ownership checks in
59
+ route handlers, or presigned-URL expiry logic — because there is exactly one
60
+ place a decision is made, and it is not in your application.
61
+
62
+ <!-- doccheck-setup
63
+ import { Filelayer } from '@filelayer/core';
64
+ const fl = await Filelayer.quickstart({ baseUrl: 'http://localhost:3000' });
65
+ const bytes = new TextEncoder().encode('hello');
66
+ -->
67
+
68
+ ```ts
69
+ // Public avatar
70
+ const { url } = await fl.files.put(bytes, { public: true });
71
+
72
+ // Private, user-owned
73
+ const { id } = await fl.files.put(bytes, { owner: 'user_123' });
74
+ const file = await fl.files.get(id, { as: 'user_123' });
75
+
76
+ // Multi-tenant, role-controlled
77
+ await fl.files.put(bytes, { org: 'acme', owner: 'user_123' });
78
+
79
+ // Shared with an expiry, a password and a download cap — and revocable
80
+ const share = await fl.shares.create(id, {
81
+ as: 'user_123', expiresIn: 3600, maxDownloads: 3, password: 'hunter2',
82
+ });
83
+ await fl.shares.revoke(share.grantId, { as: 'user_123' }); // takes effect now
84
+ ```
85
+
86
+ **Start at whichever line matches your problem.** Complexity is incremental:
87
+ each tier adds one concept, and no tier makes you pay for a concept you are not
88
+ using. → [`docs/QUICKSTART.md`](https://github.com/filelayer/filelayer/blob/main/docs/QUICKSTART.md)
89
+
90
+ ---
91
+
92
+ ## Status: pre-release. Read this part.
93
+
94
+ Filelayer is pre-1.0 and has not been deployed by anyone. This README is
95
+ accurate rather than promotional, because you are more likely to be an AI agent
96
+ reading it to write an integration than a human reading it to be persuaded, and
97
+ an inaccurate README wastes your time and ours.
98
+
99
+ - **Licensed under [Apache-2.0](https://github.com/filelayer/filelayer/blob/main/LICENSE).**
100
+ You may use it, modify it, distribute it and ship it inside commercial
101
+ software, with a patent grant. This was the single largest blocker to adoption
102
+ and it is resolved: `packages/core/package.json` declares
103
+ `"license": "Apache-2.0"`, and both `LICENSE` and
104
+ [`NOTICE`](https://github.com/filelayer/filelayer/blob/main/NOTICE) ship
105
+ inside the npm tarball. See [License](#license) below.
106
+ - The authorization core is tested: a property suite covering cross-tenant
107
+ isolation, revocation, delegation attenuation, download caps, audit
108
+ tamper-evidence and the full role matrix, plus a differential test that
109
+ asserts the set query and the point check agree exactly.
110
+ - The S3/R2 storage adapter is exercised against a **signature-verifying local
111
+ S3 implementation** (`packages/core/test/storage.test.ts`). It has **never
112
+ been run against live AWS or Cloudflare credentials.** A live-credential suite
113
+ exists (`packages/core/test/s3-live.test.ts`) and runs automatically when
114
+ `FILELAYER_TEST_S3_*` is present in the environment; nobody has supplied it.
115
+ - There is no hosted service and no CLI. You run it against your own Postgres.
116
+ - Versioning is pre-1.0: see [Versioning](#versioning) below and
117
+ [`packages/core/MIGRATIONS.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/MIGRATIONS.md).
118
+
119
+ ---
120
+
121
+ ## What it is for, and what it is not for
122
+
123
+ **Worth the overhead when:** files are private, belong to specific people or
124
+ tenants, and their permissions change over time. Documents, contracts,
125
+ attachments, exports, anything with a share link you might later want back.
126
+
127
+ **Not worth the overhead when:**
128
+
129
+ | You need | Use instead | Why |
130
+ |---|---|---|
131
+ | Public images at CDN volume | a CDN-backed bucket | Default delivery proxies every byte. Redirect delivery (below) removes the proxy but is opt-in and narrow. |
132
+ | Video or audio seeking in a browser | a CDN / media service | The shipped HTTP route helpers do not answer `Range` requests. |
133
+ | Direct browser → storage upload | Supabase / presigned S3 | Uploads go through your server. |
134
+ | Thumbnails, transforms, format negotiation | Cloudinary / imgix | We have none. |
135
+
136
+ We publish the full comparison, including the cases we lose, in
137
+ [`ARCHITECTURE-PROGRESSIVE.md`](https://github.com/filelayer/filelayer/blob/main/ARCHITECTURE-PROGRESSIVE.md)
138
+ §5. Short version: **for a public avatar, Supabase is 10 lines and Filelayer is
139
+ 16.** If avatars are your whole problem, use Supabase.
140
+
141
+ ---
142
+
143
+ ## The five security properties
144
+
145
+ These are the product. Each is enforced in the schema or the authorization
146
+ engine, not by convention, and each has tests named after it.
147
+
148
+ | | Property | What it means in practice |
149
+ |---|---|---|
150
+ | **P1** | Deny by default | There is no `public` boolean anywhere in the schema. Public delivery is an explicit, revocable, auditable grant row. "The bucket was public" is not expressible. |
151
+ | **P2** | No ambient authority | Knowing an object key, a URL or a file id grants nothing. Storage location is never an input to a decision. |
152
+ | **P3** | Structural tenant isolation | A grant's `org_id` must equal its file's `org_id`, enforced by a composite foreign key. Cross-tenant access is unrepresentable, not merely prevented by a `WHERE` clause. |
153
+ | **P4** | A URL never outlives its permission | Every signed URL embeds a grant id and is re-validated on **every** request, transitively through the whole delegation chain. Revocation beats a live URL. |
154
+ | **P5** | Every decision is audited, including denials | Hash-chained per tenant. Probes that cannot be attributed to a tenant go to a system chain rather than being dropped. |
155
+
156
+ Three more properties are enforced in the schema and documented in
157
+ [`packages/core/SEMANTICS.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/SEMANTICS.md): atomic download
158
+ counters (**P6**), deletion as a liveness predicate rather than a cascade
159
+ (**P7**), and per-project identifier namespaces (**P8**).
160
+
161
+ Two consequences worth knowing before you adopt:
162
+
163
+ - **Revocation actually works.** `fl.files.unpublish(id)` makes a URL that has
164
+ been printed, indexed and pasted into a support ticket stop working on the
165
+ next request — no deletion, no key rotation, no cache purge. Supabase's
166
+ `getPublicUrl()` is offline string concatenation, so it has no request at
167
+ which to make that decision; deleting the object is the only withdrawal.
168
+ - **This costs you a byte path.** P4 is why the default is to serve bytes rather
169
+ than hand out a presigned URL, and it is why the default path has no CDN in
170
+ front of it. The two facts are the same fact. Redirect delivery trades a
171
+ bounded revocation window for that CDN and is opt-in; see
172
+ [`packages/core/SEMANTICS.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/SEMANTICS.md).
173
+
174
+ ---
175
+
176
+ ## Install
177
+
178
+ ```bash
179
+ npm install @filelayer/core
180
+ ```
181
+
182
+ Requires **Node ≥ 22.18**. The package ships compiled JavaScript and type
183
+ declarations in `dist/`; the TypeScript source and the full test suite are in
184
+ the tarball as well, so every claim on this page is inspectable from what you
185
+ installed.
186
+
187
+ ```ts
188
+ import { Filelayer } from '@filelayer/core';
189
+
190
+ const fl2 = await Filelayer.quickstart(); // PGlite + in-memory bytes
191
+ const { url: avatarUrl } = await fl2.files.put(bytes, { public: true });
192
+ ```
193
+
194
+ `quickstart()` is **ephemeral** — everything is lost when the process exits.
195
+ Production is three configuration steps and is not hidden:
196
+ [`docs/QUICKSTART.md`](https://github.com/filelayer/filelayer/blob/main/docs/QUICKSTART.md)
197
+ §6.
198
+
199
+ ### Running the suite
200
+
201
+ The tests are in the tarball but they cannot be executed from inside
202
+ `node_modules`: Node refuses to strip types from files under `node_modules`, and
203
+ the tests import the TypeScript source directly. To run them, clone the
204
+ repository — tests run against PGlite, PostgreSQL 17 compiled to WebAssembly and
205
+ running in-process, so there is no daemon and no Docker:
206
+
207
+ ```bash
208
+ git clone https://github.com/filelayer/filelayer && cd filelayer
209
+ npm run bootstrap # npm ci in packages/core
210
+ npm test # the security property suite, 313 tests
211
+ npm run typecheck
212
+ npm run verify # typecheck + tests + build + doc and language checks
213
+ npm run example:tier1 # a public avatar, on :3000
214
+ npm run example:vault # the full Vault app, on :8787
215
+ ```
216
+
217
+ ---
218
+
219
+ ## Versioning
220
+
221
+ Pre-1.0. The version is `0.MINOR.PATCH` and the promise is deliberately narrow:
222
+
223
+ - **`0.x` → `0.(x+1)`** may break the API, the schema, or both. Every break is
224
+ in [`packages/core/CHANGELOG.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/CHANGELOG.md), and every
225
+ schema break has a migration in
226
+ [`packages/core/MIGRATIONS.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/MIGRATIONS.md).
227
+ - **`0.x.y` → `0.x.(y+1)`** is additive or a fix. No schema change that requires
228
+ action, no signature change.
229
+ - There is no long-term support branch and no backporting before 1.0.
230
+
231
+ The schema has already had one breaking change (per-project identifier
232
+ namespaces). It is written up, with the SQL, as the first entry in
233
+ [`packages/core/MIGRATIONS.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/MIGRATIONS.md).
234
+
235
+ ---
236
+
237
+ ## Layout
238
+
239
+ | Path | What it is |
240
+ |---|---|
241
+ | `packages/core/schema.sql` | The data model. Every security property is commented at the constraint that enforces it. Read this first if you are reviewing us. |
242
+ | `packages/core/src/authz.ts` | The authorization engine. Two functions, one decision core. |
243
+ | `packages/core/src/simple.ts` | The tiered API (`files`, `orgs`, `shares`). No authorization logic — a facade. |
244
+ | `packages/core/src/delivery.ts` | Byte delivery. Owns the response headers so you cannot get them wrong. |
245
+ | `packages/core/test/` | The property suite. Ships in the tarball. |
246
+ | `packages/core/dev/` | Diagnostic scripts. Not published. |
247
+ | `examples/tier1-avatar` … `tier3-org-roles` | One runnable example per tier |
248
+ | `examples/vault` | A full B2B document workspace over HTTP |
249
+
250
+ ## Documents
251
+
252
+ - [`docs/QUICKSTART.md`](https://github.com/filelayer/filelayer/blob/main/docs/QUICKSTART.md) — install → first file → the advanced capabilities
253
+ - [`packages/core/SEMANTICS.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/SEMANTICS.md) — the exact behaviour of every edge: liveness, delegation, deletion, the audit chain
254
+ - [`packages/core/MIGRATIONS.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/MIGRATIONS.md) — how schema changes are delivered and what the pre-1.0 compatibility promise is
255
+ - [`packages/core/CHANGELOG.md`](https://github.com/filelayer/filelayer/blob/main/packages/core/CHANGELOG.md) — the real history, including the security defects we found in ourselves
256
+ - [`ARCHITECTURE-PROGRESSIVE.md`](https://github.com/filelayer/filelayer/blob/main/ARCHITECTURE-PROGRESSIVE.md) — the tier model, lines of code versus the alternatives, and where we lose
257
+ - [`architecture/TIER5-DESIGN-NOTE.md`](https://github.com/filelayer/filelayer/blob/main/architecture/TIER5-DESIGN-NOTE.md) — large files, streaming, range requests, CDN
258
+ - [`llms.txt`](https://github.com/filelayer/filelayer/blob/main/llms.txt) — the same map, written for an agent implementing an integration
259
+ - [`openapi.yaml`](https://github.com/filelayer/filelayer/blob/main/openapi.yaml) — the two HTTP routes the library serves, as OpenAPI 3.1
260
+
261
+ ## License
262
+
263
+ **[Apache-2.0](https://github.com/filelayer/filelayer/blob/main/LICENSE).**
264
+ Commercial use, modification, distribution and private use are permitted, with
265
+ an express patent grant and a patent-retaliation termination clause. You must
266
+ preserve the copyright and licence notices and state significant changes; there
267
+ is no copyleft obligation on your own code.
268
+
269
+ ```
270
+ SPDX-License-Identifier: Apache-2.0
271
+ Copyright 2026 Technology Pro Bono S.L.
272
+ ```
273
+
274
+ `LICENSE` is the unmodified licence text from apache.org.
275
+ [`NOTICE`](https://github.com/filelayer/filelayer/blob/main/NOTICE) carries the
276
+ attribution notice required by section 4(d); both are inside the published npm
277
+ tarball, so the grant travels with the artifact rather than only with the
278
+ repository. There are no per-file licence headers — the grant is carried by
279
+ `LICENSE`, `NOTICE` and the `license` field of every `package.json`.
280
+
281
+ **Dependencies.** Filelayer has exactly one runtime dependency,
282
+ [`@electric-sql/pglite`](https://github.com/electric-sql/pglite), itself
283
+ Apache-2.0. It is installed from the registry rather than vendored, ships no
284
+ `NOTICE` file of its own, and is recorded in ours for convenience. No
285
+ third-party code is copied or embedded anywhere in this repository.
286
+
287
+ ## Contributing, security and conduct
288
+
289
+ - [`CONTRIBUTING.md`](https://github.com/filelayer/filelayer/blob/main/CONTRIBUTING.md)
290
+ — how to set up, what `npm run verify` covers, and what a pull request needs.
291
+ - [`SECURITY.md`](https://github.com/filelayer/filelayer/blob/main/SECURITY.md)
292
+ — **report vulnerabilities privately**, never as a public issue. Includes our
293
+ disclosure timetable and what is in and out of scope.
294
+ - [`CODE_OF_CONDUCT.md`](https://github.com/filelayer/filelayer/blob/main/CODE_OF_CONDUCT.md)
295
+ — Contributor Covenant 2.1.
296
+
297
+ Bugs, questions and "this document is wrong" reports go to
298
+ [GitHub Issues](https://github.com/filelayer/filelayer/issues). Given the alpha
299
+ status at the top of this page, a report that the product does not do what this
300
+ README says is the most valuable thing you can send us.
301
+
302
+ ## Limitations
303
+
304
+ Restated here so they are not only in an appendix. Each one is current as of
305
+ `0.3.0`; where a limitation has been lifted since an earlier release, the
306
+ [changelog](https://github.com/filelayer/filelayer/blob/main/packages/core/CHANGELOG.md) says so.
307
+
308
+ 1. **No `Range` responses from the shipped HTTP routes.** `fileDownloadRoute()`
309
+ and `shareDownloadRoute()` do not parse the `Range` request header, so they
310
+ never return `206` and a browser cannot seek. Do not use the shipped routes
311
+ for video or audio. The layer underneath is complete: `readStream()` and
312
+ `redeemStream()` take a byte range, the S3 adapter honours it, and
313
+ `sendNodeStream()` / `toStreamResponse()` emit `206` with `Content-Range`
314
+ whenever a range was served. Parsing the request header is the part you
315
+ write — see
316
+ [QUICKSTART §6](https://github.com/filelayer/filelayer/blob/main/docs/QUICKSTART.md)
317
+ for the whole thing.
318
+ 2. **The tiered facade `fl.files.put()` takes a `Uint8Array`**, so a file put
319
+ through it is fully resident in memory. The core `fl.upload()` accepts a
320
+ `ReadableStream`; use that above a few tens of megabytes.
321
+ 3. **No direct browser → storage upload.** Upload bytes go through your server.
322
+ 4. **Org admins and owners can read `private` files.** Deliberate — retention
323
+ and legal hold are their responsibility — but if you need to exclude the
324
+ operator, you need envelope encryption and we do not have it.
325
+ 5. **Identifiers are unique per *project*, not per org.** `actor.external_id`
326
+ and `org.external_id` are scoped to a project (one customer application). Two
327
+ orgs inside one project cannot both have a user called `alice` meaning
328
+ different people.
329
+ 6. **The S3/R2 adapter has never run against live credentials.** It is tested
330
+ against a local implementation that verifies SigV4 signatures, which is not
331
+ the same thing.
332
+ 7. **Unauthenticated callers can still append denial events to the audit chain
333
+ of a tenant inside a project they can reach.** That is P5 working as designed
334
+ — denials are the events worth recording — but it is a load-bearing reason to
335
+ rate-limit at ingest. `orgExists` is project-scoped, so the reach is bounded
336
+ to a project the caller is already authenticated for.
337
+ 8. **Orphan collection is a job you have to run.** Bytes are written before the
338
+ metadata commits, so a crash in between leaves an unreferenced object.
339
+ `collectStorageOrphans()` cleans them up and nothing calls it for you.
340
+ 9. **Redirect delivery has a revocation window.** If you enable it, a presigned
341
+ URL stays valid for up to its TTL after the grant is revoked. It is off by
342
+ default, defaults to anonymous grants only, and requires passing a verbatim
343
+ acknowledgement string. That string is the point.