firestore-rulekit
v0.1.1
Published
Split Firestore security rules into small, reusable files; compiles to a plain firestore.rules.
Readme
firestore-rulekit
Firestore security rules you can split into files and validate with types.
A real firestore.rules for a production app runs to hundreds of lines in a single file, with no way to split it, and half of it is hand-written key and type checks. rulekit adds two things to the rules language:
includeto split rules into one file per collection plus shared helpers.typeto declare a document's shape once and get create and update validators generated.
rulekit build compiles your sources to an ordinary firestore.rules. Firebase deploys it, the emulator runs it, and @firebase/rules-unit-testing tests it, all unchanged. There's no runtime and nothing to learn beyond those two keywords. Everything else is the Firestore rules syntax you already use.
// Before: hand-written, in a 600-line file
function validPost() {
let data = request.resource.data;
return data.keys().hasOnly(['title', 'body', 'status', 'createdAt'])
&& data.keys().hasAll(['title', 'status', 'createdAt'])
&& data.title is string && data.title.size() >= 1 && data.title.size() <= 200
&& (!('body' in data) || (data.body is string && data.body.size() <= 10000))
&& data.status in ['draft', 'published']
&& data.createdAt == request.time;
}
// After: rules/posts.rules
type Post {
title: string(1..200);
body?: string(..10000);
status: 'draft' | 'published';
readonly createdAt: timestamp where value == request.time;
}Contents
- Quick start
includetype- Project layout
- Add to an existing project
- Testing and debugging
- CLI
- Programmatic API
- For AI agents
- Limitations
- Examples
- Development
Quick start
npm i -D firestore-rulekit # or: bun add -d firestore-rulekitNot published to npm yet. Until it is, build the package from this repo (
bun install && bun run build && npm pack) and install the tarball:npm i -D ./firestore-rulekit-0.1.0.tgz.
// rules/main.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
include "lib/auth.rules";
include "posts.rules";
match /{document=**} {
allow read, write: if false;
}
}
}// rules/lib/auth.rules
function isSignedIn() {
return request.auth != null;
}
function isUser(uid) {
return isSignedIn() && request.auth.uid == uid;
}// rules/posts.rules
match /posts/{postId} {
type Post {
readonly authorId: string where value == request.auth.uid;
readonly createdAt: timestamp where value == request.time;
title: string(1..200);
status: 'draft' | 'published';
}
allow read: if true;
allow create: if isSignedIn() && isValidPost(request.resource.data);
allow update: if isUser(resource.data.authorId)
&& isValidPostUpdate(request.resource.data, resource.data);
}npx rulekit build rules/main.rules -o firestore.rulesfirestore.rules is now a single, plain rules file, with a header marking it as generated.
include
include "relative/path.rules";- Where it goes: the file's contents are pasted at that spot and re-indented. Paths are relative to the file that contains the
include. - What the included code sees: the variables of the block it lands in. A helper that uses
databaseworks when it's included insidematch /databases/{database}/documents, and a type included insidematch /users/{uid}can useuid. - Including twice: a file already included in the same block is skipped, so including the same file twice there is harmless. A nested block (or a sibling block) gets its own copy, so its code sees that block's match variables; Firestore allows the inner copy to shadow the outer one. Include cycles are an error.
- Braces: an included file must close every
{it opens. - Own line:
includeandtype Name {must each start their own line, and atypegoes in amatchblock or at the top of a file, not inside a function. - Exact paths: an include path is relative to the including file (absolute paths aren't allowed) and must match the file's case on disk, even on macOS, so the build also works on Linux. Globs aren't supported.
- Same function name twice: a build error when both are visible from one block, whether in the same block or in an enclosing one, in either order. The error names both source locations. Firestore itself would reject the first case only at deploy time, and would silently let the inner definition win in the second.
type
match /posts/{postId} {
type Post {
readonly authorId: string where value == request.auth.uid;
readonly createdAt: timestamp where value == request.time;
always updatedAt: timestamp where value == request.time;
title: string(1..200);
body?: string(..10000);
status: 'draft' | 'published';
tags?: list<'news' | 'tech'>(..5);
meta?: PostMeta;
photoPath?: string | null where value == 'posts/' + postId + '.jpg';
}
type PostMeta {
source: 'web' | 'app';
}
}Each field is [readonly|always] name[?]: type [where expr];, one per line. // and /* */ comments are allowed in the body.
| Syntax | Meaning |
|---|---|
| string int number bool timestamp list map bytes path latlng float | Firestore type. Prefer number to float, because JS clients send 5.0 as an int; the build warns on float. (duration isn't allowed: Firestore can't store one.) |
| any | Any value, including null. A required any field must still be present. As with other types, only any \| null skips a where for null. |
| (min..max) (min..) (..max) | Inclusive. Size for string, list, map and bytes (a string's size counts UTF-16 code units, so an emoji can count 2); value for int, float and number. An open-ended number range also rejects Infinity. For an exclusive bound use where, as in price: number(0..) where value > 0 (the range also keeps out Infinity). |
| 'a' \| 'b' | One of these literals. Numbers, true and false work too; 1 \| 2 also requires an int, so 1.0 is rejected. |
| list<'a' \| 'b'> | Every element is one of these literals. Rules can't loop over a list, so only literals are allowed, and no numbers (rules can't tell 1 from 1.0 inside a list). |
| \| null | May be null. where is skipped when the value is null. |
| Name | Nested type: a map that passes isValidName. It can be combined with null only, and takes no where; put checks inside the nested type. If the nested type has readonly fields, the field holding it must be required and non-null (or itself readonly), because removing the map and adding it back would reset them. A readonly field can't hold a nested type with always fields, which would need to change on every update, so the build rejects it. always on a nested field checks the whole map on every update. |
| ? | May be absent. |
| readonly | Set on create, never changed afterwards. |
| always | Checked on every update, even when the write doesn't change it. Use it for updatedAt-style stamps. An always field can't be optional (no ?). |
| ... | On its own line: the type is open. Keys not listed are allowed and not checked, and may change on update; listed fields are checked as usual. |
| 'odd-name' | A quoted field name, for keys that aren't identifiers, like 'created-at': timestamp; or "it's": string; (no backslashes; Firestore reserves names like __x__). |
| where expr | Any single rules expression, with balanced brackets, that constrains the value: value is the field's value (so a match variable named value can't be used here), and it can call your functions and use other match variables. It can't use data, prev or changed, the generated functions' own names. A where that uses value is skipped when the field is absent or null, and on update when the field doesn't change. A where that doesn't use value, like featured?: bool where isAdmin(), is a condition on writing the field: it's checked whenever the field is set, changed, set to null or removed. |
A type generates two functions in place:
isValidPost(data)validates the whole document:- all required fields are present;
- no key outside the type is present;
- every field passes its check.
Use it for
create, or for anupdatethat must re-validate everything.isValidPostUpdate(data, prev)validates only what the write changes. It enforces:- only non-
readonlyfields of the type may change, so fields outside the type (server-written ones, for example) stay untouched; - each changed field must pass its check;
- removing a required field fails;
alwaysfields are checked on every update;- int and float fields are type-checked on every update. Firestore's diff treats
5and5.0as equal, so an unchanged-looking field could otherwise turn into a double. For the same reason, the valuewhereof a field that admits both (number,any,int | float) runs again when an unchanged-looking value switches between int and double; - a changed nested map that already existed is checked with the nested type's own update rules, so its
readonly,alwaysand int/float rules apply too. A new map gets the full check. An untouched nested map is re-checked only when its type has int/float oralwaysfields (at any depth); analwaysfield inside a nested type must be restamped on every update of the document.
Documents holding older, invalid data can still update their other fields. If nothing calls it, it's left out of the output.
- only non-
Rules that span several fields, such as "changing a consent must restamp consentsUpdatedAt", stay as normal expressions in the allow line.
Project layout
Recommended convention, for people and agents alike:
rules/
main.rules # entry point: includes lib/ once, then every top-level collection
lib/ # shared functions; visible to every collection file
auth.rules
posts.rules # match /posts/{postId}, and its type
users.rules # match /users/{uid}; includes its subcollections
users/
projects.rules # match /projects/{projectId}, inside users/{uid}- One file per collection, at its document path. The file for
/users/{uid}/projects/{projectId}isusers/projects.rules. main.rulesincludeslib/once at the top of the documents block. Collection files call those functions without including anything.- A type lives in the collection file it describes, inside the
matchblock, so it can use the match variables. - Deeper subcollections follow the same rule:
/users/{uid}/projects/{projectId}/tasks/{taskId}isusers/projects/tasks.rules, included inside the match inusers/projects.rules. - Multi-segment or repeated paths: a match like
/orgs/{orgId}/settings/{doc}goes where its first collection lives (orgs/settings.rules, included inside theorgsmatch); if the same path is matched in several places, keep them together in one file. - Helpers that use match variables (
uid,orgId) go in the collection file inside that match, not inlib/. - A
**wildcard ({document=**}) can appear once per path, counting enclosing matches, so don't include subcollection files inside a{name=**}match. - Collection-group rules (
match /{path=**}/comments/{id}) have no single parent, so they go in their own file named after the group, such ascomments.group.rules, included frommain.rules. - A shared type, used by several collections, goes in
lib/like shared functions (for examplelib/types.rules, included frommain.rules), so every collection sees it.
Add to an existing project
Your existing firestore.rules is already valid rulekit source:
npm i -D firestore-rulekit
mkdir rules && git mv firestore.rules rules/main.rules
npx rulekit build rules/main.rules -o firestore.rulesThen split rules/main.rules into files at your own pace, and wire the build in so firestore.rules never goes stale:
// firebase.json
"firestore": {
"rules": "firestore.rules",
"predeploy": ["npx rulekit build rules/main.rules -o firestore.rules"]
}// package.json
"scripts": {
"pretest": "rulekit build rules/main.rules -o firestore.rules"
}predeploy and pretest don't run for firebase emulators:start or emulators:exec, which read firestore.rules as it is. Build first there too, for example with scripts like these:
// package.json
"scripts": {
"rules": "rulekit build rules/main.rules -o firestore.rules",
"emulators": "npm run rules && firebase emulators:start --only firestore",
"test:rules": "npm run rules && firebase emulators:exec --only firestore 'npm test --ignore-scripts'"
}test:rules builds before the emulator starts, because the emulator loads firestore.rules at startup; --ignore-scripts skips pretest inside, so it builds once.
Whether to commit the generated firestore.rules is up to you. Committing it keeps deploy diffs reviewable.
When you replace a hand-written validator with a type, check how it was used on update. isValidXUpdate checks only the fields a write changes; a hand-written validator run on update usually re-checked the whole document, including rules that span fields. Keep those cross-field rules in the allow line, or call isValidX(request.resource.data) on update too.
Testing and debugging
- Testing: rules tests don't change.
@firebase/rules-unit-testingreads the generatedfirestore.rulesexactly as before. - Tracing an error to its source: the emulator reports lines in the generated file (
evaluation error at L214:24).rulekit wheremaps one to its source line:
In the file itself, eachnpx rulekit where rules/main.rules 214 # -> rules/users/projects.rules:12// >>> pathmarker names the file the lines below it come from, including the return to the including file, so the nearest marker above any line names its source. - Build errors name the file and line, and the whole include chain when the problem is in an included file:
rulekit: rules/main.rules:4: rules/users.rules:12: function isOwner is already defined at rules/lib/auth.rules:9; rename one (definitions in enclosing blocks are visible here)
CLI
rulekit build <entry.rules> [-o firestore.rules]
rulekit where <entry.rules> <line>
rulekit --help
rulekit --version-o: the output file. The default isfirestore.rulesin the current directory. Missing directories are created. rulekit refuses to write over one of its own source files, or over any existing file it didn't generate (move old hand-written rules torules/main.rulesfirst).where: prints the sourcefile:lineof a line of the built file (214,L214andL214:24all work; line 1 is the GENERATED header). It maps a fresh build of the sources, and warns when the built file (-o, defaultfirestore.rules) is out of date.- Warnings go to stderr, and the build still succeeds. Besides
float, the build warns about a.rulesfile under the entry's folder that nothing includes, a type nothing uses, a type whose valid writes would exceed Firestore's expression limit, a call to a function no source file defines, and awherethat calls a type's validator instead of declaring the field with that type. - Errors go to stderr, and the command exits with code 1.
Programmatic API
import { build } from 'firestore-rulekit';
const { rules, warnings } = build('rules/main.rules');
// rules: the compiled rules source (without the GENERATED header the CLI adds)
// warnings: string[], e.g. use of `float`build throws an Error whose message holds the source location, the same text the CLI prints. The result also has files, the real paths of every source file read. The package works from ES modules and CommonJS, and ships TypeScript types.
For AI agents
rulekit is designed to be picked up by coding agents from a few lines of instructions. Paste this into your project's AGENTS.md or CLAUDE.md:
## Firestore rules
- Source lives in `rules/` (entry `rules/main.rules`). `firestore.rules` is GENERATED; never edit it.
- Syntax is plain Firestore rules plus `include "relative/path.rules";` (pastes the file; a file already
included in an enclosing block is skipped). Included files must close every `{` they open.
- Document shapes are `type X { [readonly|always] field[?]: type [where expr]; }` blocks inside the collection's
match. They generate `isValidX(data)` (create) and `isValidXUpdate(data, prev)` (update). Run
`npx rulekit --help` for the field syntax. Don't hand-write key/type checks that a `type` can express.
- One file per collection, at its document path: `rules/posts.rules`, and subcollections in
`rules/users/projects.rules` (included inside `match /users/{uid}`). Shared functions go in `rules/lib/`.
`main.rules` includes every `lib/` file once; collection files don't include lib, they just call it.
- After editing: `npx rulekit build rules/main.rules -o firestore.rules`, then run the rules tests.
- Emulator errors cite lines in firestore.rules; `npx rulekit where rules/main.rules <line>` prints the source file:line.Limitations
- List elements: Firestore rules can't loop, so
list<...>can only restrict elements to literals. For anything else, uselist where yourCheck(value), as inexamples/app/rules/lib/values.rules. - Recursion: types can't refer to themselves, directly or through other types or helper functions, and functions can't call themselves in a cycle, because rules functions can't recurse. The build rejects both.
- Ruleset size: Firestore rejects rulesets over 256 KB. The build warns above it, and stops early if the output keeps growing (a file included in more and more blocks).
- Rules limits: Firestore evaluates at most 1000 expressions per request and 20 nested function calls. The build estimates what a valid write of each type costs, including helpers its
whereclauses call, and warns when the estimate passes 1000 (roughly 35–40 range-checked fields, or fewer with nested types; the estimate leans high for very cheap checks likebool), and rejects a type whose nested types andwherehelpers (and whatever they call) make more than 15 nested calls. Validators for large types are grouped in parentheses to stay under Firestore's expression-depth limit. - Field names:
serviceandrules_versionare written asdata['service'], since Firestore can't parse them after a dot. - Undefined functions: a call to a function that exists only in another, non-visible block is a build error. A call to a function that doesn't exist anywhere is reported by Firestore only when the rule runs.
Examples
| Example | What it shows | Run |
|---|---|---|
| examples/basic | Includes, a Post type with nested types, emulator tests. | bun run test:basic |
| examples/app | A production-size app: public content, owner-only member data behind a paid subscription, server-only collections. The 443-line hand-written original.rules and its port to one file per collection with types pass the same 107 tests. | bun run test:app |
Both need the Firebase CLI (firebase-tools) and Java for the Firestore emulator.
Development
rulekit is written in TypeScript and runs on Bun. The published package is bundled for Node, so consumers don't need Bun.
bun install
bun test ./test # compiler tests
bun run test:findings # regression tests from the adversarial rounds (examples/break-*)
bun run test:basic # example against the Firestore emulator
bun run test:app # production-size example: original vs port, same tests
bun run build # bundle dist/ for npmsrc/build.ts: include expansion, scope tracking, duplicate detection, and the call graph (cycles, nesting depth, cost warnings, pruning unused validators).src/schema.ts: thetypecompiler:compileTypeturns one block into its validators,linkTypeslinks nested types across the program.src/cli.ts: the command-line interface.
License
MIT
