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

Rule Types

Three types of rules control different aspects of data access:

  • read - Controls who can read data at a path (triggered by get() operations)
  • write - Controls who can create, update, or delete data (triggered by set(), update(), and remove() operations)
  • validate - Ensures data meets specific requirements (triggered by set() and update() operations)

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
remove() write Only write rules are checked (newData is null)
query() read Read rules filter which items are returned

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",]
    }
  }
}

// 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.

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 All rule types

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('.')"
}