Security Rules
NukeBase uses a JSON-based security rules system to control access to your database. Rules are defined in
server/rules.js and are evaluated for every database operation.
Available Variables in Rules:
admin- The standard auth context for the caller (admin.uid,admin.claims, etc.) — see Auth Context for the full shaperoot- The database object at the top leveldata- The current/old value at the path being accessednewData- The new value being written (for write/validate rules)$variables- Wildcard captures like$userId,$postIdctx- An object holding every wildcard on this path.ctx.$userIdand the bare$userIdread the same value; use whichever you prefer.
Rules never run for server-side calls. get, set, update, increment, remove and query called from app.js default to admin = "root" and short-circuit every check below. Rules govern clients. If you want a server-side call checked, pass the caller's auth context explicitly as the admin argument. This also means admin inside a rule is always a real auth context — the string "root" never reaches rule code.
Rule Types
Six rule types control access, in two parallel families — one for the JSON tree, one for the file store:
- read - Controls who can read data at a path (triggered by
get()andquery()operations) - write - Controls who can create, update, or delete data (triggered by
set(),update(),increment(), andremove()operations) - validate - Ensures data meets specific requirements (triggered by
set(),update()andincrement()operations) - fileRead - Controls file downloads and directory listings
- fileWrite - Controls file uploads and deletions
- fileValidate - Constrains an uploaded file's size, type and name
A seventh key, index, is not a rule at all — it declares a query index on the collection at that path. See Index Declarations below.
File rules live in the same file, on the same paths. A path carries both families independently: ["posts","p1"] consults read/write as a record and fileRead/fileWrite as a place files live. They cascade the same way — fileRead/fileWrite grant, fileValidate conjoins. Full semantics, the file descriptor and the differences from the data side are covered in File Storage.
Everything is default-deny. A path with no matching rule at any level along it is unreachable — for data and for files alike. There is no implicit grant to remove.
Rules are compiled once, at startup. Each expression becomes a real JavaScript function, and wildcards become real function parameters — so a mistyped posts.$id throws a ReferenceError naming posts rather than silently evaluating to undefined and denying forever. A rule that fails to compile, an index declared at the root or under a wildcard, or two wildcards with the same name on one path are all startup errors: the server refuses to boot rather than run with rules that don't mean what they say.
How Rules Are Checked:
Read and write rules grant access — they do not revoke it. When you read or write at a path like users.john.email, NukeBase walks from the root toward that path and evaluates each level that has a matching rule:
- Check
users— if its rule returnstrue, ALLOWED (stop here) - Check
users.john— if its rule returnstrue, ALLOWED (stop here) - Check
users.john.email— if its rule returnstrue, ALLOWED
Any single level returning true grants access. The operation is denied only if no level along the path grants. A "read": "false" at a parent does NOT prevent a child rule from granting access at a deeper path — it just means that level didn't grant on its own.
Validate rules behave differently. They cascade through every level along the write path AND into the new value, and ALL applicable rules must pass. Any single failure denies the write.
Rule Matching at Same Level:
- Read/Write rules: If you have both exact (
pets) and wildcard ($other) rules at the same level, BOTH must pass for access topets. - Validate rules: Only the most specific rule matches. Exact match (
pets) takes priority over wildcard ($other).
// These two rules are at the SAME LEVEL (both are direct children of the parent)
module.exports = {
"pets": {
"read": "true", // Rule 1: Anyone can read pets
"write": "admin.claims.role == 'petOwner'", // Rule 2: Must be pet owner
"validate": "newData.type == 'cat' || newData.type == 'dog'" // Only cats/dogs
},
"$other": { // ← This is at the SAME LEVEL as "pets" above
"read": "admin.claims.role == 'admin'", // Rule 3: Must be admin
"write": "false", // Rule 4: No writes allowed
"validate": "newData != null" // Not empty
}
}
// When accessing "pets":
// Read: BOTH "true" AND "admin.claims.role == 'admin'" must pass → Fails for non-admins!
// Write: BOTH "admin.claims.role == 'petOwner'" AND "false" must pass → Always fails!
// Validate: ONLY the "pets" rule applies (most specific)
Basic Example
module.exports = {
"users": {
"$userId": {
// Don't grant a blanket read at $userId — that grant cascades down
// and would override the deeper email rule. Grant read on the
// public-facing fields instead.
"write": "admin.uid == $userId", // Only the user can edit their profile
"name": { "read": "true" }, // Public
"bio": { "read": "true" }, // Public
"email": { "read": "admin.uid == $userId" } // Private — only the user
}
}
};
Path Patterns
Rules support different path patterns to match your data structure:
| Pattern | Description | Example |
|---|---|---|
users.john |
Exact path matching | Matches only users.john |
users.$userId |
Wildcard matching | Matches users.alice, users.bob, etc.The $userId variable captures the actual key |
posts.$postId |
Wildcard for collections | Matches any child: posts.abc, posts.xyz, etc. |
messages.$msgId |
Works with arrays too | Arrays are objects with numeric keys Matches messages.0, messages.1, messages.2 |
Arrays and Path Matching:
JavaScript arrays like ["red", "blue", "green"] are stored as objects with numeric keys:
{ "0": "red", "1": "blue", "2": "green" }
This means:
colors.0- Exact match for first elementcolors.$index- Wildcard matches all elements (0, 1, 2, etc.)colors- Matches the array itself
Operations and Their Rules
Different database operations trigger different combinations of rules:
| Operation | Rules Triggered | Description |
|---|---|---|
get() |
read | Only read rules are checked when retrieving data |
set() |
write + validate | Both write permission and data validation are required |
update() |
write + validate | Same as set() - must have permission and valid data |
increment() |
write + validate | Same as update(). newData is the resolved value after the delta is applied, not the delta itself |
remove() |
write | Only write rules are checked (newData is undefined) |
query() |
read | One check for the whole query, at path + the wildcard child level + childPath. Rules do not filter individual results |
| File download / list | fileRead | A grant on a directory cascades to everything in it — listings are not filtered per entry |
| File upload | fileWrite + fileValidate | Both run twice: once on the declared headers, then authoritatively on the bytes that arrived |
| File delete | fileWrite | fileValidate does not run, mirroring remove() |
Read rules are not a query filter. A query() is authorized once, before it runs; if that check passes, every matching record is returned. Rules are never evaluated per record, so you cannot use them to hide individual rows from a query. Worse, the wildcard binds to a literal "$" placeholder during that check, so "read": "admin.uid == $userId" denies all queries on the collection. See How a query is authorized for the pattern that works.
Rule Evaluation by Path Depth
The set of rules that actually applies to a given operation is determined dynamically by the depth of the path you're targeting. Two writes against the same rules file can hit completely different rules depending on how deep the operation lands. Designing security correctly means knowing exactly which rules will be evaluated for each call.
Read / Write — walked from root toward the target path
NukeBase iterates each level along the path and evaluates any matching rule. If any one level returns true, access is granted and evaluation stops. Deeper rules are not consulted past a grant. The operation is denied only if no level along the path grants.
Validate — cascades through every level and into the new value
Validate runs at every prefix of the write path AND at every leaf inside the new value. All applicable validate rules must pass; a single failure denies the write.
Given the rules below, here's what gets evaluated for writes at different depths:
module.exports = {
"store": {
"write": "admin.claims.role == 'admin'",
"products": {
"write": "admin.claims.role == 'manager'",
"$productId": {
"write": "admin.uid == data.ownerId",
"validate": "newData.name && newData.price > 0"
}
}
}
};
| Operation | Rules evaluated | Outcome |
|---|---|---|
set(["store"], {...}) |
Write: store.write onlyValidate: any validate rule reachable from the new value (e.g. store.products.$productId.validate for each product in the payload) |
Allowed only if admin. Note: only the top-level write rule is checked — the deeper write rules are not consulted, because the operation targets ["store"]. |
set(["store","products"], {...}) |
Write: store.write, then store.products.writeValidate: store.products.$productId.validate for each product leaf in the payload |
Allowed if the caller is admin OR a manager (any one returning true grants). Validate must also pass for every product written. |
set(["store","products","abc"], {name:"X", price:5}) |
Write: store.write, store.products.write, store.products.$productId.writeValidate: store.products.$productId.validate |
Allowed if admin OR manager OR the caller owns "abc". Validate runs against the new value. |
get(["store","products","abc"]) |
Read: store.read, store.products.read, store.products.$productId.read (none are defined here, so the call is denied) |
Denied — no level along the path grants read. |
Common pitfall — a blanket grant at a parent cascades. Writing "users.$userId.read": "true" means any deeper read rule like "users.$userId.email.read": "admin.uid == $userId" is effectively bypassed: the parent's true grants access first, and the email rule never runs. To restrict deeper data, don't grant blanket access at the parent — split the data into subnodes (e.g. public / private) and grant read only on the part you want exposed.
Mental model: read/write rules answer the question "is there any reason to allow this?" — one yes is enough. Validate rules answer "does the new data satisfy every constraint?" — one no is enough.
Rule Types in Detail
Read Rules
Control who can read data at a specific path:
// Simple read rule
// Don't put "read": "true" at the $postId level — it would cascade and
// override the draft restriction. Grant read on the published fields only.
"posts": {
"$postId": {
"title": { "read": "true" },
"body": { "read": "true" },
"draft": { "read": "admin.uid == data.authorId" } // Only author can read drafts
}
}
// Using variables in paths
"users": {
"$userId": {
"name": { "read": "true" }, // Public
"email": { "read": "admin.uid == $userId" } // Only the user can read their own email
}
}
Write Rules
Control who can create, update, or delete data:
// Basic write rule
"posts": {
"$postId": {
"write": "admin.uid == data.authorId", // Only author can edit
"createdAt": {
"write": "!data" // Can only set createdAt when creating (no previous data)
}
}
}
// How write rules cascade along the target path
// (any rule along the path that returns true is sufficient)
"store": {
"write": "false", // Blocks writes that TARGET ["store"] directly
"products": {
"write": "admin.claims.role == 'manager'", // Applies when writing AT ["store","products"] or deeper
"$productId": {
"write": "admin.uid == data.ownerId" // Applies when writing AT ["store","products",<id>]
}
}
}
// What actually happens:
// set(["store"], ...) → only store.write applies → DENIED
// set(["store","products"], ...) → store.write OR store.products.write
// → ALLOWED if user is a manager
// set(["store","products","abc"], ...) → store.write OR store.products.write
// OR store.products.$productId.write
// → ALLOWED if manager OR uid == data.ownerId
Validate Rules
Ensure data integrity and format requirements:
// Simple field validation
"users": {
"$userId": {
"age": {
"validate": "newData >= 13 && newData <= 120"
},
"email": {
"validate": "newData.includes('@') && newData.includes('.')"
}
}
}
// Validating objects with required fields
"posts": {
"$postId": {
"validate": "newData.title && newData.content && newData.title.length <= 200"
}
}
// Using data and newData to compare old and new values
"users": {
"$userId": {
"credits": {
// Ensure credits can only increase, not decrease
"validate": "newData >= data"
}
}
}
// Complex validation with multiple conditions
"products": {
"$productId": {
"validate": "newData.name && newData.price > 0 && newData.stock >= 0"
}
}
Array Validation
Arrays are validated using the same rule system, but understanding how paths are generated is essential for proper validation.
How Array Validation Works:
When you set/update an array, NukeBase generates validation paths for:
- The array itself - Path to the array as a whole
- Each array element - Individual paths like
["tags", "0"],["tags", "1"]
Arrays are treated as objects with numeric keys: ["red", "blue"] becomes {"0": "red", "1": "blue"}
// Example: update(["users", "john", "tags"], ["red", "blue", "green"])
// This generates paths:
// 1. ["users", "john", "tags"] ← Entire array
// 2. ["users", "john", "tags", "0"] ← Element 0: "red"
// 3. ["users", "john", "tags", "1"] ← Element 1: "blue"
// 4. ["users", "john", "tags", "2"] ← Element 2: "green"
// METHOD 1: Validate the ENTIRE array
"users": {
"$userId": {
"tags": {
// newData = entire array ["red", "blue", "green"]
"validate": "Array.isArray(newData) && newData.length <= 5"
}
}
}
// METHOD 2: Validate EACH element using wildcard
"users": {
"$userId": {
"tags": {
"$index": { // $index matches "0", "1", "2", etc.
// newData = individual element ("red", "blue", or "green")
"validate": "typeof newData === 'string' && newData.length < 20"
}
}
}
}
// METHOD 3: COMBINE both approaches
"users": {
"$userId": {
"tags": {
// Validate array properties
"validate": "Array.isArray(newData) && newData.length <= 5",
"$index": {
// Validate each element
"validate": "typeof newData === 'string' && newData.length < 20"
}
}
}
}
// Complex array validation with element uniqueness check
"users": {
"$userId": {
"favoriteColors": {
"$index": {
// Each color must be a valid hex code
"validate": "typeof newData === 'string' && /^#[0-9A-F]{6}$/i.test(newData)"
}
}
}
}
Important: Both the array-level rule AND element-level rules must pass. If you have rules at both levels, all of them are checked.
File Rules
Govern the file store from the same block as the record it belongs to:
"posts": {
"$id": {
"read": "true",
"write": "admin.uid == data.authorId",
"attachments": {
"fileRead": "true", // download + list
// A file rule reaching for a sibling RECORD must go through `root` —
// `data` here is the stat of the file path, not the post.
"fileWrite": "admin.uid == root.posts[$id]?.authorId", // upload + delete
"fileValidate":
"file.size < 10 * 1024 * 1024 && file.type.startsWith('image/')"
}
}
}
In a file rule: file (also spelled newData) is the incoming file descriptor — { name, ext, size, type, declaredType, sniffedType, path } — and data is the stat of whatever is already at that path ({ name, ext, size, type, mtime, isDir }) or undefined. Unlike the data side, data is the target's stat at every level of the walk, not the entry at each prefix. A throwing file rule denies and logs rather than surfacing its error to the caller.
Index Declarations
An "index" key declares query indexes on the collection at that path. It is not a rule and grants nothing — it only changes how fast queries run.
module.exports = {
"posts": {
// A LIST of field paths, each itself an array. The inner arrays are
// required: ["views","createdAt"] would read as one NESTED path
// (child.views.createdAt), not two separate indexes.
"index": [["views"], ["createdAt"], ["meta", "rating"]],
"$id": { "read": "true", "write": "admin.uid == data.authorId" }
}
};
Constraints, all enforced at startup:
- Must be on a concrete path — not at the root, and not inside a wildcard segment
- At most 4 declared indexes per collection; field paths at most 8 segments deep
- A malformed declaration throws on boot rather than being ignored
See Indexes for what the planner does with them and when you need one.
Available Variables
Rules have access to several context variables:
| Variable | Description | Available In |
|---|---|---|
data |
Current value at the path (before changes) | All rule types |
newData |
Value after the write operation | write, validate |
root |
Current database root | All rule types |
admin |
Auth context (see Auth Context) | All rule types |
$variables |
Values from wildcard path segments. Only wildcards on this node's own path are in scope — a rule cannot see a wildcard from a sibling branch. | All rule types |
ctx |
All wildcards on this path as one object. ctx.$userId === the bare $userId. |
All rule types |
file |
The incoming file descriptor — an alias for newData that reads correctly on the file side. null on delete. |
fileWrite, fileValidate |
Rules are full JavaScript; queries are not. Rule expressions compile via new Function, so Array.isArray, regex, typeof, .startsWith(), arithmetic and ternaries all work. Query strings run through a restricted evaluator that supports only comparisons, &&/||/! and .includes(). Don't copy an expression from one into the other.
Best Practices
- Start with restrictive rules, then add exceptions as needed
- Use validate rules to ensure data integrity
- Test rules thoroughly before deploying to production
- Keep rules simple and readable
- Only one validate rule per path - combine conditions with
&&or|| - Read/write rules grant access — any single rule along the path that returns
trueis sufficient. Don't put a blanket"read": "true"at a parent if you intend to restrict child paths; the parent grant cascades and the deeper rule never gets the chance to deny. - Validate rules only match the most specific rule at a given path
Common Mistakes to Avoid
Mistake 1: Multiple validate rules on same path
// WRONG - Only the last validate rule will be used!
"email": {
"validate": "newData.includes('@')",
"validate": "newData.includes('.')" // This overwrites the first rule!
}
// CORRECT - Combine with &&
"email": {
"validate": "newData.includes('@') && newData.includes('.')"
}