NukeBase

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 shape
  • root - The database object at the top level
  • data - The current/old value at the path being accessed
  • newData - The new value being written (for write/validate rules)
  • $variables - Wildcard captures like $userId, $postId
  • ctx - An object holding every wildcard on this path. ctx.$userId and the bare $userId read 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() and query() operations)
  • write - Controls who can create, update, or delete data (triggered by set(), update(), increment(), and remove() operations)
  • validate - Ensures data meets specific requirements (triggered by set(), update() and increment() 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:

  1. Check users — if its rule returns true, ALLOWED (stop here)
  2. Check users.john — if its rule returns true, ALLOWED (stop here)
  3. Check users.john.email — if its rule returns true, 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 to pets.
  • Validate rules: Only the most specific rule matches. Exact match (pets) takes priority over wildcard ($other).
Rule matching example
// 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

Simple security rules
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 element
  • colors.$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:

Rules used in the table below
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 only
Validate: 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.write
Validate: 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.write
Validate: 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:

Read rule examples
// 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:

Write rule examples
// 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:

Validate rule examples
// 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"}

Array validation methods
// 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:

Data rules and file rules on one path
"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.

Declaring indexes in rules.js
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 true is 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('.')"
}