Open-source Node library · Apache-2.0 · v0.4.4 alpha
The file layer for SaaS applications.
Every application that stores files ends up rebuilding the same thing: who owns this file, who can reach it, how it gets shared, and when that ends. Filelayer is that model, built once, on the S3 or R2 bucket you already own — from a public avatar to a contract with a link you can take back.
Keep your storage. You are adding a dependency, not a vendor.
npm install @filelayer/core
Alpha — developer preview. v0.4.4, zero known production deployments, one maintainer. The numbers, including the ones that are zero →
One file model, from simple to complex
A public avatar and a board deck under legal hold are the same row, authorized by the same function, on the same table. There is no second code path for the simple case, and no migration when the simple case grows up.
// A public avatar
const { url } = await fl.files.put(bytes, { public: true });
// The same file, now owned by someone
const { id } = await fl.files.put(bytes, { owner: 'user_123' });
const file = await fl.files.get(id, { as: 'user_123' });
// The same file, now inside a tenant with roles
await fl.files.put(bytes, { org: 'acme', owner: 'user_123' });
// The same file, now shared — with an expiry, a cap, and revocable
const share = await fl.shares.create(id, {
as: 'user_123', expiresIn: 3600, maxDownloads: 3, password: 'hunter2',
});
await fl.shares.revoke(share.grantId, { as: 'user_123' }); // takes effect now
Five concepts, and you only meet the ones you use.
Don't rebuild file semantics in every application
Storage is solved. What is not solved is everything that attaches to a file once real people start using your product — and it attaches one question at a time, usually in this order.
Ownership
The file belongs to a person, and that person is not the account that uploaded it.
Organizations
It belongs to a tenant too, and two tenants must never see each other's rows.
Access
An admin, a member and a viewer are not the same reader, and the difference is per file.
Sharing
Someone outside the org needs it, without an account and without a password reset.
Expiration
Not forever. For an hour, or three downloads, whichever runs out first.
Revocation
The link went to the wrong address. It has to stop working now, not at the TTL.
Lifecycle
Retention, legal hold, and deletion that does not quietly cascade through your grants.
Audit
Someone asks who opened it — and, in an incident, who was refused.
Each of these is maybe fifty lines. The cost is that they end up as fifty lines in your application, in different route handlers, written at different times by different people. Filelayer moves them behind one interface so a new file path cannot quietly skip one.
How it fits
You tell Filelayer who the caller is. It decides what they may do, serves the bytes with the right headers, and writes the audit event.
Your bucket keeps the bytes. Your Postgres keeps the metadata, the grants and the audit chain. Filelayer owns neither — it owns the model between them. Nothing moves, and nothing about your storage bill changes.
Node ≥ 22.18 and a Postgres you already run. The package installs zero runtime dependencies.
For file access that goes through Filelayer, the decision is made in one place. That is the whole value of the middle layer: you write no ownership checks in route handlers, no presigned-URL expiry logic, and no per-route access rules of your own. It is authorization middleware, not row-level security — a client that queries your Postgres directly bypasses it. Where that boundary is, exactly →
From an avatar to an audit trail
The same API the whole way up. Each rung adds one concept, and no rung makes you pay for a concept you are not using.
1 · Public files — avatars, images, PDFs, exports
One call and a URL. Public is a grant rather than a bucket setting, which is why you can take it back later without deleting the object or rotating a key.
const { url } = await fl.files.put(bytes, { public: true });
// later — the URL is printed, indexed, pasted into a ticket
await fl.files.unpublish(id);
// the next request 404s. No deletion, no key rotation, no cache purge.
2 · Files that belong to a user
An owner is the only new concept. A typo in a user id denies rather than quietly becoming an anonymous read.
const { id } = await fl.files.put(bytes, { owner: 'alice' });
await fl.files.get(id, { as: 'alice' }); // bytes
await fl.files.get(id, { as: 'bob' }); // throws 404
await fl.files.get(id); // throws 404
3 · Files that belong to an organization
Add org: and a role model appears. No migration, no second table to keep
in step, no tenant predicate to repeat in every route.
await fl.orgs.create('acme', { name: 'Acme Inc', owner: 'ceo' });
await fl.orgs.setRole('acme', 'analyst', 'member', { as: 'ceo' });
const { id } = await fl.files.put(deck, {
org: 'acme', owner: 'ceo', name: 'board-deck.pdf',
});
await fl.files.get(id, { as: 'analyst' }); // 404 insufficient_role
4 · Files shared outside the org
An expiry, a password and a download cap on one call. Then the link goes somewhere it should not have, and you take it back — on the next request, not at the TTL.
const share = await fl.shares.create(id, {
as: 'ceo', expiresIn: 3600, maxDownloads: 3, password: 'hunter2',
});
await fl.shares.redeem(share.secret, { password: 'hunter2' }); // bytes
// ...forwarded to someone it should not have been
await fl.shares.revoke(share.grantId, { as: 'ceo' });
await fl.shares.redeem(share.secret, { password: 'hunter2' }); // 404 grant_revoked
5 · Lifecycle, and the record of it
Retention, legal hold, deletion — and a trail answered in your identifiers rather than internal UUIDs, without you writing a query.
Hash-chained per tenant and append-only by database rule. The refusals are in it too, which is what makes it useful in an incident.
for (const row of await fl.orgs.audit('acme', { as: 'ceo' }))
console.log(row.summary);
ceo file.create allow board-deck.pdf @acme
analyst file.read deny:insufficient_role board-deck.pdf @acme
ceo file.share allow board-deck.pdf @acme
anonymous file.read allow board-deck.pdf @acme
ceo grant.revoke allow board-deck.pdf @acme
anonymous file.read deny:grant_revoked board-deck.pdf @acme
All five run from the repository with one command each, and the full B2B document workspace runs over HTTP: examples/ →
One case where a bucket is still the better answer today: public assets served at CDN volume. Filelayer's default byte path goes through your process, so there is no CDN in front of it. Filelayer earns its place when a file needs an owner, an expiry, or a link you can take back.
Install, and have a file in the same session
No account. No API key. Nothing to sign up for.
quickstart() runs Postgres in-process and keeps bytes in memory, so you can
exercise the whole model before configuring anything. It is ephemeral — everything is
lost when the process exits.
Keep the PGlite version constraint. The supported line is
0.3.x; npm's latest is 0.5.x, and installing that
makes npm refuse the tree with ERESOLVE. This is the most likely first-run
failure, so it is on the page rather than in a footnote.
Production is three configuration steps and is not hidden — your Postgres, your bucket, and confirming the bucket is private. Quickstart §7 →
npm install @filelayer/core
npm install --save-dev "@electric-sql/pglite@^0.3.11"
import { Filelayer } from '@filelayer/core';
const fl = await Filelayer.quickstart({ baseUrl: 'http://localhost:3000' });
const bytes = new TextEncoder().encode('hello');
const { id, url } = await fl.files.put(bytes, { public: true });
url is served by a route you mount — three more lines with
fileDownloadRoute(), and the quickstart has them.Readable by machines, not just by people
Every surface below already exists in the repository and ships inside the npm tarball. This is not a roadmap section.
llms.txt
The integration map, ordered by what an implementer has to get right rather than by what is interesting — including a "things that will bite you" section, because the expensive failures are all in that list.
OpenAPI 3.1
The complete HTTP surface, and it is honestly two byte-delivery routes. The description says so, and states the precondition it cannot enforce: your bucket must be private.
schema.sql
Every property is commented at the constraint that enforces it. Resolvable from an
install at node_modules/@filelayer/core/schema.sql.
The tests ship in the tarball
324 tests across 74 suites travel with the package, so every behavioural claim on this page is checkable from what you installed rather than from what we say.
Using a coding agent? Point it at the map:
Add Filelayer to my app: https://github.com/filelayer/filelayer/blob/main/llms.txt
No MCP server yet, and we would rather say so than ship one for the announcement — there is no hosted API to expose over it.
The state of it, in numbers
Filelayer is alpha and nobody runs it in production. Should you depend on it? Probably not yet — and this section exists so you can decide that quickly instead of inferring it from what a landing page does not say.
- Version0.4.4 — alpha
- Known production deployments0
- Maintainers with commit rights1
- Independent security reviewnone
- Storage adapter against live AWS / Cloudflarenever run
- Tests, on Node 22 / 24 / 26, every commit324 across 74 suites
- Adversarial suite27 attacks, 0 breaches
- Runtime dependencies0
- LicenceApache-2.0, with a patent grant
What Filelayer guarantees
- One decision point for every file access that goes through the library.
- Cross-tenant grants cannot be written — a composite foreign key, binding every writer including
psql. - Delegation cannot amplify authority — a database trigger, binding raw SQL too.
- A signed URL is re-validated on every request, so revocation beats a live link.
- Every decision is recorded, including denials, hash-chained and append-only.
What it does not
- It is not row-level security. A client querying your Postgres directly is not filtered and writes no audit event.
- It cannot make your bucket private. Keys are
orgId/fileIdand are not secrets. A public bucket bypasses the rest. - No CDN on the default byte path. Revocation and no-CDN are the same fact.
- No
Rangein the shipped routes, so no video seeking out of the box. - No thumbnails, transformations or format negotiation.
- No direct browser-to-storage upload.
- Org admins and owners can read
privatefiles. Deliberate.
What would change the numbers above, in the order it matters: production deployments that are
not ours; the storage adapter proven against live R2 and S3 in CI; an independent review of
schema.sql and authz.ts published in full; 1.0 with a frozen schema;
a second maintainer. We report against that list publicly, including when a number stays at zero.
The full trust page → · Security policy → · Where we are the wrong tool →
Give your files a model
Not a demo account — a file you actually own, in a process you actually run.
npm install @filelayer/core
If something on this page confused you, that is a defect and we want the report.