File Rules
fileRead, fileWrite and fileValidate govern the file store exactly the way read, write and validate govern the data tree — on the same trie, with the same path arrays and the same wildcards. A path carries both sets independently.
module.exports = {
"posts": {
"$id": {
"read": "true",
"write": "admin.uid == data.authorId",
"attachments": {
"fileRead": "true",
// A file rule reaching for a sibling RECORD must go through `root` —
// `data` here is file-side state, not the post.
"fileWrite": "admin.uid == root.posts[$id]?.authorId",
"fileValidate": "file.size < 10 * 1024 * 1024 && file.type.startsWith('image/')"
}
}
},
"avatars": {
"$uid": {
"fileRead": "true",
"fileWrite": "admin.uid == $uid",
"fileValidate": "file.size <= 2 * 1024 * 1024"
}
}
};
Semantics mirror the data side exactly:
fileReadgrants — any level along the path returningtrueallows, and the grant cascades over everything beneath it. Governs download and listing. Because a grant cascades, one check on a directory covers every entry in it, so a listing never costs one rule evaluation per file.fileWritegrants — same walk. Governs upload and delete.fileValidateconjoins — every matching level must pass, and a single failure denies. It does not run on delete, exactly asvalidatedoesn't run onremove.
"!data" is write-once. Because data is the existing file's stat and is undefined when nothing is there, "fileWrite": "admin.uid == $uid && !data" permits the first upload to a path and refuses every overwrite — the same idiom as "write": "!data" on the data side.
Two deliberate differences from the data side:
datais the stat of the path being acted on, and it is the same at every level of the walk — not the entry at each prefix. Statting every ancestor would be a syscall per level to answer a question nobody asks. Sodatameans "what is already at this path" ({ name, ext, size, type, mtime, isDir }) orundefinedif nothing is.fileValidatecascades over path prefixes only. A file descriptor is flat, so unlikevalidatethere is nothing to descend into.
The file descriptor
In fileWrite and fileValidate, the incoming value is bound to both file and newData — the same object under two names, because newData reads like a JSON payload and this isn't one. It is null on a delete.
| Field | Value |
|---|---|
file.name | The last path segment, e.g. "hero.png" |
file.ext | Lowercased extension including the dot, e.g. ".png" |
file.size | Size in bytes |
file.type | Resolved MIME type: sniffed content wins, then the extension, then the declared header |
file.declaredType | Raw Content-Type the client sent (least trustworthy — it is whatever the caller typed) |
file.sniffedType | Type detected from the leading bytes, or "" |
file.path | The full path array |
Uploads are validated twice, and the second run is the real one.
- Before the first byte, against
Content-LengthandContent-Type— a cheap early-out so a request with no write grant never reaches the disk. - After the last byte, against the actual byte count and the type sniffed from the leading bytes — authoritative. Both headers are attacker-chosen, so a rule like
file.size < 5*1024*1024enforced only against the declared value would be enforced against a number the client picked.
Content sniffing recognises PNG, JPEG, GIF, WebP, MP4, PDF, ZIP, gzip, SVG, HTML and XML. The case that matters is markup wearing an image extension: a rule reading file.type.startsWith('image/') sees text/html for a .png full of <script>, so the check it appears to make is the check it actually makes.
A throwing file rule denies, and logs. Data rules let the error reach the WebSocket reply; file operations are served over HTTP, where a rule's internal error text should not reach the caller. The failure is written to the server log so a broken rule is diagnosable rather than mysteriously silent — but from the client it is indistinguishable from a deliberate refusal. Keep ?. in any rule that walks the tree: root.usage?.[$uid]?.bytes is a working quota check, root.usage[$uid].bytes is one that denies everything the moment usage is empty.
The store root can never be listed. An empty path array is rejected outright, and fileRead on one returns false because there is no level at which to grant.