npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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:

  • include to split rules into one file per collection plus shared helpers.
  • type to 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

npm i -D firestore-rulekit        # or: bun add -d firestore-rulekit

Not 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.rules

firestore.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 database works when it's included inside match /databases/{database}/documents, and a type included inside match /users/{uid} can use uid.
  • 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: include and type Name { must each start their own line, and a type goes in a match block 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 an update that must re-validate everything.

  • isValidPostUpdate(data, prev) validates only what the write changes. It enforces:

    • only non-readonly fields 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;
    • always fields are checked on every update;
    • int and float fields are type-checked on every update. Firestore's diff treats 5 and 5.0 as equal, so an unchanged-looking field could otherwise turn into a double. For the same reason, the value where of 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, always and 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 or always fields (at any depth); an always field 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.

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} is users/projects.rules.
  • main.rules includes lib/ 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 match block, so it can use the match variables.
  • Deeper subcollections follow the same rule: /users/{uid}/projects/{projectId}/tasks/{taskId} is users/projects/tasks.rules, included inside the match in users/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 the orgs match); 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 in lib/.
  • 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 as comments.group.rules, included from main.rules.
  • A shared type, used by several collections, goes in lib/ like shared functions (for example lib/types.rules, included from main.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.rules

Then 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-testing reads the generated firestore.rules exactly as before.
  • Tracing an error to its source: the emulator reports lines in the generated file (evaluation error at L214:24). rulekit where maps one to its source line:
    npx rulekit where rules/main.rules 214    # -> rules/users/projects.rules:12
    In the file itself, each // >>> path marker 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 is firestore.rules in 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 to rules/main.rules first).
  • where: prints the source file:line of a line of the built file (214, L214 and L214:24 all work; line 1 is the GENERATED header). It maps a fresh build of the sources, and warns when the built file (-o, default firestore.rules) is out of date.
  • Warnings go to stderr, and the build still succeeds. Besides float, the build warns about a .rules file 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 a where that 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, use list where yourCheck(value), as in examples/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 where clauses 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 like bool), and rejects a type whose nested types and where helpers (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: service and rules_version are written as data['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 npm
  • src/build.ts: include expansion, scope tracking, duplicate detection, and the call graph (cycles, nesting depth, cost warnings, pruning unused validators).
  • src/schema.ts: the type compiler: compileType turns one block into its validators, linkTypes links nested types across the program.
  • src/cli.ts: the command-line interface.

License

MIT