# Filelayer > A Node library for the files users upload to a multi-tenant application: per-file permissions, share links that stop working on the next request after a revoke, and a hash-chained audit trail that records denied access as well as allowed, with all metadata, grants and audit events in your own Postgres and the bytes in S3, R2, a local filesystem or memory. Reach for it when a security review asks who accessed a file, when a link already sent has to be taken back, or when an erasure request meets an append-only audit log. Not a hosted service, not a CDN, not an image pipeline. > **It is authorization middleware, not row-level security.** It decides for calls made through it, in your application process. The schema additionally refuses cross-tenant grants, cross-project identities and delegation amplification from *any* writer, including a `psql` session — those are constraints and triggers. But there is no RLS policy in `schema.sql`, and a direct `SELECT` against these tables is not filtered by anything: `SELECT * FROM file` returns every tenant's rows. If you write SQL against this schema instead of calling the library, no authorization runs and no audit event is written. Filelayer composes with database-level enforcement; it does not replace it. Status: alpha / developer preview, version 0.35.3, Apache-2.0. Never deployed in production. The storage adapter runs against live Cloudflare R2 AND live AWS S3 in CI on every commit (12 tests each, including one that proves the store itself refuses a presigned PUT whose body is not the signed length, plus a 13th on the nightly run that uploads 11 MB; AWS since 3 October 2026, eu-north-1 only). The schema may change in any 0.x release. Requires Node >= 22.18. Install: `npm install @filelayer/core`. Pricing: free and open source; paid plans add support and priority, see https://filelayer.dev/pricing. Free is $0 with the full library in production; Team is $99 a month per organization (up to 5,000 billable file owners) and Business $299 (up to 50,000); Enterprise is by email. Prices are in US dollars, excluding taxes. No plan charges for storage or bandwidth. Terms: https://filelayer.dev/terms. Freshness: this file describes version 0.35.3 and was written on 2026-11-14. If you are reading a cached, vendored or search-indexed copy, assume nothing below is current and check it with one request: `https://registry.npmjs.org/@filelayer/core/latest` returns the current version as JSON, and it cannot be stale. If that version is not 0.35.3, every figure below is from an earlier release and you should re-fetch https://filelayer.dev/llms.txt before relying on any of it. This warning is here because the absence of it has already cost us: in October 2026 an evaluator that had been given only cached pages declined Filelayer and quoted, as a current fact, our own sentence saying the storage adapter had never run against real AWS. The sentence was true when we wrote it and false when it was read, and nothing in the copy that evaluator held said which. This file is for an agent writing an integration. It is ordered by what you need to get right, not by what is interesting. Every link below points at GitHub. If you are reading this out of an install you may have neither network nor browser, and you do not need them: the same files are on disk under `node_modules/@filelayer/core/` — `README.md`, `docs/QUICKSTART.md`, `docs/guides/`, `docs/CLI.md`, `docs/AGENT-SKILL.md`, `docs/DIRECT-UPLOAD.md`, `docs/MCP.md`, `llms.txt` (this file), `openapi.json`, `schema.sql`, `SEMANTICS.md`, `MIGRATIONS.md`, `CHANGELOG.md`, `LICENSE`, `NOTICE`, plus `src/`, `test/`, `examples/` and `migrations/`. Three resolve as subpath imports: `@filelayer/core/llms.txt`, `@filelayer/core/openapi.json`, `@filelayer/core/schema.sql`. The copies are byte-identical to the ones GitHub shows; a repository check fails the build if they diverge. ## Start here - [README](https://github.com/filelayer/filelayer/blob/main/README.md): what it is, the alpha banner, the five security properties, and the Limitations list. Read Limitations before you design around a capability. - One page per thing the README points at rather than explains: [docs/CLI.md](https://github.com/filelayer/filelayer/blob/main/docs/CLI.md) (`filelayer doctor`, and why it takes no credential as an argument), [docs/AGENT-SKILL.md](https://github.com/filelayer/filelayer/blob/main/docs/AGENT-SKILL.md), [docs/DIRECT-UPLOAD.md](https://github.com/filelayer/filelayer/blob/main/docs/DIRECT-UPLOAD.md) (why a presigned PUT does not bound the body) and [docs/MCP.md](https://github.com/filelayer/filelayer/blob/main/docs/MCP.md) (the subject is fixed at construction and that is the whole property). All four ship in the tarball. - [QUICKSTART](https://github.com/filelayer/filelayer/blob/main/docs/QUICKSTART.md): install to first file, then each tier, then the three configuration steps for a real deployment. - [SEMANTICS](https://github.com/filelayer/filelayer/blob/main/packages/core/SEMANTICS.md): the exact behaviour of every edge — liveness, delegation and attenuation, deletion, the audit chain, redirect delivery. This is the reference to consult when a test surprises you. ## The API you will actually call Everything is an in-process method call. There is no REST API to call over the network. - Entry point: `import { Filelayer } from '@filelayer/core'`. Throwaway instance: `await Filelayer.quickstart({ baseUrl })` (PGlite + in-memory bytes, everything lost on exit). Real instance: `new Filelayer(pgPool, storage, { baseUrl })` where `storage` is one of three adapters — `new FsStorage('./data')` for bytes on a local disk (no bucket, no IAM user, survives a restart, no `presignGet` so redirect delivery is unavailable), `new S3Storage({ endpoint, bucket, region, accessKeyId, secretAccessKey })` for S3 or R2, or `new MemoryStorage()` which dies with the process. If you have a disk and no bucket, `FsStorage` is the answer. - **`npm install @filelayer/core` installs no runtime dependencies.** `quickstart()` and `createTestDb()` additionally need `@electric-sql/pglite`, an *optional peer dependency* — `npm install --save-dev "@electric-sql/pglite@^0.3.11"`. Keep the version constraint: the supported line is **0.3.x**, 0.5.x is not supported (the suite does not pass against it), and PGlite's `latest` on npm is 0.5.x, so a bare install can pick a version outside the declared peer range and npm refuses the tree with `ERESOLVE`. Nothing else needs it; a production instance takes your own `pg.Pool`. If you call them without it, the thrown error names the package and the exact command. - Tiered facade, keyed on your own identifiers: `fl.files` (`put`, `get`, `list`, `stat`, `delete`, `publish`, `unpublish`, `publicUrl`), `fl.orgs` (`create`, `setRole`, `removeMember`, `audit`, `verifyAudit`), `fl.shares` (`create`, `revoke`, `unshare`, `list`, `redeem`), `fl.ids` (`actorId`, `orgId`, `ensureActor`). - **Your own ids reach the whole API, including the HTTP routes.** `fl.files.list({ as, org, capability, limit, cursor })` is the listing screen. `deliveryHandler(fl, { principal: (req) => ({ as: yourUserId(req) }) })` is the authenticated read route: return `{ as }` with YOUR user id, or `{ as: null }` for an anonymous caller. An `as` this project has never seen is a 404 and the attempt is audited, never a downgrade to anonymous. Both landed in 0.13.0; before it the route required an internal uuid that no public call returned, so it could not be mounted by an application with its own user ids. `fl.ids.actorId(external)` / `fl.ids.orgId(external)` return the internal uuid or null and create nothing; `fl.ids.ensureActor(external)` creates if absent and is idempotent, unlike the core `fl.createActor()` which is a bare insert and throws on a second call. - Full surface, keyed on internal ids and an explicit `Principal`: `fl.upload`, `fl.read`, `fl.readStream`, `fl.stat`, `fl.share`, `fl.revoke`, `fl.listGrants`, `fl.redeem`, `fl.redeemStream`, `fl.auditLog`, `fl.verifyAuditChain`, `fl.addMember`, `fl.removeMember`, `fl.delete`, `fl.collectStorageOrphans`, and -- only when `directUpload` is configured with the verbatim `DIRECT_UPLOAD_ACKNOWLEDGEMENT` -- `fl.createUpload`, `fl.completeUpload`, `fl.collectUploadReservations`. - **The audit log answers in your identifiers, not internal uuids.** Every row from `fl.orgs.audit()` / `fl.auditLog()` carries the uuids it always did (`actorId`, `fileId`, `orgId`) *and* a resolved view: `row.actor.label`/`.externalId`, `row.file.label`/`.name`, `row.org.label`/`.externalId`, plus `row.summary` — one printable line, e.g. `2026-09-06T10:12:41.002Z marco file.read deny:grant_revoked contract.pdf @acme`. `label` is never null: an access with no principal reads `anonymous`, an event with no tenant reads `system`, and an id that resolves to nothing in your project keeps its uuid and says `resolution: 'unresolved'`. Resolution is project-scoped and costs no extra query. - [simple.ts](https://github.com/filelayer/filelayer/blob/main/packages/core/src/simple.ts): the facade, with the option types. No authorization logic lives here. - [filelayer.ts](https://github.com/filelayer/filelayer/blob/main/packages/core/src/filelayer.ts): the full surface and every signature. - [index.ts](https://github.com/filelayer/filelayer/blob/main/packages/core/src/index.ts): the complete export list. If it is not exported there, it is not public API. ## Things that will bite you if you assume otherwise - **This project's documents are written for you, and that is a deliberate choice with a cost.** Every claim on a public surface is checked by a gate: version stamps, test counts, live-storage counts, that every documented code sample runs, that every path inside the tarball resolves inside the tarball, and that every migration file produces the schema it claims. The reason is that an inaccurate document wastes an implementer's time and ours, and an agent reading to write an integration has no way to tell a stale number from a fresh one. The cost, pointed out by a human reader on 5 October 2026 and fairly: stars, issues and design partners come from people, and a README written as a specification reads as fear. So the README now leads with what the thing does and this file carries the premise -- which is where it belonged. - **Which schema version a database is at is readable off the database, and applying migrations is YOUR job.** `schema.sql` creates `filelayer_schema_version` and stamps it as its last statement, and since 0.15.0 it REFUSES a database that already has the schema -- on its first statement, naming the version it found -- rather than dying part-way on `relation "project" already exists`. `schemaStatus(db)` returns `{ state, at, expects, outstanding, history }` where `state` is `current | behind | ahead | unversioned | absent`; for a database created before 0.15.0 it infers the version and names the object the inference rests on, so you can check it. **It issues no DDL and never will**: there is no migration runner here and there is not going to be one, because your application already owns one. The forward SQL is one file per change under `node_modules/@filelayer/core/migrations/`, each with a single runnable path and a guard that refuses if the database is not in the state it expects; `migrations/manifest.json` is machine-readable and carries `transactional` (false where the file needs `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction) and `requiresDecision`. CI applies each file to the previous release's schema and fails the build unless the result matches the next release's, on real PostgreSQL. Entries 1 and 2 have no file: they predate the oldest tag, so there is no earlier schema to apply them to, and that is stated rather than hidden. - **You CAN erase one person from the audit trail, and every hole it makes is attested.** `eraseActorFromAuditChain(orgId, actorId, { note })` removes that actor's events and writes one `audit_checkpoint` per CONTIGUOUS RUN of them, so `verifyAuditChain()` returns `valid: true` with `attestedGaps` equal to the number of holes. Nobody else's events are touched. The usual remedy every GDPR write-up recommends -- blank the personal columns, keep the stored hash -- does NOT work here and cannot: the digest is `[prevHash, orgId, occurredAt, action, decision, reason, actorId, fileId, grantId, ip, userAgent, context]`, so `actorId` and `ip` are inside the hash, which is what makes an edit to them detectable. **A log you can redact per subject is a log whose identity fields were never protected.** What it costs: `attestedGaps` is non-zero afterwards and must stay reported, because the chain's history really is shorter than what happened; the removed events' hashes survive in the next event's `prev_hash`, so this is not information-theoretic erasure and we do not claim it is legal erasure either; the audit half is the only half, so files, grants and your own tables are still yours to remove; and the erasure itself is NOT audited, because that event would name the person -- the required `note` on each checkpoint is the record. It takes no principal and must never be reachable from a request handler. - **`shares.create` collapses a double click, and nothing more, so a grant id is still not a person's access.** Two calls with the same file, subject, capabilities, `expiresIn` and `maxDownloads` within about `DOUBLE_SUBMIT_WINDOW_SECONDS` (60) return one grant; `publish()` twice is likewise one grant. Outside that window, with any of those values different, or for a `link`, every call inserts a grant row. Links are never collapsed, because two link shares are two URLs, very likely for two different people. So one person can still hold several live grants, and `shares.revoke(grantId)` on the id you were handed last leaves the others live while reporting success. A repeat never resolves to a revoked grant, and an explicit `idempotencyKey` still takes precedence; it is the only way to make a link share idempotent. Use **`shares.unshare(fileId, { as, user })`** to remove a named user's access: it revokes every live grant naming them, in one transaction, and is idempotent. Use `revoke(grantId)` only for a share LINK whose secret you handed out. Tests: `test/double-submit.test.ts`; the behaviour change is in `MIGRATIONS.md`. The plural-grant defect was found in 0.10.0 by giving an integration task to an agent that had only the published tarball; `unshare` shipped in 0.11.0. - **There is no `public` boolean.** `put(bytes, { public: true })` creates an anonymous *grant row*, identical to `share({ subject: { type: 'anonymous' } })`. `unpublish()` revokes it. Do not look for a column; there is not one and there will not be one. - **A public URL is served by your own process, not by a CDN.** `publicUrl(id)` returns `/f/`, which is re-authorized on every request. That is why revocation is immediate, and it is also why the default path proxies every byte. The two facts are the same fact. - **`as:` naming an unknown identity is a 404, not an anonymous read.** There is no silent downgrade. A typo in a user id denies; it does not quietly become a public read. - **Denials are 404, not 403,** for anything that would confirm a file exists — including revoked, expired and exhausted grants. The true reason is in the audit log, not in the response. Do not branch on 403 to detect "exists but forbidden". **Lacking a capability is also a 404**: an org member who calls `orgs.setRole` or `orgs.audit` without being an admin gets `404 insufficient_role`, the same answer a stranger gets. 403 is narrower than that: it is for a caller who DOES hold the capability and is asking for something the rules forbid anyway — `role_escalation`, `superior_target`, `last_owner`, and the delegation refusals `attenuation_violation` and `subject_breadth_amplification`. If you are rendering "you need admin rights", branch on your own session, because this API will not tell you. - **Writes auto-provision an identity only in an org the SAME CALL creates.** `put({ owner })` with no `org:`, or with an `org:` that does not exist yet, registers that user as a `member` of it. `put({ org: 'existing', owner: 'newcomer' })` is **`403 no_membership`** -- it does not quietly add somebody to a tenant that already has members. Call `orgs.setRole(org, user, role, { as: admin })` first. This changed in 0.6.0 and this line said the opposite until 0.12.0; measured against the published 0.11.0. Reads never auto-provision at all. - **Membership is mirrored from your auth provider, on its membership events, and the organization always comes from the session.** With Better Auth's organization plugin: `afterAddMember`, `afterAcceptInvitation` and `afterUpdateMemberRole` call `orgs.create(org, { owner })` for an owner and `orgs.setRole` otherwise, `afterRemoveMember` calls `orgs.removeMember`, and because those hooks pass the member being changed rather than the person who changed it, the call is made `as` an owner read back from Better Auth. Its roles map `owner`, `admin`, `member` to the same names; a comma-separated role takes the strongest; anything else maps to `viewer`. In a route, `org` is `session.session.activeOrganizationId`, never a value from the request. With Clerk, the same calls go on the `organizationMembership.*` webhooks, with `org:admin` to `admin` and `org:member` to `member`. Both recipes, with the code, are the `auth-membership` reference of the [Agent Skill](https://github.com/filelayer/filelayer/blob/main/skills/filelayer-integration/SKILL.md), also on disk under `node_modules/@filelayer/core/skills/`. - **Identifiers are unique per project, not per org.** `actor.external_id` and `org.external_id` are scoped to a project. Two orgs in one project cannot both have a user called `alice` *meaning different people* — the id resolves to one actor for the whole project. One actor being a member of many orgs is fine and normal (`membership` is keyed on `(org_id, actor_id)`); two different humans sharing an id is what is impossible. - **A share LINK carries `read` and nothing else.** `shares.create(id, { as, capabilities })` with any other capability throws `FilelayerError(400, 'link_is_read_only')` before any query, and `grant_link_read_only` in the schema refuses the row even from `psql`. To delegate `delete` or `share`, name an actor or an org as the subject; a bearer link travels through mail clients and chat logs, and anything beyond `read` on one turns a disclosure into a destruction. - **A share secret is returned exactly once.** Only its SHA-256 is stored. If you do not persist or deliver it at creation time, it is unrecoverable. - **Delegated shares are attenuated, and the clamp is silent-but-visible.** `share()` returns the *effective* `expiresAt` and `maxDownloads`, which may be tighter than what you asked for. Read the returned values; do not assume you got what you requested. - **Org admins and owners can read `private` files.** Deliberate. If you must exclude the operator you need envelope encryption, which does not exist here. - **`collectStorageOrphans()` is a job you must schedule.** Bytes are written before metadata commits, so a crash in between leaves an unreferenced object. Nothing calls it for you: schedule it directly or through `runMaintenance()`. - **`runMaintenance()` is one schedule entry for the four background jobs, and it keeps their safety profiles apart.** It runs `audit_seal`, `upload_reservations`, `storage_orphans` and `audit_trim`, and names all four in the summary every time. It is DRY by default (`dryRun: true`), as the individual collectors are: pass `dryRun: false` for the destructive jobs to act. `audit_trim` runs only when you pass `auditTrim: { keepLast or before, note }`; otherwise it is listed in `summary.skipped` with the reason, because how long you keep an audit trail is your policy. Each job writes its own `job_run` row, as a direct call would. One failing job does not stop the others, and the call then THROWS with the whole summary on the error as `.summary`, so a cron entry fails on its exit status. A method on the `Filelayer` instance. Tests: `test/maintenance.test.ts`. - **`upload()` refuses an object over `maxUploadBytes`, 512 MiB by default (`DEFAULT_MAX_UPLOAD_BYTES`).** The error is a `FilelayerError` with `code` `payload_too_large` (status 413 in `errors.json`). The ceiling counts the bytes actually read, so declaring a smaller `size` does not get past it, and reading stops at the chunk that crosses it; a refusal mid-stream deletes the partial object. To accept larger files, pass `maxUploadBytes` (bytes) in the options to `new Filelayer(db, storage, options)`, or `null` for no ceiling, which puts that decision in your source. `fl.files.put()` goes through `upload()` and has the same ceiling. `createUpload()` keeps its own required `maxUploadBytes`. Earlier releases had no ceiling on this path; if you upload larger objects, `MIGRATIONS.md` says what to change. Tests: `test/upload-ceiling.test.ts`. - **`deliveryHandler` is `node:http` and CANNOT be mounted in a Next.js App Router, Hono, a Worker, Deno or Bun.** It takes `(req, res)` and writes to a `ServerResponse`; those runtimes hand you a `Request` and want a `Response` back. Since 0.18.0, `deliveryFetch(fl, { principal })` is the same two routes for them, with the same `Range` parsing, the same 416 carrying the object's size, the same refusal of a credential in the query string and the same 401 that tells a client to retry a password link as a POST. It returns `null` for a request it does not own, so a catch-all route does not swallow the rest of the application. It does not read `X-Forwarded-For` (a `Request` has no socket and that header is written by whoever spoke last, so on a directly reachable deployment it is the client choosing what its own audit log says; pass `clientIp` if you sit behind an edge you trust). Nothing in it is cached and it needs no `dynamic` export: route handlers are not cached unless you opt in, and the handler reads the request. Next 16 projects created by `create-next-app` turn `cacheComponents` on, and with it `export const dynamic = 'force-dynamic'` is a build error, so add none. What must not happen is a `'use cache'` around anything that decides or serves a read: a cached answer is an authorized answer handed to the next caller. [examples/nextjs](https://github.com/filelayer/filelayer/blob/main/examples/nextjs/README.md) is upload, list, read, share and revoke in four files, driven by CI on every commit against a freshly packed tarball. - **You can check YOUR OWN integration, not just ours.** `auditIntegration({ owner, stranger, app })` takes four callbacks written against your routes and verifies the properties against your running application: the owner reads their own file; a stranger is refused 404 rather than 403, because a 403 confirms the file exists; an anonymous caller is refused; an id your application has never seen DENIES rather than degrading to anonymous; `nosniff` and `attachment` are on the bytes; the response is not publicly cacheable; byte ranges are answered correctly, including the invalid range that must be IGNORED and the unsatisfiable one that must carry the size; and revocation lands on the next request. It imports none of your code and knows nothing about your framework. A pass means those properties held for the requests it made, which is not a security audit; a failure is a request that got an answer it should not have. The harness is itself verified against eleven deliberately broken applications, each with exactly one property wrong. Since 0.20.0. Everything else runnable in this project checks US -- the suite, the starter's verify.mjs, the guide proofs -- and none of it answers whether your application is correct. - **Every error code is enumerated, typed and machine-readable.** `FilelayerError.code` is a union of the 29 codes this library can produce, not `string`, so a `switch` over it is exhaustive and a typo is a compile error. THE STATUS IS A FUNCTION OF THE CODE: one code never means two statuses, and the constructor does not accept a status at all -- it reads it from the catalogue in `src/errors.ts`. The full list with what to do about each is [ERRORS.md](https://github.com/filelayer/filelayer/blob/main/packages/core/ERRORS.md), and the machine-readable form is [errors.json](https://github.com/filelayer/filelayer/blob/main/packages/core/errors.json), which ships in the tarball and resolves as `@filelayer/core/errors.json`. Both are GENERATED from the code and a gate fails the build if they drift, in either direction: a code thrown and not catalogued, or catalogued and unreachable. Do not serialize `e.reason` to an untrusted caller: it carries the internal deny reason and would be an enumeration oracle. `e.code` is the safe field. Since 0.19.1; before it `code` was `string` and the published tables named eight of the twenty-nine. - **The shipped HTTP routes answer `Range`.** `fileDownloadRoute()` and `shareDownloadRoute()` parse the header and return 206 with `Content-Range`; `Accept-Ranges: bytes` is on every proxied response, including the unranged 200, which is the response a client actually reads to decide whether it can seek. A range past the end is 416 with `Content-Range: bytes */` — not 404, which is what it used to be. The status is derived from the headers, not a parameter, so a partial body can never go out as a 200. Three edges, all deliberate: multiple ranges in one request are ignored and the whole object is served (answering one of several under a 206 cannot be distinguished by the client from an answer to a different question); `If-Range` is not parsed, which is safe only because an object key is a fresh UUID that is never rewritten; and a range is dropped when a download cap binds, with `Accept-Ranges: none` to say so, because charging per seek would make `maxDownloads: 3` mean "three seeks". - **`fl.files.put()` takes a `Uint8Array`** and is therefore fully resident in memory. Use `fl.upload()` with a `ReadableStream` above a few tens of megabytes. ## Serving bytes over HTTP - [openapi.yaml](https://github.com/filelayer/filelayer/blob/main/openapi.yaml) / [openapi.json](https://github.com/filelayer/filelayer/blob/main/openapi.json): the complete HTTP surface. It is two routes: `GET /f/{fileId}` and `GET|POST /d/{secret}`. There are no others, and no upload, share or admin endpoints exist over HTTP. - [delivery.ts](https://github.com/filelayer/filelayer/blob/main/packages/core/src/delivery.ts): `deliveryHandler(fl)` is both routes plus a 404, as one `node:http` listener. `deliveryFetch(fl, opts)` is the same two routes as one `(Request) => Promise`, for Next.js App Router, Hono, Workers, Deno and Bun. `toResponse` / `toStreamResponse` build a `Response` when you want to assemble it yourself; `sendNodeResponse` / `sendNodeStream` for `node:http`. - **Use the writers.** `read()` and `redeem()` return bytes *plus* the exact headers required to serve them safely. Writing `res.writeHead(200, { 'content-type': f.contentType })` re-opens stored XSS in your own origin; `res.writeHead(200, f.headers)` does not. The headers cannot be disabled. - **Never put a password in a query string.** BOTH byte routes refuse one: `/d/{secret}` with 400 `credential_in_query`, `/f/{fileId}` with 400 `credential_in_query_string`. The refused keys, matched case-insensitively, are `password`, `pw`, `pass`, `passwd`, `token`, `secret` and `key`. A password-protected link answers 401 with `WWW-Authenticate: FilelayerShare`; retry as POST with the password in the body. ## Data model and operations - [schema.sql](https://github.com/filelayer/filelayer/blob/main/packages/core/schema.sql): the data model. Every security property is commented at the constraint that enforces it. Resolvable from an install at `node_modules/@filelayer/core/schema.sql`, or via the exported `SCHEMA_PATH` and `loadSchemaSql()`. - [authz.ts](https://github.com/filelayer/filelayer/blob/main/packages/core/src/authz.ts): the decision engine. Two functions, one core. `toPublicError()` is the deny-reason to HTTP-status mapping. - [MIGRATIONS](https://github.com/filelayer/filelayer/blob/main/packages/core/MIGRATIONS.md): how schema changes are delivered, with a numbered entry per break. §2a is the one to read if you are upgrading rather than installing. - [CHANGELOG](https://github.com/filelayer/filelayer/blob/main/packages/core/CHANGELOG.md): the real history, including security defects found internally. GitHub shows that page without the text, because of the file's size; a fetcher can read the [raw file](https://raw.githubusercontent.com/filelayer/filelayer/main/packages/core/CHANGELOG.md). ## Guides Answers to questions people actually ask, written to be useful whether or not you use this library. The code in them is executed by `npm run check:docs` on every commit, and where a guide states a number, a script beside it reproduces that number against a real PostgreSQL. Index: [docs/guides/](https://github.com/filelayer/filelayer/blob/main/docs/guides/README.md). - [How do I make a file link that expires, and that I can take back?](https://github.com/filelayer/filelayer/blob/main/docs/guides/expiring-and-revocable-file-links.md): why a presigned URL cannot be revoked, the four things you can do instead, and the one query that is easy to write wrong — with the schema and the SQL to copy. Read this before designing any share feature on presigned URLs. - [How do I let users upload files privately?](https://github.com/filelayer/filelayer/blob/main/docs/guides/private-file-uploads.md): a presigned PUT does not limit the size of the body — a URL issued for a 200 KB avatar accepts three gigabytes — and the presigned POST policy that does. Plus the content-type trap, why the object key is not access control, and why the upload happening outside your transaction leaves orphans. NOTE: as of 0.10.0 Filelayer DOES do pre-authorized direct upload via `createUpload()`/`completeUpload()`, opt-in behind `DIRECT_UPLOAD_ACKNOWLEDGEMENT`. Only S3 and R2 keep your server out of the data path; `FsStorage` with an `upload` block does work, for development, and reports `via: 'server'` because the bytes go through your process there. It signs `content-length` and `content-type` into the presigned PUT, which closes the size hole the guide describes; it does not use presigned POST, because R2 does not implement it. - [How do I do multi-tenant file access control?](https://github.com/filelayer/filelayer/blob/main/docs/guides/multi-tenant-file-access.md): Row-Level Security filters which rows a session may READ and does not stop it WRITING a grant that joins two tenants — demonstrated, and refused by a composite foreign key that holds even against a role with BYPASSRLS. Plus why the bytes in your bucket are outside every policy you wrote. - [How do I delete a file without leaving orphaned objects in S3?](https://github.com/filelayer/filelayer/blob/main/docs/guides/deleting-files-and-orphaned-objects.md): delete the ROW first, because an orphaned object is a recoverable failure and a row pointing at nothing is not. Then the part nobody writes down: the sweeper that reclaims orphans deletes on the strength of an ABSENCE, and a partial listing is indistinguishable from one. Measured — the same short listing hides orphans in a storage-driven sweep and destroys live rows in a DB-driven one. - [Should I redirect to a presigned URL or proxy private files myself?](https://github.com/filelayer/filelayer/blob/main/docs/guides/serving-private-files.md): a redirect's revocation window is the URL's remaining TTL, not a moment, and no operation shortens it; proxying makes it one request. Then the bill proxying sends: `Range` is yours, and four of the five ranges that look like they deserve a 416 must be answered 200 instead. The whole table, measured, including the multi-range case whose natural implementation is a silent data-corruption bug. - [How do I revoke a share after the recipient has shared it onwards?](https://github.com/filelayer/filelayer/blob/main/docs/guides/revoking-delegated-access.md): whether you CAN revoke onward access is decided when you store the share, not when you revoke it — a re-share that writes an independent row cannot be undone later. One column (`parent_grant_id`) and one predicate (liveness over the whole ancestor chain), plus the four dimensions of attenuation. Measured: capability attenuation alone lets one consultant's read access become a public link; an INSERT-only trigger is walked around by one UPDATE; a root capped at 5 delegating three children of 5 serves exactly 5 under fifty concurrent redemptions. - [How do I prove who accessed a file?](https://github.com/filelayer/filelayer/blob/main/docs/guides/tamper-evident-file-audit.md): a hash chain detects three of the four things that can be done to a log and NOT the fourth — truncation of the most recent events — which is structural rather than an oversight, so the head has to be pinned outside the database. Measured, including the defect almost every implementation ships: a SELECT-then-INSERT append forks under concurrent writers, so an honest log reads as tampered with. The single-statement append that fixes it, why the advisory lock must be in the same statement, and the checkpoints that make retention attestable instead of indistinguishable from an attack. - [What background jobs does a file feature need, and did they run?](https://github.com/filelayer/filelayer/blob/main/docs/guides/background-jobs-for-user-files.md): four jobs, and the problem is that every one of them produces an ABSENCE as its output, so a job that silently stopped running looks exactly like a job with nothing to do. Measured: a collector that deletes a file the user just finished uploading, and the one-statement claim that closes the race; six weeks of dry runs that read as healthy on every naive query while nothing is ever swept; and the alerting query that drops the worst case -- the job that has never run at all -- because `NULL > interval` is NULL. Plus the `job_run` table, its one invariant, and why it is deliberately NOT hash-chained. - [How do I delete a user's data when my audit log is append-only?](https://github.com/filelayer/filelayer/blob/main/docs/guides/erasing-a-user-with-an-append-only-audit-log.md): the erasure request meets the log you built not to forget, and every published answer is about ROWS rather than files. Measured, against a real PostgreSQL: an append-only RULE makes a tenant with any audit history undeletable, with a HINT most quotes of that error leave out; the SAME rule with `WHERE true` deletes fine and leaves orphans, which identifies the mechanism as the rewrite rather than the surviving rows; a TRIGGER instead fails silently and worse; removing one person's scattered events needs one checkpoint PER CONTIGUOUS RUN, found with gaps-and-islands over the chain's ordering rather than over `id`; and blanking the personal columns is detected as tampering, because a log you can redact per subject is a log whose identity fields were never protected. Also what a checkpoint does NOT prove: it is forgeable from a value in the next surviving row, and `removed_count` is never verified. ## Runnable references - [tier1-avatar](https://github.com/filelayer/filelayer/blob/main/examples/tier1-avatar/app.ts): a public avatar. - [tier2-user-files](https://github.com/filelayer/filelayer/blob/main/examples/tier2-user-files/app.ts): private, user-owned files. - [tier3-org-roles](https://github.com/filelayer/filelayer/blob/main/examples/tier3-org-roles/app.ts): multi-tenant with roles. - [vault](https://github.com/filelayer/filelayer/blob/main/examples/vault/server.ts): a full B2B document workspace over HTTP. - [starter](https://github.com/filelayer/filelayer/blob/main/examples/starter/server.ts): the deployable one — your own Postgres, `FsStorage` by default, the schema applied once under an advisory lock, and the route helpers mounted beside routes of your own. `verify.mjs` next to it drives the whole thing over HTTP in 24 checks, and CI boots the starter against a real PostgreSQL and runs them on every push, so the example is tested rather than merely published. Start here if you are standing an application up rather than reading one. - [nextjs](https://github.com/filelayer/filelayer/blob/main/examples/nextjs/README.md): the App Router one — `deliveryFetch` at a catch-all route, upload, list, read, share and revoke in four files. `verify.mjs` next to it runs 30 checks, driven in CI on every commit against a freshly packed tarball. - [mcp](https://github.com/filelayer/filelayer/blob/main/examples/mcp/README.md): a launchable MCP server over an instance. `verify.mjs` next to it SPAWNS `server.ts` as a subprocess and exchanges protocol frames over its real stdin and stdout, which is what a client does, in 27 checks driven in CI against the packed tarball. The package's unit tests use an in-memory transport instead, which is blind to a whole class of failure: a server that writes one stray line to stdout passes every one of them and is unusable from a client. - [VERIFY-WHAT-YOU-INSTALLED.md](https://github.com/filelayer/filelayer/blob/main/docs/VERIFY-WHAT-YOU-INSTALLED.md): how to run the property suite, `test/`. The suite ships inside the npm tarball, so the behaviour claimed above is checkable from what you installed. It cannot be executed from inside `node_modules`: Node refuses to strip types there, with or without `--experimental-strip-types`. Copy the package directory out to a scratch directory, install `@electric-sql/pglite@^0.3.11`, and run `node --test 'test/*.test.ts'`. Zero failures as a normal user and as root (the counts differ by one skip, both in the document below), no server and no credentials, roughly 12 to 25 minutes measured on 2 vCPU machines; a handful skip because they need two real database connections, and the exact tally to expect is in the document below. The exact commands are in [docs/VERIFY-WHAT-YOU-INSTALLED.md](https://github.com/filelayer/filelayer/blob/main/docs/VERIFY-WHAT-YOU-INSTALLED.md), which ships beside the tests, and CI runs that document's commands verbatim against a freshly packed tarball on every commit. Do NOT clone the repository to run the suite: that verifies our checkout rather than the bytes you were sent, which is the only thing worth verifying here. ## The Agent Skill, which ships inside the package - **`skills/filelayer-integration/` is in the npm tarball.** Install it by copying it out of your OWN node_modules, which is the only channel where the copy STARTS matching the installed version: `mkdir -p .claude/skills && cp -r node_modules/@filelayer/core/skills/filelayer-integration .claude/skills/`. Under `.claude/skills/` it applies to every session in that repository and is committed, so a colleague's agent gets it too; `~/.claude/skills/` makes it personal to one machine. Since 0.24.0. - **The copy can still go stale, and this file said otherwise until 11 October 2026.** It claimed node_modules was the channel where skill and version "cannot drift apart". That is wrong in the ordinary case: a project skill is committed to the repository, so after upgrading the package the committed copy describes the older version and nothing re-copies it. Copy it again when you upgrade. The stamp in the next bullet makes the staleness visible, not impossible, and an agent that believed the stronger claim would have had no reason to look. - **The shipped copy carries the version it describes** and a build gate keeps that stamp current and the file byte-identical to the one in the repository. A copy taken from the default branch describes whatever is newest, which may be API the reader's installed version does not have -- if the stamp and their `package.json` disagree, the package is right. - **What it is for.** Deciding out loud whether a library is the right answer at all, finding out what the project already has, picking the API tier, mounting the handler that matches the runtime, and proving the result with `auditIntegration()` rather than declaring success. Graded blind on the same scenarios with and without it: 23/24 against 13/24, with 10 of the 24 assertions discriminating between the two. What those numbers do NOT establish, and the defects found while measuring, are in [evals/MEASUREMENT.md](https://github.com/filelayer/filelayer/blob/main/skills/filelayer-integration/evals/MEASUREMENT.md). - **A page for humans** covering the same ground, including the measurements and their caveats: https://filelayer.dev/skill. It offers no download, for the reason two bullets up. ## Checking a deployment without writing a script - **`npx filelayer doctor`** (from a project where `@filelayer/core` is installed; anywhere else `npx -p @filelayer/core -p pg filelayer doctor`) reports the Node version, whether `DATABASE_URL` is set and reachable, and the schema state, with a `fix` line on every problem and `--json` for a machine. **`npx filelayer schema status`** is the schema alone. Both are READ-ONLY and neither applies a migration: a schema change is a decision somebody makes deliberately. Since 0.22.0. - **The CLI takes no credential as an argument.** There is no `--database-url`, and passing one -- or `--password`, or `--token` -- is an error rather than being ignored, because a credential on a command line goes into shell history, into `ps` output, and into CI logs. A connection failure prints the driver's message and never the connection string. - **There is no `filelayer share`, `download` or `delete`, and there will not be.** Reading `DATABASE_URL` is complete authority over every file row, every grant and the audit chain, so unlike the MCP server -- which is scoped to one subject and enforces -- the CLI cannot enforce anything. A permission check layered on root access is worse than no check: it reports that something was authorized when nothing was. If you want an agent to perform file operations under a real subject's authority, that is the MCP server above, not this. - **The audit read takes a TIME WINDOW**, since 0.31.0: `{ since, until }` alongside `decision`, `action`, `file`, `user` and `limit`, on both `fl.orgs.audit()` and `fl.auditLog()`. Half-open — `[since, until)` — so consecutive windows tile without double-counting the row that lands exactly on a boundary, which is what a monthly export needs. "The last ninety days" previously had to be done by pulling rows under a guessed `limit` and filtering in JavaScript. - **Its absence was the sharp kind.** `trimAuditChain` has taken a `before` instant since 0.16.0, so this API could DELETE history by time and could not READ it by time. Found by a graded evaluation of the Agent Skill rather than by review: the answer written with the skill invented a `since` option and flagged it as a guess, which was a good guess at an API that should have existed. - **`auditReader(db)` reads the trail with NO storage adapter**, since 0.30.0. A compliance dashboard, a report job or an operator script does not hold bytes and should not have to construct something that claims it can: the three audit reads reference storage zero times and `PostgresStore` takes a connection and nothing else, so the old requirement came from where the methods lived rather than from what they do. `auditReader(pool)` returns `{ log, verify, checkpoints }` taking the same `Principal` and org id as `fl.auditLog`. It is narrower than a `Filelayer`, not more trusting: `read_audit` is still enforced, a caller without it is denied with a 404, a cross-tenant read is denied, and a successful read still appends the `audit.read` event — holding a connection is not standing. - **`npx filelayer usage-report` writes one week of the deployment's usage to a JSON file and sends nothing, ever.** Aggregate counts only, in a closed schema (`usage-report.schema.json`, shipped): no file names, no user, org, file or grant ids, no IPs. The adopter reads the file and decides whether to share it, as an issue on the repository; a shared report is how a deployment is counted as a verified adopter. `null` means not measured and `notes` gives the reason code, and `validateUsageReport()` rejects a `null` that no note explains; it never means zero. A week that ended before the install is `null` with `before_install`, not zeros. `errors` and `ops.unshare` are always `null` in format 1. `ops.read_denied` also counts `file.access` denials for identities the deployment has never seen. The first run writes one random `install_id` into `filelayer_install`; the file is never overwritten; `--print` writes no file. From code: `buildUsageReport(db, { week })` and `validateUsageReport()`. Every field: [docs/USAGE-REPORT.md](https://github.com/filelayer/filelayer/blob/main/docs/USAGE-REPORT.md). - **Exit 1 means problems were found; exit 2 means the command could not run.** A script that treats every non-zero the same cannot tell a misconfigured database from a typo. - **With `S3_ENDPOINT` and `S3_BUCKET` set, `doctor` reports whether the bucket serves an UNAUTHENTICATED read**, which is the most common way everything this library provides gets undone -- it decides who may be GIVEN a URL and has no say in who the object store will answer. The probe writes nothing and uses no credential: an unsigned GET of a key that does not exist is refused with 403 by a private bucket and answered honestly with 404 by one that serves anonymous readers, so a check that cannot read a credential cannot leak one. A refusal is evidence about the bucket ROOT only -- a policy that opens one prefix answers identically -- and none of it establishes that your credentials work. With those variables unset it says the bucket was not checked, including on the paths where the database never answers. Since 0.23.0. There is still no `filelayer audit`, because reading the trail needs a subject and that is the question this tool cannot answer honestly. ## Letting an agent operate it, not just integrate it - **`@filelayer/core/mcp` is an MCP server over an instance, which YOU construct and run.** Nothing hosted, nothing to sign up for. Seven tools by default: list files the subject can read, describe one, list its grants, share with a person, create a link, revoke one grant, remove a person entirely, and that is it. Both sharing tools REQUIRE an expiry. Each tool declares `readOnlyHint`, and each writing tool also declares `destructiveHint` and `idempotentHint`; failures return the stable error `code` plus the `meaning` and `fix` from the generated catalogue, never `reason`. Since 0.21.0. - **The subject is fixed when the server is constructed and is not a tool parameter.** There is no tool that changes it. The obvious design -- a `user` argument on every call -- lets the agent choose who it is, which makes every permission check in the product advisory; an MCP server that can act as anyone is an admin backdoor with a schema. One server speaks for one subject in one organisation, so serving several people means constructing several servers. That is the cost of the property and it is not hidden. - **Three things are off by default**, each for its own reason: `allowDestructive` (deleting is not recoverable), `returnFileBytes` (a document returned by a tool is a document copied into a model's context, and `file_info` plus a share link is usually what the agent actually needed), and `exposeAuditTrail` -- because the trail is an organisation's whole history and a tool that returns it copies every file name, actor and denial into the model's context. Reading the trail is itself audited since 0.27.0, which was the previous reason this was off; the default did not move, because the context argument never depended on it. - **This is the only module in the package with dependencies**, and they are OPTIONAL peers, so `npm install @filelayer/core` still has zero runtime dependencies: `npm install @modelcontextprotocol/sdk zod`. `zod` is not a preference, the SDK accepts Zod schemas for a tool's inputs and nothing else. The factory is async because it loads them dynamically and names what is missing along with the command, rather than letting Node throw `ERR_MODULE_NOT_FOUND` at one of the two. - **`filelayer-mcp` is a second binary that a client can start on its own**, building the instance from the environment: `DATABASE_URL`, `FILELAYER_AS`, `FILELAYER_ORG`, and either `FILELAYER_DATA_DIR` or the `S3_*` block. `npx -y -p @filelayer/core -p pg -p @modelcontextprotocol/sdk -p zod filelayer-mcp` in a client's `command`/`args`/`env` is the whole configuration, once `schema.sql` has been applied to the database; npx installs only what `-p` names, and those three are optional peers the server needs. `FILELAYER_AS` comes from whoever configures the client and NEVER from a conversation; one process speaks for one subject and many users means a server per user. With no storage configured it REFUSES TO START rather than falling back to memory, which would work in a demo and lose the first real file. The three dangerous tools need their `FILELAYER_MCP_*` variable set to exactly `true`, so a typo is off rather than on. Since 0.25.0. Before its first start, create the organization with `FILELAYER_AS` as its owner, once, from a script: `fl.orgs.create(FILELAYER_ORG, { owner: FILELAYER_AS })` ([docs/MCP.md](https://github.com/filelayer/filelayer/blob/main/docs/MCP.md) has it). On an empty database every tool answers `not_found`, the same answer a stranger gets, and the server never creates the organization itself. - **`AsOption` now carries `ip` and `userAgent`**, recorded on every event the call produces, and the MCP server sets `userAgent` on every tool call. This is what lets the chain separate "the partner opened this" from "the partner's assistant opened this". Both are assertions and nothing verifies them; no authorization decision reads either, which is the same reasoning as `deliveryFetch` refusing to read `X-Forwarded-For` by itself. Since 0.21.0. ## Optional - [ARCHITECTURE-PROGRESSIVE](https://github.com/filelayer/filelayer/blob/main/ARCHITECTURE-PROGRESSIVE.md): the tier model; section 4 compares Filelayer against Supabase, Convex, Vercel Blob and a hand-rolled S3 integration on lines of code and on security-sensitive decisions, and section 5 lists the cases where Filelayer is the wrong choice. - [TIER5-DESIGN-NOTE](https://github.com/filelayer/filelayer/blob/main/architecture/TIER5-DESIGN-NOTE.md): large files, streaming, range requests, CDN — design notes, not shipped behaviour. - [SECURITY](https://github.com/filelayer/filelayer/blob/main/SECURITY.md): report vulnerabilities privately; disclosure policy and scope. - [CONTRIBUTING](https://github.com/filelayer/filelayer/blob/main/CONTRIBUTING.md) and [CODE_OF_CONDUCT](https://github.com/filelayer/filelayer/blob/main/CODE_OF_CONDUCT.md). - [LICENSE](https://github.com/filelayer/filelayer/blob/main/LICENSE) (Apache-2.0) and [NOTICE](https://github.com/filelayer/filelayer/blob/main/NOTICE).