Open-source Node library · Apache-2.0 · 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.33.0. The schema can still change in any 0.x release. Every number behind that word →
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' }); // this link, now
// For a named person, unshare: create() is not idempotent, so one grant id
// is not necessarily all of their access.
await fl.shares.unshare(id, { as: 'user_123', user: 'user_456' });
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. It arrives 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. It stops working on the next request.
Why a presigned URL cannot do this, and what to write if you would rather build it yourself: Expiring and revocable file links → · also private uploads and multi-tenant access →
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. That is the half you need during 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
Tiers 1 to 3 each run from the repository with one command. Sharing and the audit trail come together in the B2B document workspace, which 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 and not in a footnote.
Production is three configuration steps and is not hidden: your Postgres, somewhere for
the bytes — a local directory with FsStorage, no bucket and no IAM user,
or your S3/R2 bucket — and, if you chose a bucket, confirming it is private.
examples/starter →
is all three in one file you can copy.
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 first. It includes 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. The YAML is in the repository; the tarball ships the JSON.
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
729 tests across 161 suites travel with the package, so every behavioural claim on this page is checkable from what you installed. It takes two minutes and one extra dependency, and CI runs the published procedure against a freshly packed tarball on every commit: run them yourself →
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. There is no hosted API to put behind one.
The state of it, in numbers
Every figure below is produced by a job you can read, on every commit, and is reproducible from the repository. The full set — including what this project has not earned yet — is on the trust page, which is where we keep the numbers that do not flatter us.
- Version0.33.0 — alpha
- Storage adapter against live Cloudflare R212 tests, every commit
- Storage adapter against live AWS S312 tests, every commit
- Tests against a real PostgreSQL, every commit729 across 161 suites
- Concurrency, against a real PostgreSQL8 tests, every commit
- Adversarial suite27 attacks, 0 breaches
- Runtime dependencies0
- LicenceApache-2.0, with a patent grant
Production deployments, maintainers, independent review and the rest of the set: the trust page →
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.
- Every request is re-authorized, so revocation beats a live URL.
- Every decision is recorded, including denials, hash-chained and append-only.
The one precondition
- Filelayer cannot make your bucket private. Keys are
orgId/fileIdand are not secrets, so a public bucket bypasses everything above it. Confirming that setting is the one step of going to production we cannot do for you.
Where else Filelayer is the wrong tool (no CDN on the default path, no transformations, no resumable upload): the trust page has the full list →
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.