NukeBase

Server-Side API

NukeBase is a managed service that provides instant provisioning and deployment. Your project structure includes:

  • server/database.js: The core database engine
  • server/data/: Your database directory (a tree of data.json files joined by $split markers — see Storage & Backups)
  • server/files/: The file store — a plain directory tree mirroring the path arrays you address files with
  • server/rules.js: Security rules configuration (and query index declarations)
  • server/extensions/: Optional auth extensions — password login, easy passwordless email, magic link. Mounted from app.js; see Authentication
  • server/app.js: Your application configuration file
  • public/: Frontend files (index.html, css, js, etc.)
  • sys/deploy.js: Deploy program
  • sys/config.json: Deployment configuration
  • node_modules/: Dependencies (auto-generated)
  • package.json: NPM package configuration
  • package-lock.json: Dependency lock file

Setup and Initialization

Getting started with NukeBase is simple - provision your project through our managed service and start developing immediately.

Step 1: Create Your Project

Getting started is as simple as visiting a URL in your browser:

Create your project
1. Visit: https://nukebase.com/createuser
2. Fill in your project details (username, project name)
3. Click "Provision & Download"
4. Your project zip will download automatically

Instant Deployment: Your project is automatically provisioned, deployed, and live at:
https://username-project.nukebase.com

No build steps, no server configuration - just download the zip and start coding!

Step 2: Local Development Setup

After provisioning, extract the downloaded zip file and set up your VS Code workspace:

Setup in VS Code
# 1. Extract the downloaded project zip file
# Right-click the .zip file and select "Extract All"

# 2. Open VS Code
# File → Add Folder to Workspace → Select your extracted project folder

# 3. Open Terminal in VS Code
# Terminal → New Terminal → Select Folder As Directory

# 4. Install NukeBase CLI globally
npm install -g

# 5. Install NukeBase NPM Packages
npm install

# 6. Now you can use NukeBase commands:
nukebase push   # Push local changes to live server
nukebase pull   # Pull live server changes to local

NukeBase CLI Commands:

  • nukebase push - Upload your local changes to the live server
  • nukebase pull - Download the latest changes from the live server

Changes are synced in real-time, allowing you to develop locally and deploy instantly.

Push or pull will instantly remove or add folder/files to server/client unless sys/config "exclude": ["sys", "server/data.json"]

Step 3: Start Developing

Your project structure is ready to use:

  • /public: Edit your frontend files (HTML, CSS, JavaScript)
  • /server/app.js: Configure backend logic, domains, and database triggers
  • /server/rules.js: Define security rules for data access
  • /server/data/: Your real-time database directory (auto-synced)

Hot Reload: Changes to your /public files are instantly reflected on your live site. Backend changes in /server/app.js are automatically deployed.

Basic Server Configuration Structure

Your server/app.js file uses a module export pattern that provides access to all NukeBase APIs:

server/app.js structure
module.exports = ({
  // Data
  get, set, update, increment, remove, query, data,
  // Files
  getFile, setFile, removeFile, listFiles, fileStat, fileUrlFor, FILES_DIR,
  // Extension points
  addDbTrigger, addFileTrigger, addCallable, addConnectionTrigger,
  // Server
  addDomain, startDB, checkAuth, withBody, hashToken, generateRequestId
}) => {

  const path = require("path");

  const nukebase = addDomain({
    authPath: ["users"],
    host: "127.0.0.1", // optional - defaults to "127.0.0.1"
    port: 3000 // optional - defaults to 3000
  });

  nukebase.app.serveStatic("/*", path.join(__dirname, "../public"),
    (res, req) => { return true; }
  );

  startDB(nukebase);
}

Available Exports

Your app.js module receives the following functions and objects:

Export Description
get, set, update, increment, remove, queryCore data operations, all synchronous and running as root by default — see CRUD Operations
getFile, setFile, removeFile, listFilesFile store operations. Unlike the data operations these are async — see File Storage
fileStatSynchronous stat of a file path: { name, ext, size, type, mtime, isDir }, or undefined
fileUrlForBuild the /_file/… URL a client would fetch for a given path array
FILES_DIRAbsolute path of the file store root (defaults to server/files)
addDomainCreate a domain with auth and server config
startDBStart the server (call once at the end)
addDbTriggerRegister database triggers — see Database Triggers
addFileTriggerRegister file-store triggers (reserve, release, write, remove) — see File Storage
addCallableRegister callable functions — see Callable Functions
addConnectionTriggerRegister connection triggers — see Connection Triggers
generateRequestIdGenerate a random hex string (crypto.randomBytes). Default 8 bytes (16 hex chars); pass a byte count for longer IDs
dataDirect read-only reference to the in-memory database object — see CRUD Operations
checkAuthAuthenticate a request and return the auth context. Called automatically by postWithBody; call manually in raw post handlers
withBodyBody-parsing middleware wrapper. Wraps a (res, req) handler to parse the request body and populate req.body and req.admin. Used internally by postWithBody — useful if you need to attach body parsing to a custom HTTP method
hashTokenSHA-256 hash a string and return the hex digest. Used internally for session token storage — useful in extensions or callables that need to store/verify tokens the same way the auth system does

Starting the Database

Start the NukeBase server with configuration options by calling startDB() once at the end of your configuration:

Starting the database
// Basic setup - pass the domain object to startDB
const nukebase = addDomain({
  authPath: ["users"],
  host: "127.0.0.1", // optional
  port: 3000 // optional
});

startDB(nukebase);

addDomain Configuration Options:

  • authPath: Array - path to user authentication data (e.g., ["users"])
  • host: String (optional) - the IP address to bind to
    • Use "127.0.0.1" to accept connections only from the local machine (default)
    • Use a specific IP address like "126.23.45.1" to bind to that server address
    • Use "0.0.0.0" to accept connections from any IP
  • port: Number (optional) - the port to listen on (default: 3000)

Environment Variables

The server reads these from the environment. Every one has a working default — you normally set none of them.

Deployment

VariableDefaultEffect
DOMAINPublic origin used to build magic-link URLs and console output (e.g. https://your-app.nukebase.com).
SOCKETIf set, the server listens on this Unix domain socket instead of host/port (useful behind nginx/Caddy). The socket file is created, chmoded to 777, and replaced if it already exists.
TRUST_PROXYautoPeer-only override. See the note below — you almost certainly do not need this.
DB_ALLOW_MISSING_SPLITS0Set to "1" to downgrade a missing or corrupt $split subdirectory from a fatal startup error to a logged warning, treating the subtree as empty. Default is fail-fast so a bad disk state can't silently produce data loss on the next flush.

The client IP is worked out from where the connection came from — there is nothing to configure. A forwarding header (x-real-ip, then x-forwarded-for) is honored only when the peer could plausibly be a proxy you put there:

  • Unix socket — no peer address exists, so the header is the only source of a client IP. This is how the managed platform runs.
  • Loopback or private address (127.0.0.0/8, ::1, RFC1918, fc00::/7, fe80::/10) — a local proxy. The header is used if present, otherwise the peer.
  • Public address — the open internet. The peer address wins and the header is ignored, because a caller on a public port can set and rotate that header themselves.

This replaced an earlier TRUST_PROXY flag that had exactly one correct value per deployment and no way for anyone to know which. Setting TRUST_PROXY="false" or "0" still forces the peer address unconditionally — useful only for a topology that answers the question wrongly, such as a reverse proxy on another host reaching the engine over a public address. Any other value, including "true", has no effect.

Files

VariableDefaultEffect
FILES_DIRserver/filesRoot of the file store. Nothing outside it is reachable.
FILE_MAX_BYTES67108864 (64 MB)Hard ceiling on a single file, enforced on the bytes that actually arrive.
FILE_MAX_DIR_ENTRIES10000Entries returned by one directory listing before it is truncated.
FILE_WRITE_RATE_MAX120Uploads + deletes per IP per minute.

Limits and capacity

VariableDefaultEffect
WS_RATE_LIMIT_MAX500000WebSocket messages per second per connection. Exceeding it sends a rateLimit notice and closes the socket.
HTTP_MAX_BODY_BYTES1048576 (1 MB)Default body cap for postWithBody handlers. Override per route with { maxBytes }.
QUERY_MAX_RESULTS1000000Maximum records one query may return before it is truncated.
QUERYSUB_MAX_RESULTS1000Matching records above which a querySub is refused or dropped.
WINDOW_SUB_SCAN_MAX1000Collection size above which a windowed subscription requires an index-order plan.
MAX_QUERY_BUCKETS_PER_PATH64Distinct query-subscription windows allowed on one path/event/action.
MAX_TOTAL_INDEX_ENTRIES8000000Total index entries held across the whole process.
KEY_INDEX_MIN_RECORDS1000Collection size at which an automatic $key index becomes worth building.

Graceful shutdown is automatic. On SIGTERM or SIGINT the server clears its flush timer, writes every dirty subtree to disk, and exits — so a normal restart never loses the last few seconds of writes.

Important: Call startDB() only once at the end of your server configuration. This function initializes and starts the database server with all configured domains and settings. Multiple calls to startDB() can cause resource conflicts and unexpected behavior. Once you've defined all your domains, middleware, triggers, and functions, finish with a single call to startDB() to launch your server.

Serving Static Files

Serve files from a local directory using app.serveStatic. This is typically the very next thing you'll register after addDomain — it powers your frontend, assets, and any other files the browser needs. Registers both GET and HEAD handlers automatically:

Basic static file serving
// Serve everything in ../public at the root
nukebase.app.serveStatic("/*", path.join(__dirname, "../public"));

// Serve a private directory (no auth callback = open access)
nukebase.app.serveStatic("/assets/*", path.join(__dirname, "assets"));
Parameter order: The serveStatic auth callback receives (res, req)res first, then req. This matches the underlying uWebSockets convention and is the opposite of Express.

Signature

app.serveStatic(routePattern, rootDir, auth?)

  • routePattern - URL pattern with trailing /* (e.g., "/*", "/admin/*"). The matched portion before /* is treated as the mount point and stripped before resolving against rootDir.
  • rootDir - Absolute path to the directory to serve from. Use path.join(__dirname, "...").
  • auth (optional) - Async callback returning a boolean. If provided, runs before any file is served. Return true to allow, false to respond with 401.

Auth Callback

The auth callback receives (res, req) — same convention as postWithBody:

Static files with authentication
// Only logged-in users can access /private/*
nukebase.app.serveStatic("/private/*", path.join(__dirname, "../private"),
  async (res, req) => {
    return Boolean(req.uid);
  }
);

// Admins only
nukebase.app.serveStatic("/admin/*", path.join(__dirname, "../admin"),
  async (res, req) => {
    return req.claims?.role === "admin";
  }
);

The req object passed to the auth callback contains the auth fields populated by checkAuth (uid, username, email, claims, cookies, urlParams, referer, userAgent, ip, url) plus host and method. Identity fields are present only when the user is authenticated. Returning a falsy value sends a 401 response automatically.

The callback is a hook, not just a gate. It runs on every request before a byte is served and receives res, so it is the natural place for cross-cutting work — request logging, page-view analytics, setting a cookie. Do the work, then return true to serve the file normally. Keep it cheap and never let it throw: it sits on the path of every asset your site serves.

Behavior Details

  • Index files: Requests ending in / automatically serve index.html from that directory.
  • Path traversal protection: Any URL that resolves outside rootDir (e.g., via ../) returns 403 Forbidden.
  • Directories: A URL resolving to a directory without a trailing slash gets a 301 Moved Permanently to the same URL with one added, so relative links inside the served page resolve correctly.
  • Missing files: Return 404 Not Found.
  • Streaming: Files are streamed in 16KB chunks rather than buffered into memory — safe for large files.
  • Content-Type: Set automatically from the file extension via the mime package; falls back to application/octet-stream for unknown types.
  • Image caching: Files with image/* content types receive Cache-Control: public, max-age=31536000 (1 year). Other file types are served without cache headers.

CRUD Operations

The same six methods — get, set, update, increment, remove, query — work identically on both client and server. The only difference is how results are returned:

Sync vs Async:

  • Client: Returns a Promise. Use await or .then().
  • Server: Returns the result directly. No await needed (the in-memory database is accessed without a network round-trip).

Server-side calls run as root. get, set, update, increment, remove and query called from app.js — inside a trigger, a callable, or a POST handler — default to admin = "root", which bypasses security rules entirely. That is what makes them useful for privileged work, and it means an unfiltered server-side write is not protected by the rules you wrote for clients. To have a call checked as a particular caller, pass their auth context as the third argument: set(path, value, admin).

Durable Writes

By default, writes (set, update, increment, remove) are applied to the in-memory database immediately and flushed to disk on a debounce timer (~5 seconds). If you need to guarantee that a write is persisted to disk before continuing, pass the durable option:

Durable write examples (server-side)
// Durable set — waits for fsync before resolving
await set(["users", "john", "email"], "john@example.com", "root", { durable: true });

// Durable update
await update(["users", "john"], { lastLogin: Date.now() }, "root", { durable: true });

// Durable remove
await remove(["users", "john", "tempData"], "root", { durable: true });

How durable writes work:

  • The write is applied to the in-memory database immediately (subscribers and triggers fire as normal).
  • The response is held until the data is flushed to disk via fsync.
  • Multiple durable writes in the same event-loop tick are batched into a single fsync (group commit), so there is no per-write I/O penalty.
  • If the flush fails, the response returns status: "Failed" with message: "Durable flush failed", even though the in-memory state was updated. A retry timer will attempt to reconcile disk later.

When to use durable: Use { durable: true } for writes where data loss on crash is unacceptable — authentication tokens, financial transactions, user-created content. Skip it for ephemeral data like presence status or analytics counters where the debounce flush is sufficient.

Client-side durable writes: The durable flag also works from the client SDK over WebSocket. The server holds the WebSocket response until the fsync completes, so the client's await only resolves once the data is on disk.

Same method, two return styles
// Client (async)
const user = await get(["users", "john"]);
console.log(user.data);

// Server (sync)
const user = get(["users", "john"]);
console.log(user.data);

Examples in this section use the client (async) form. To use any of these on the server, drop await / .then() — the method calls themselves are unchanged.

Path argument shape: path must be an array for all data operations (get, set, update, remove, query) and their subscription variants — even for a single segment, write ["users"], not "users". (Internally a string path is only meaningful as the name of a registered callable when invoking callableFunction; it is not a one-segment shorthand for data ops, and would be iterated character-by-character.)

Hard limits (enforced server-side — operations that exceed any of these are rejected):

  • Path depth: ≤ 64 segments
  • Path segment length: ≤ 256 characters per string segment
  • Numeric segments: non-negative integers ≤ 1,000,000 (no NaN, Infinity, negatives, or non-integers)
  • Forbidden segments: "", ".", "..", anything containing /, \, or null bytes, or any prototype-pollution key (__proto__, constructor, prototype, etc.)
  • Value nesting depth: ≤ 64 levels. Applies to the value you write, on both transports and to set as well as update — so the same payload is never accepted on a path that doesn't exist yet and rejected on one that does.
  • Update merge depth: ≤ 64 levels (deeper merges are rejected)
  • Increment fields: ≤ 256 numeric fields touched by one increment call
  • Query string length: ≤ 512 characters
  • Subscriptions per WebSocket session: ≤ 256
  • WebSocket message size: ≤ 16 MB
  • requestId length: ≤ 128 characters (echoed back in replies)
  • Forbidden values: the string "$split" is reserved. On disk it is indistinguishable from the engine's own "this subtree lives in its own directory" marker, so storing it as a value is refused at the door.

These limits prevent individual clients from holding open too many subscriptions or amplifying server CPU/memory with adversarial payloads. Stay well under them in normal use.

Setting Data

The set() function creates or replaces data at a specific path:

Auto-creation: The set() function automatically creates any missing parent containers in the path. The type of each created container is chosen by the next segment:

  • String segment → object ({})
  • Integer segment → array ([])

Numeric path segments must be actual integers, not numeric strings — 0 and "0" behave differently. The path validator only accepts non-negative integers as numeric segments; numeric strings are treated as object keys.

Array vs object auto-creation
// Integer segment → array container is auto-created
set(["messages", 0], "hi");
// Result: { messages: ["hi"] }

// String segment → object container is auto-created
set(["messages", "0"], "hi");
// Result: { messages: { "0": "hi" } }

// Mixed: a nested integer segment creates an array inside an object
set(["users", "matt", "scores", 0], 100);
// Result: { users: { matt: { scores: [100] } } }
Setting data examples
// Set a complete object
set(["users", "john"], { name: "John Doe", age: 32 }).then(response => {
    console.log("User created successfully");
});

// Set a single value
set(["users", "john", "email"], "john@example.com").then(response => {
    console.log(response);
});

// Auto-creates parent objects - even if 'users' doesn't exist
set(["users", "alice", "profile", "preferences", "theme"], "dark").then(response => {
    // Creates: { users: { alice: { profile: { preferences: { theme: "dark" } } } } }
    console.log("Theme set with auto-created parent objects");
});

Getting Data

Retrieve data with the get() function:

Getting data examples
// Get a single user
get(["users", "john"]).then(response => {
    console.log(response.data);  // User data
});

// Get entire collection
get(["users"]).then(response => {
    const users = response.data;
    // Process users...
});

Updating Data

Update existing data without replacing unspecified fields:

Auto-creation: Same behavior as set() — missing parent containers are created automatically, with the type chosen by the next segment (string → object, integer → array). See the array-vs-object example under Setting Data above.

Updating data examples
// Update specific fields
update(["users", "john"], {
    lastLogin: Date.now(),
    loginCount: 42
}).then(response => {
    console.log(response);
});

// Update a single property
update(["users", "john", "status"], "online").then(response => {
    console.log(response);
});

// Auto-creates missing parent objects
update(["settings", "app", "notifications", "email"], true).then(response => {
    // If 'settings' doesn't exist, creates the entire path
    console.log("Setting created with auto-generated parents");
});

Incrementing Data

The increment() function applies numeric deltas atomically. Use it wherever you would otherwise read a number, add to it, and write it back — that read-modify-write is a race, and this isn't.

Incrementing data examples
// Add 1 to a single counter
const r = await increment(["posts", "p1", "views"], 1);
console.log(r.data);  // 43 — the RESULTING value, so no follow-up get() is needed

// Negative deltas subtract
await increment(["users", "john", "credits"], -10);

// A nested object of numbers is applied as ONE all-or-nothing write
const r2 = await increment(["stats", "2026-08"], {
  views: 1,
  bandwidth: 84213,
  byCountry: { US: 1 }
});
console.log(r2.data);
// { views: 1291, bandwidth: 9930118, byCountry: { US: 402 } }
// Every field resolved to its new absolute value

// Missing values start at 0 — no need to seed a counter before using it
await increment(["stats", "brand-new-key"], 5);   // → 5

// Durable, like the other writes
await increment(["ledger", "balance"], 250, "root", { durable: true });

How increment works: the deltas are resolved against the current values first, producing a plain object of absolute numbers. That resolved object is then what security rules see as newData, what gets written, and what comes back in response.data. So a validate rule like "newData <= 100" is checked against the result, not the delta — which is what you want for a cap.

Rules can still reach the delta, because they also receive the previous value as data: newData - data is the increment.

rules.js — constraining increments
module.exports = {
  "accounts": {
    "$id": {
      "write": "$id == admin.uid",
      // Blocks any increment that would take the balance negative
      "balance": { "validate": "newData >= 0" },
      // Caps how much a single increment may add
      "score":   { "validate": "newData - data <= 10" }
    }
  }
};

Increment rejects (the whole call fails, nothing is written) when:

  • A delta is not a finite number, or the payload is neither a number nor an object of numbers
  • The existing value at a target field is present but not a finite number — "Cannot increment non-numeric value at <field>". A missing or null value is treated as 0, so only a genuinely wrong type fails.
  • A result would exceed Number.MAX_SAFE_INTEGER in either direction
  • The payload touches more than 256 fields, nests deeper than 64 levels, or an object in it is empty

Strict about the value, not about its parents. The type check applies to the number being incremented, not to the containers above it — increment inherits the same path behaviour as set and update, which replace a non-object intermediate rather than failing:

  • set(["a"], 5) then increment(["a","b"], 1) succeeds and leaves a as { b: 1 } — the 5 is gone.
  • An object payload aimed at an array replaces the array wholesale: increment(["arr"], { 0: 5 }) turns [1, 2] into { "0": 6 } — element 1 is lost, not just reshaped. Incrementing a single element by path (increment(["arr", 0], 5)) keeps the array intact and is the safe form.

Use validate rules if you need the shape of a subtree enforced.

Increments are ordinary JavaScript numbers. Repeated fractional increments accumulate the usual floating-point drift, and results beyond Number.MAX_SAFE_INTEGER are rejected outright because addition stops being exact there. For money, either round at the call site or store integer units (cents).

Increment fires as an update. Subscribers on update@ and triggers registered with addDbTrigger("update", …) both see it — there is no separate increment@ event to subscribe to.

Removing Data

Delete data at a specific path:

Removing data examples
// Remove a user
remove(["users", "john"]).then(response => {
    console.log("User deleted");
});

// Remove a specific field
remove(["users", "john", "temporaryToken"]).then(response => {
    console.log(response);
});

Server-Side: Direct Access via data

On the server only, the data export gives you direct read access to the raw in-memory database object. This skips the overhead of get() for fast lookups inside triggers, callables, and middleware:

Using the data export (server only)
module.exports = ({ data, get, set, ... }) => {

  // Direct read — access the raw database object
  const userName = data.users?.john?.name;  // "John"
  const allUsers = data.users;  // { john: {...}, alice: {...} }

  // Compared to using get():
  const user = get(["users", "john"]);
  console.log(user.data.name);  // "John"
};

Read-only. Always use set(), update(), and remove() to modify data — these run subscriptions, triggers, security rules, and persistence. Writing directly to data bypasses all of these.

Query Operations

Query allows you to search through collections and find items that match specific conditions. The query string uses JavaScript expressions where child represents each item being evaluated:

How queries work: NukeBase iterates through each child at the specified path and evaluates your condition. Items where the condition returns true are included in the results.

This page covers writing the query itself. Four more cover what you do with it:

Page Covers
Query Authorization The single collection-level read check — and why an ownership rule denies queries outright
Sorting & Pagination orderBy, desc, limit, startAt, cursor and count, plus the metadata a windowed query returns
Query Indexes Declaring indexes in rules.js, and reading the plan to see whether one is being used
Query childPath Querying and returning only a nested portion of each record
Querying data examples
// Basic equality check
query({
    path: ["users"],
    query: "child.age == 32"
}).then(response => {
    console.log(response.data);  // All users who are exactly 32
});

// Using comparison operators
query({
    path: ["products"],
    query: "child.price < 50"
}).then(response => {
    console.log(response.data);  // All products under $50
});

// Compound conditions with AND (&&)
query({
    path: ["products"],
    query: "child.price < 100 && child.category == 'electronics'"
}).then(response => {
    console.log(response.data);  // Affordable electronics
});

// Compound conditions with OR (||)
query({
    path: ["users"],
    query: "child.role == 'admin' || child.role == 'moderator'"
}).then(response => {
    console.log(response.data);  // All admins and moderators
});

// Text search with includes()
query({
    path: ["posts"],
    query: "child.title.includes('JavaScript')"
}).then(response => {
    console.log(response.data);  // Posts with "JavaScript" in the title
});

// Checking nested properties with childPath
query({
    path: ["users"],
    childPath: ["profile", "location"],
    query: "child == 'New York'"  // child refers to the location value
}).then(response => {
    // Returns: { matt123: { profile: { location: "New York" } } }
    // `child` in the query is the value at childPath; the response wraps
    // that value back in the childPath structure to mirror the DB shape.
    console.log(response.data);
});

// Combining multiple conditions
query({
    path: ["orders"],
    query: "child.status == 'pending' && child.total > 100 && child.items.length > 2"
}).then(response => {
    console.log(response.data);  // Large pending orders with multiple items
});

// Checking if a property exists
query({
    path: ["users"],
    query: "child.premiumAccount == true"
}).then(response => {
    console.log(response.data);  // All premium users
});

// Using NOT operator
query({
    path: ["tasks"],
    query: "child.completed != true"
}).then(response => {
    console.log(response.data);  // All incomplete tasks
});

// Date comparisons (assuming timestamps)
query({
    path: ["events"],
    query: "child.date > " + Date.now()
}).then(response => {
    console.log(response.data);  // Future events
});

Query Syntax Reference

Queries are evaluated by a restricted safe expression engine — not full JavaScript. The query string is parsed by a hand-written evaluator that supports only the operators and methods listed below. Anything outside this list (arithmetic, regex, typeof, Array.isArray, .startsWith, .toLowerCase, ternaries, function calls other than .includes(), array indexing with []) will not parse correctly and will fail or return an empty result.

This is different from Security Rules, which compile via new Function and have access to the full JavaScript language. Don't copy a complex rule expression into a query and expect it to work.

Supported in queries:

  • Boolean: ||, &&, !
  • Comparison: ===, !==, ==, !=, >, <, >=, <=
  • Method: .includes(arg) on strings or arrays (one literal or path argument)
  • Property access: dotted paths only (child.foo.bar); .length works as a plain property read on arrays/strings
  • Literals: numbers, single- or double-quoted strings, true, false, null, undefined
  • Parentheses for grouping

Queries support these operators and methods:

Operator/Method Description Example
== Equal to child.status == 'active'
!= Not equal to child.deleted != true
<, >, <=, >= Comparison child.age >= 18
&& Logical AND child.active && child.verified
|| Logical OR child.role == 'admin' || child.role == 'mod'
.includes() String contains child.email.includes('@gmail.com')
.length Array/string length child.tags.length > 3

Important: The child variable represents each item at the path you're querying. For example, when querying "users", child represents each individual user object.

Query Authorization

A query is checked once, before it runs, against the read rules along path + the wildcard child level + childPath. Understanding that one sentence prevents two mistakes.

Read rules do not filter query results. There is a single permission check for the whole query. If it passes, every matching record is returned; if it fails, the query returns status: "Failed" and nothing else. Rules never run per record, so you cannot rely on them to strip private rows out of a result set — use childPath to scope what the query can reach, or keep private data in a separate subtree.

A read rule that compares against a wildcard will deny queries. The check walks a synthetic path with a $ placeholder standing in for "any child", so a wildcard variable binds to the literal string "$" rather than a real key. Given:

"users": { "$uid": { "read": "admin.uid == $uid" } }

a get(["users","matt"]) succeeds for Matt, but query({ path: ["users"], … }) is denied for everyone — the rule evaluates admin.uid == "$". To make a collection queryable, grant read with a rule that does not depend on the wildcard's value:

"users": {
  "$uid": {
    "public":  { "read": "true" },                     // queryable with childPath: ["public"]
    "private": { "read": "admin.uid == $uid" }         // get-only, never queryable
  }
}

The practical consequence: scope by path, not by filter. Put each user's data somewhere they can be granted, and query inside it — rather than querying a shared collection and hoping rules narrow the result.

Scoping a query per user
// WRONG — a shared collection with an ownership rule. The collection check
// runs once with $orderId unbound, so this returns nothing at all.
//   rules: { "orders": { "$orderId": { "read": "admin.uid == data.ownerId" } } }
query({ path: ["orders"], query: "child.status == 'open'" });

// RIGHT — give each user their own subtree, grant that, and query inside it.
//   rules: { "orders": { "$uid": { "read": "admin.uid == $uid" } } }
query({ path: ["orders", myUid], query: "child.status == 'open'" });

// Also fine — a genuinely public collection.
//   rules: { "posts": { "$postId": { "read": "true" } } }
query({ path: ["posts"], query: "child.published == true" });

Server-side query() runs as root and bypasses all of this, so a callable or a POST endpoint is the right place to implement a filtered read that rules cannot express.

Sorting, Limits and Pagination

A query takes five optional window parameters. They work on query() and — except for count — on querySub and querySubChanged as well.

Parameter Type Meaning
orderBy array of field names Sort by a field on each record: ["views"], ["meta","rating"]. Always an array, never a dotted string — one spelling means one canonical form everywhere (queries, indexes, subscription keys). Omit it to order by record key. Max 8 segments.
desc boolean Reverse the order. Default false.
limit integer 1–1,000,000 Maximum records to return. Implies an order — without orderBy, the window is ordered by record key.
startAt integer 0–1,000,000 Skip this many matching records. Mutually exclusive with cursor.
cursor opaque string Resume after a previous page. Pass back the nextCursor from the last response. Mutually exclusive with startAt.
count true Return only how many records match. data is null; the number is in response.count. Cannot be combined with limit, startAt or cursor, and is not supported on subscriptions.
Sorting and paging
// Top 10 posts by view count
const top = await query({
    path: ["posts"],
    query: "child.published == true",
    orderBy: ["views"],
    desc: true,
    limit: 10
});
// Read the results IN ORDER via response.order — a JSON object's key
// order is not something to depend on.
top.order.forEach(key => console.log(key, top.data[key].views));

// Order by a nested field
await query({
    path: ["products"],
    query: "true",
    orderBy: ["meta", "rating"],
    desc: true,
    limit: 20
});

// Order by record key (just omit orderBy)
await query({ path: ["messages"], query: "true", limit: 50 });

// Cursor pagination — the correct way to walk a large collection
let cursor;
for (;;) {
  const page = await query({
    path: ["events"],
    query: "true",
    orderBy: ["ts"],
    limit: 100,
    ...(cursor && { cursor })
  });
  page.order.forEach(k => handle(page.data[k]));
  if (!page.nextCursor) break;   // absent == you reached the end
  cursor = page.nextCursor;
}

// Offset pagination — simpler, but O(offset) to skip. Prefer a cursor
// for anything deep.
await query({ path: ["events"], query: "true", orderBy: ["ts"],
              startAt: 200, limit: 100 });

// Just the number
const c = await query({ path: ["users"], query: "child.active == true", count: true });
console.log(c.count);   // 8412   (c.data is null)

Records missing the orderBy field are excluded from the result, not sorted to one end. A record with no views key cannot participate in an ordering by ["views"], so orderBy: ["views"] silently narrows the result set to records that have one. If you need them included, give every record the field (even as 0 or null).

Ordering across mixed types is total and stable. Values sort by type first — null < boolean < number < string < everything else — and then by value within a type. Ties are broken by record key, where keys that look like integers sort numerically and ahead of non-integer keys. This is what makes a cursor able to resume from an exact position.

Cursors are opaque — but they are not secret. A cursor encodes the last row's sort value and key. Pass it straight back; don't parse or construct one. A malformed or over-long (> 512 character) cursor is rejected with Invalid cursor.

Query response metadata

When a query uses any window parameter (orderBy, limit, startAt, cursor or count), the response carries extra fields alongside data. A plain query({path, query}) keeps its original two-field shape and does not include them.

FieldMeaning
orderArray of matching record keys, in window order. This is the authoritative ordering — read the result through it.
planHow the query was executed: "scan", "index-filter", "index-order", "index-union" or "index-count". See Indexes.
examinedHow many records the engine had to look at. Compare it to the number returned to see whether an index is doing its job.
truncatedtrue if the engine stopped early against a hard scan limit. The accompanying status is "Incomplete" rather than "Success".
nextCursorPresent only when more records exist beyond this page. Its absence is how you detect the end.
countPresent only for count: true queries.

Hard scan limits. A query examines at most 8,000,000 records and returns at most 1,000,000. Hitting either stops the query early and returns status: "Incomplete" with truncated: true — a partial answer, clearly labelled, rather than an unbounded stall on the single-threaded engine. The query string itself is capped at 512 characters.

Query Indexes

Without an index, a query is a full scan of the collection: every record is examined, and an ordered query then sorts all of them. That is fine for hundreds of records and wrong for hundreds of thousands. Declare an index and the engine binary-searches instead.

Indexes are declared in server/rules.js, next to the rules for the same path, with an "index" key holding a list of field paths, each itself an array:

rules.js — declaring indexes
module.exports = {
  "posts": {
    // Two separate single-field indexes. The inner arrays are required —
    // ["views","createdAt"] would read as ONE nested path, not two indexes.
    "index": [["views"], ["createdAt"], ["meta", "rating"]],

    "$id": { "read": "true", "write": "admin.uid == data.authorId" }
  },

  "users": {
    "index": [["city"]],
    "$uid": { "read": "true" }
  }
};

Rules for declaring an index:

  • Must sit on a concrete path — not at the root, and not under a wildcard segment. "posts": { "index": … } is valid; "posts": { "$id": { "index": … } } throws at startup.
  • Each entry is a field path array: ["views"], ["meta","rating"]. Max 8 segments deep.
  • At most 4 declared indexes per collection.
  • An invalid declaration is a startup error, not a silent no-op — you will know immediately.

What the planner does with them. The plan field in a query response tells you which path was taken:

  • "index-order" — best case. Binary-search to the start of the window, then walk limit entries. Cost is O(log n + limit) regardless of collection size. Chosen when orderBy matches an index.
  • "index-filter" — an equality or range condition matched an index, so only the candidate keys are examined instead of the whole collection.
  • "index-union" — an || query where every branch hit an index; the branches' key sets are merged.
  • "index-count" — a count: true query answered from index positions alone, without touching a single record.
  • "scan" — no usable index. Every record is examined. Check examined; if it is much larger than the number returned, this is the query to index.

Ordering by record key gets an index automatically. A paged, key-ordered query over a collection of 1,000 records or more builds a $key index on demand — you don't declare it, and $key is not a value you can pass to orderBy (omitting orderBy is how you ask for that ordering). Automatic key indexes are dropped first if the process runs out of index budget, so a declared index always wins.

Indexes are maintained incrementally as records are written, so a write costs a bounded insert or move rather than a rebuild. A write above the indexed collection invalidates it, and it is rebuilt lazily on the next query that needs it. Across the whole process, indexes hold at most 8,000,000 entries (MAX_TOTAL_INDEX_ENTRIES); an index that would take the process past that is disabled with a warning in the server log and its queries fall back to scanning.

Query childPath

The childPath parameter allows you to query and return only specific nested portions of your data. This is especially useful for separating public and private data, improving performance, or working with complex data structures.

How childPath works:

  • Navigation: childPath navigates to a nested position in your data
  • Query context: The child variable in your query refers to the data at that nested position
  • Response structure: Results include the full path with childPath, so you know which parent item matched
childPath examples
// Data structure:
// {
//   users: {
//     matt123: {
//       public: { name: "Matt", age: 25, city: "NYC" },
//       private: { ssn: "123-45-6789", salary: 80000 }
//     },
//     john456: {
//       public: { name: "John", age: 30, city: "LA" },
//       private: { ssn: "987-65-4321", salary: 90000 }
//     }
//   }
// }

// Query WITHOUT childPath - queries full user objects
query({
    path: ["users"],
    query: "child.public.age > 21"
}).then(response => {
    console.log(response.data);
    // Returns: {
    //   matt123: { public: {...}, private: {...} },
    //   john456: { public: {...}, private: {...} }
    // }
    // You get FULL user objects including private data
});

// Query WITH childPath - queries only public portion.
// `child` inside the query refers to the data AT childPath ("public").
// The response value MIRRORS the database shape: each match is wrapped
// in the same childPath structure, so you can read it the same way you'd
// read the original tree.
query({
    path: ["users"],
    childPath: ["public"],
    query: "child.age > 21"  // child still refers to the "public" object
}).then(response => {
    console.log(response.data);
    // Returns: {
    //   matt123: { public: { name: "Matt", age: 25, city: "NYC" } },
    //   john456: { public: { name: "John", age: 30, city: "LA" } }
    // }
    // The "public" wrapper is preserved; "private" is not present because
    // the query never walked into it.
});

// Multiple childPath levels — wrapper is nested to match
query({
    path: ["users"],
    childPath: ["public", "address"],
    query: "child.city == 'NYC'"  // child refers to the "address" object
}).then(response => {
    console.log(response.data);
    // Returns: {
    //   matt123: { public: { address: { city: "NYC", state: "NY" } } }
    // }
    // Each childPath segment shows up in the response, in order.
});

childPath Use Cases

Practical childPath use cases
// Use Case 1: Return a smaller payload
// childPath narrows what is READ and SENT, not what is PERMITTED. It is a
// shape and bandwidth tool, not an authorization one — see Query authorization.
query({
    path: ["users"],
    childPath: ["public"],
    query: "child.verified == true"
}).then(response => {
    // Only the "public" subtree is walked and returned. This does not grant
    // or deny anything: if the query is authorized at all, it is authorized
    // for every child at that path.
    displayPublicProfiles(response.data);
});

// Use Case 2: Performance - Return only needed data
// When clients only need profile info, not full user objects
query({
    path: ["users"],
    childPath: ["profile"],
    query: "child.country == 'USA'"
}).then(response => {
    // Smaller response payload, faster transmission
    renderUserProfiles(response.data);
});

// Use Case 3: Complex filtering on nested arrays
// Query specific nested collections
query({
    path: ["orders"],
    childPath: ["items"],
    query: "child.quantity > 5"
}).then(response => {
    // Returns: {
    //   order123: { items: { itemA: {quantity: 10, ...}, ... } }
    // }
    // The "items" wrapper is preserved so the result mirrors the DB shape.
    console.log("Orders with high-quantity items:", response.data);
});

// Use Case 4: Separating data concerns
// Different parts of your app query different data sections
query({
    path: ["products"],
    childPath: ["inventory"],
    query: "child.stock < 10"
}).then(response => {
    // Warehouse dashboard only needs inventory data
    showLowStockAlert(response.data);
});

When to use childPath:

  • You want to exclude certain fields from the response payload (public vs private data)
  • You need to improve query performance by returning less data
  • You're querying nested collections or arrays within parent objects
  • Not for access control — childPath is chosen by the caller, so it restricts nothing. Authorization is the single collection-level read check described in Query authorization.

Important: When using childPath, remember that child in your query refers to the data AT the childPath position, not the root object. Adjust your query conditions accordingly.

Numeric childPath segments preserve array shape. Segments inside childPath can be non-negative integers, and they walk into arrays in your data. The response wrapper is built to match: numeric segments produce arrays, string segments produce objects.

Numeric childPath segments
// Data structure:
// users.matt123 = { scores: [42, 87, 99], name: "Matt" }
// users.john456 = { scores: [10, 20, 30], name: "John" }

// childPath ending at index 0 of "scores"
query({
    path: ["users"],
    childPath: ["scores", 0],
    query: "child > 25"   // child refers to the integer at scores[0]
}).then(response => {
    console.log(response.data);
    // Returns: {
    //   matt123: { scores: [42] }
    // }
    // The wrapper preserves the array shape from the DB.
});

// childPath ending at index 1 of "scores"
query({
    path: ["users"],
    childPath: ["scores", 1],
    query: "child > 25"
}).then(response => {
    console.log(response.data);
    // Returns: {
    //   matt123: { scores: [null, 87] }
    // }
    // Index 1 is preserved. The leading slot is null because the query only
    // matched scores[1] — sparse-array slots become explicit nulls when JSON
    // is sent over the WebSocket. The matched value is still at index 1.
});

JSON null padding for non-zero indices: When a numeric childPath segment is greater than 0, the slots before the matched index are filled with null in transit. This keeps the index correct on the client (so response.data.matt123.scores[1] works) at the cost of leading nulls. Iterate with care, or filter null entries if your code can't tolerate them.

File Storage

NukeBase stores files on disk alongside the JSON tree, addressed by the same path arrays and governed by the same rules.js. A post's record lives at ["posts","p1"]; its attachments live at ["posts","p1","attachments","hero.png"], and both sets of rules sit in one block.

Why files don't go in the JSON tree: the data tree is held entirely in memory and re-serialized on every flush. A 20 MB image stored as base64 would be re-encoded on every write to its subtree. Files live outside the tree, are streamed rather than buffered, and never participate in a flush.

This page covers how the store is addressed and reached. The rest of the file API is split across six more:

Page Covers
File Client API Uploading and downloading from the browser — setFile, getFile, listFiles, removeFile, progress and cancellation
File Server API The async server-side operations, plus fileStat and fileUrlFor
File Rules fileRead, fileWrite and fileValidate — who can reach what, and what an upload is allowed to be
File Triggers addFileTrigger and its four points: reserve, release, write, remove
File Quotas A per-user storage limit built from triggers and increment
File Limits & Safety Path resolution, atomic uploads, and the size and rate caps

Paths and the file store root

Files live under server/files/ (override with the FILES_DIR environment variable). The directory is created at startup. Nothing is registered per directory — rules alone decide what is reachable, and file rules default-deny, so a path with no fileRead/fileWrite at any level simply does not exist as far as the server is concerned.

Path segment rules (stricter than data paths, because these become real filenames):

  • Non-empty strings ≤ 256 characters, or non-negative integers ≤ 1,000,000 (a filesystem has no array/object distinction — integers are simply directory or file names)
  • Maximum 64 segments; the joined relative path must be ≤ 200 characters (on Windows, the resolved absolute path must also be ≤ 250 characters)
  • May not contain / \ : * ? " < > | or any control character
  • May not be "." or "..", and may not end with a dot or a space (Windows silently strips those, which would make two distinct rule paths collide on one file)
  • May not be a Windows reserved device name — CON, PRN, AUX, NUL, COM1COM9, LPT1LPT9, with or without an extension
  • May not start with $ — reserved for engine markers and in-flight uploads
  • May not be a prototype-pollution key (__proto__, constructor, prototype, …)

Case collisions are refused, not silently merged. On Windows and macOS Pic.png and pic.png are one file but two distinct rule paths — so a write through the permissive spelling could land on the file governed by the stricter one. Creating a name that differs from an existing entry only by case fails with 409 Conflict. Overwriting with the exact same name is unaffected.

HTTP routes

Every file operation is reachable over HTTP under /_file/. Path segments are URL-encoded, and the encoding round-trips exactly — / and \ are illegal inside a segment, so splitting the URL back on / can only ever rebuild the array that was sent.

Method URL Does
GET/_file/posts/p1/hero.pngDownload the file (streamed with backpressure)
GET/_file/posts/p1?list=1List one directory level as JSON
HEAD/_file/posts/p1/hero.pngHeaders only, no body
PUT / POST/_file/posts/p1/hero.pngUpload (raw request body is the file)
DELETE/_file/posts/p1/hero.pngDelete; add ?recursive=1 to delete a directory

Download response headers:

  • Content-Type — resolved from the file's leading bytes first, then its extension. The upload checks resolve it the same way, so a rule never enforces one type while the download serves another.
  • X-Content-Type-Options: nosniff — on every response.
  • Content-Dispositioninline normally, so images and PDFs behave as expected. Forced to attachment for types a browser would execute in your origin: text/html, application/xhtml, image/svg, application/xml, text/xml.
  • Cache-Control: private, no-cache — contents can change and rules can be revoked, so a shared cache must never hold these.

Denied and missing both answer 404. This is deliberate: a distinguishable 403 would let anyone map which paths exist behind a rule that refuses them. The same applies to directory listings.

Status codes

CodeMeaning
400Malformed path — bad percent-encoding, illegal segment, too long, too deep. Also a directory delete without ?recursive=1.
403Upload refused by fileWrite or fileValidate
404Not found or not permitted (download, listing, delete)
409On upload: a file differing only by case already exists at this path, or the path is an existing directory
413Body exceeds FILE_MAX_BYTES
429Upload/delete rate limit exceeded for this IP
500Disk error, or a reserve file trigger threw

File Client API

The browser SDK exposes five file methods. They travel over HTTP rather than the WebSocket — the socket protocol is JSON-only, and framing binary through it would buy nothing a PUT doesn't already give. Cookies ride along, so the caller is the same admin the socket sees.

Client file methods
import createClient from './sdkmod.js';
const { getFile, setFile, listFiles, removeFile, fileUrl } = await createClient();

// Upload — body may be a Blob, File, ArrayBuffer, TypedArray or string
const picker = document.querySelector('input[type=file]');
const result = await setFile(["posts", "p1", "attachments", "hero.png"], picker.files[0], {
  onProgress: (fraction) => { bar.style.width = (fraction * 100) + '%'; }
});
// result → { status: "Success", size: 84213, type: "image/png",
//            name: "hero.png", path: ["posts","p1","attachments","hero.png"] }

// Download — resolves with a Blob
const blob = await getFile(["posts", "p1", "attachments", "hero.png"]);
img.src = URL.createObjectURL(blob);

// Or skip the download entirely and link straight to it.
// Rules still apply when the browser fetches the URL — this is a link, not a bypass.
img.src = fileUrl(["posts", "p1", "attachments", "hero.png"]);
// → "/_file/posts/p1/attachments/hero.png"

// List one directory level
const entries = await listFiles(["posts", "p1", "attachments"]);
// → [{ name: "hero.png", isDir: false, size: 84213, type: "image/png", mtime: 1770000000000 }, …]
// Names are relative — build a child path as [...path, entry.name]

// Delete. Directories need { recursive: true } so a mistyped path
// cannot take a whole tree with it.
await removeFile(["posts", "p1", "attachments", "hero.png"]);
await removeFile(["posts", "p1", "attachments"], { recursive: true });

File methods throw; data methods don't. get/set/query resolve with { status: "Failed" }. The four file methods reject with a NukeBaseFileError carrying .status (the HTTP code), .body and .url, so you branch on a number instead of parsing a message. Wrap them in try/catch.

Error handling and cancellation
const controller = new AbortController();
cancelBtn.onclick = () => controller.abort();

try {
  await setFile(["uploads", "big.zip"], file, {
    signal: controller.signal,
    onProgress: (p) => console.log(Math.round(p * 100) + '%')
  });
} catch (err) {
  if (err.name === 'AbortError') return;      // user cancelled
  switch (err.status) {
    case 403: alert("A rule refused this upload"); break;
    case 409: alert("A file with that name (differing only by case) already exists"); break;
    case 413: alert("File is too large"); break;
    case 429: alert("Too many uploads — slow down"); break;
    default:  alert("Upload failed: " + err.body);
  }
}

Every file method accepts opts.onProgress and opts.signal. onProgress is called with a fraction from 0 to 1 (upload progress for setFile, download progress for getFile). signal is a standard AbortSignal; an aborted request rejects with err.name === 'AbortError'.

All four run on XMLHttpRequest rather than fetch. That is not legacy: fetch cannot report upload progress at all — request streaming needs duplex: "half", is Chrome-only, and fails outright on HTTP/1.1 — and XHR also gives download progress and abort() for free. Nothing is given up, because these methods buffer into a Blob and never used response streaming. Uploads stream straight to disk server-side, so a large file never sits in server memory either.

File Server API

Five file functions and two helpers are destructured from your app.js module argument. Unlike the synchronous data operations, the file operations are async — they touch the disk — so await them.

Server file operations
module.exports = ({ getFile, setFile, removeFile, listFiles,
                   fileStat, fileUrlFor, addFileTrigger, FILES_DIR, ... }) => {

  // Write. content may be a Buffer, TypedArray or string.
  const w = await setFile(["reports", "2026-08.csv"], csvString);
  // → { status: "Success", size: 4821, type: "text/csv", name: "2026-08.csv" }

  // Read. Resolves with a Buffer in .data.
  const r = await getFile(["reports", "2026-08.csv"]);
  // → { status: "Success", data: <Buffer>, name, size, type, mtime }
  // → { status: "Path not found", data: null }  if it isn't there

  // List one level.
  const l = await listFiles(["reports"]);
  // → { status: "Success", data: [{ name, isDir, size, type, mtime }, …] }
  // Capped at FILE_MAX_DIR_ENTRIES; a truncated listing sets .truncated = true

  // Delete. Directories require { recursive: true }.
  await removeFile(["reports", "2026-08.csv"]);
  await removeFile(["reports"], "root", { recursive: true });

  // Synchronous stat — no await, returns undefined if nothing is there.
  const st = fileStat(["reports", "2026-08.csv"]);
  // → { name, ext: ".csv", size, type, mtime, isDir: false }

  // Build the URL a client would fetch.
  fileUrlFor(["reports", "2026-08.csv"]);  // "/_file/reports/2026-08.csv"
};

Server-side file calls run as root by default. Every one of these takes an optional admin argument that defaults to the string "root", which bypasses file rules entirely — exactly like get/set on the data side. To have a call checked against your rules, pass the caller's auth context through explicitly:

addCallable("uploadAvatar", async (data, admin) => {
  // Checked against fileWrite/fileValidate as the *caller*, not as root
  return await setFile(["avatars", admin.uid + ".png"], data.bytes, admin);
});

File Rules

fileRead, fileWrite and fileValidate govern the file store exactly the way read, write and validate govern the data tree — on the same trie, with the same path arrays and the same wildcards. A path carries both sets independently.

rules.js — record rules and attachment rules side by side
module.exports = {
  "posts": {
    "$id": {
      "read":  "true",
      "write": "admin.uid == data.authorId",

      "attachments": {
        "fileRead":  "true",
        // A file rule reaching for a sibling RECORD must go through `root` —
        // `data` here is file-side state, not the post.
        "fileWrite": "admin.uid == root.posts[$id]?.authorId",
        "fileValidate": "file.size < 10 * 1024 * 1024 && file.type.startsWith('image/')"
      }
    }
  },

  "avatars": {
    "$uid": {
      "fileRead":  "true",
      "fileWrite": "admin.uid == $uid",
      "fileValidate": "file.size <= 2 * 1024 * 1024"
    }
  }
};

Semantics mirror the data side exactly:

  • fileRead grants — any level along the path returning true allows, and the grant cascades over everything beneath it. Governs download and listing. Because a grant cascades, one check on a directory covers every entry in it, so a listing never costs one rule evaluation per file.
  • fileWrite grants — same walk. Governs upload and delete.
  • fileValidate conjoins — every matching level must pass, and a single failure denies. It does not run on delete, exactly as validate doesn't run on remove.

"!data" is write-once. Because data is the existing file's stat and is undefined when nothing is there, "fileWrite": "admin.uid == $uid && !data" permits the first upload to a path and refuses every overwrite — the same idiom as "write": "!data" on the data side.

Two deliberate differences from the data side:

  • data is the stat of the path being acted on, and it is the same at every level of the walk — not the entry at each prefix. Statting every ancestor would be a syscall per level to answer a question nobody asks. So data means "what is already at this path" ({ name, ext, size, type, mtime, isDir }) or undefined if nothing is.
  • fileValidate cascades over path prefixes only. A file descriptor is flat, so unlike validate there is nothing to descend into.

The file descriptor

In fileWrite and fileValidate, the incoming value is bound to both file and newData — the same object under two names, because newData reads like a JSON payload and this isn't one. It is null on a delete.

FieldValue
file.nameThe last path segment, e.g. "hero.png"
file.extLowercased extension including the dot, e.g. ".png"
file.sizeSize in bytes
file.typeResolved MIME type: sniffed content wins, then the extension, then the declared header
file.declaredTypeRaw Content-Type the client sent (least trustworthy — it is whatever the caller typed)
file.sniffedTypeType detected from the leading bytes, or ""
file.pathThe full path array

Uploads are validated twice, and the second run is the real one.

  • Before the first byte, against Content-Length and Content-Type — a cheap early-out so a request with no write grant never reaches the disk.
  • After the last byte, against the actual byte count and the type sniffed from the leading bytes — authoritative. Both headers are attacker-chosen, so a rule like file.size < 5*1024*1024 enforced only against the declared value would be enforced against a number the client picked.

Content sniffing recognises PNG, JPEG, GIF, WebP, MP4, PDF, ZIP, gzip, SVG, HTML and XML. The case that matters is markup wearing an image extension: a rule reading file.type.startsWith('image/') sees text/html for a .png full of <script>, so the check it appears to make is the check it actually makes.

A throwing file rule denies, and logs. Data rules let the error reach the WebSocket reply; file operations are served over HTTP, where a rule's internal error text should not reach the caller. The failure is written to the server log so a broken rule is diagnosable rather than mysteriously silent — but from the client it is indistinguishable from a deliberate refusal. Keep ?. in any rule that walks the tree: root.usage?.[$uid]?.bytes is a working quota check, root.usage[$uid].bytes is one that denies everything the moment usage is empty.

The store root can never be listed. An empty path array is rejected outright, and fileRead on one returns false because there is no level at which to grant.

File Triggers

addFileTrigger(action, path, handler) mirrors addDbTrigger, and is what closes the loop on anything a file rule reads out of the data tree: a rule can check root.usage[uid].bytes, but only a trigger can move that number. Paths are matched by prefix, so a trigger on ["posts"] sees every file operation beneath it.

ActionFires
"reserve"Book the cost. Once with the declared descriptor before a byte is accepted, and again with the actual descriptor once the real size is known.
"release"Unbook it. Whenever an upload fails, is rejected, or the connection aborts — and once mid-upload, immediately before the second reserve.
"write"After the bytes are committed. Notification only; ctx.reserved is the booked size.
"remove"After a delete commits.

The handler receives one context object:

  • ctx.path — the full path array of the file
  • ctx.file — the descriptor described above, or null on a delete
  • ctx.existing — the stat of what was already there, or undefined
  • ctx.action — the action name that fired
  • ctx.reserved — the committed size, on "write"
  • ctx.admin — the caller's auth context, or null for a server-side call

reserve is a gate; the other three are notifications. If a reserve handler throws, the write is refused (500). Anything else would store the file and charge nothing for it — the quota would stop enforcing with no error anywhere. write, remove and release fire after the operation has already committed, so a throw there is logged and the operation still succeeds.

Matching and scope

  • Paths match by prefix, so a trigger on ["posts"] sees every file written anywhere beneath it.
  • They fire for both entry points — an HTTP upload and a server-side setFile() — so a privileged server-side write is not a door around your accounting.
  • They do not fire for operations that were refused, so a rejected upload never reaches your handler.
  • They run synchronously inside the operation. Keep them short; a slow handler is time the single-threaded engine is not spending on anyone else.

File Quotas

A worked use of those four points, and the reason reserve exists at all.

Why four points and not one. An upload is not atomic. Data writes are free of read-modify-write races — nothing awaits between reading the old value and writing the new one. A streaming upload awaits constantly, so two uploads can both pass a root.usage[uid].bytes + file.size < quota rule before either finishes, and neither sees the other. The fix is to book the cost before the bytes rather than after. Because increment() is synchronous and atomic, a reservation taken this way is race-free even though the upload around it is not.

An app therefore writes exactly two accounting handlers — one adding ctx.file.size, one subtracting it — and every path reconciles itself.

A per-user storage quota, in full
// app.js — the cost of an upload is the DELTA. An upload to a path that
// already holds a file REPLACES it, so it only costs the difference.
const d = (c) => c.file.size - (c.existing?.size ?? 0);

addFileTrigger("reserve", ["uploads"], (c) =>
  increment(["usage", c.path[1], "bytes"],  d(c)));

addFileTrigger("release", ["uploads"], (c) =>
  increment(["usage", c.path[1], "bytes"], -d(c)));

// Deletions give the space back.
addFileTrigger("remove", ["uploads"], (c) =>
  increment(["usage", c.path[1], "bytes"], -(c.existing?.size ?? 0)));

…and the rule that spends it. data is the existing file's stat, so subtracting it keeps the check consistent with the delta the triggers book:

rules.js — the quota that spends it
"uploads": {
  "$uid": {
    "fileRead":  "admin.uid == $uid",
    "fileWrite": "admin.uid == $uid",
    "fileValidate":
      "(root.usage?.[$uid]?.bytes ?? 0) + file.size - (data?.size ?? 0) <= 5e8"
  }
}

Usage lives in the data tree as an ordinary record, so you can inspect it, correct it, subscribe to it, and render a meter from it — and a fileValidate rule reads it live. Nothing here is special-cased by the engine.

Book the difference, not the size. This is the mistake that looks correct and fails slowly. An upload to a path that already holds a file replaces it, so it costs file.size - existing.size. Charging the full size leaks the replaced file's bytes on every overwrite, and usage climbs until the quota locks the user out of space nothing is occupying. Note the optional chaining too — a fileValidate that throws on a missing root.usage denies, so write root.usage?.[$uid], never root.usage[$uid].

The release/reserve swap at the end of an upload is not bookkeeping fussiness. It is what stops an upload's own reservation from counting against it: a rule reading usage + file.size <= quota must see usage without this upload in it, or a 2 KB file against a 3 KB quota fails its own second check. Release, check and re-reserve all run synchronously with no await between them, so no other upload can interleave.

File Limits & Safety

LimitDefaultEnvironment variable
Maximum file size64 MBFILE_MAX_BYTES
Entries returned by one listing10,000FILE_MAX_DIR_ENTRIES
Uploads + deletes per IP per minute120FILE_WRITE_RATE_MAX
Store rootserver/files/FILES_DIR
Path depth64 segments
Segment length256 characters
Joined relative path200 characters

The size cap is enforced on the bytes that arrive, not on Content-Length — a client that lies about the length is cut off at FILE_MAX_BYTES mid-stream and the partial temp file is removed. A permissive fileValidate can never authorise unbounded disk use.

The upload rate limit exists because uploads are not messages. The WebSocket limiter allows 500,000 messages per second — a sane ceiling for JSON and a meaningless one for an operation costing a disk write and up to FILE_MAX_BYTES of space. Uploads and deletes get their own budget, keyed by IP rather than session, because an HTTP request may carry no session at all.

Path resolution is the security boundary. Every file operation turns its path array into a real filesystem path in one place and nowhere else, and it re-validates from scratch rather than trusting the caller — file operations arrive over HTTP and from server-side code, neither of which passes through the WebSocket message validator. Two separate checks run: a lexical containment check that catches ../-style escapes in the path itself, and a realpath check that catches a symlink already sitting inside the store pointing out of it — where the path is clean but the resolution is not.

Add server/files to exclude in sys/config.json. nukebase push and nukebase pull delete files that are absent on the other side. Without that entry, your first push wipes every user upload on the live server — your local server/files is empty or stale, and the deploy makes the live one match it.

{ "exclude": ["sys", "server/data", "server/files"] }

Atomic uploads

An upload lands in a $tmp- prefixed file beside its destination and is then renamed into place. Two properties fall out of that: readers see either the old bytes or the new ones and never a half-written file, and because a leading $ is a rejected path segment, an in-flight upload has no addressable path — it cannot be downloaded and it never appears in a listing. Leftovers from a crashed or aborted upload are swept at startup.

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

The admin Auth Context

Several server-side APIs receive an admin object describing the current caller. The shape is the same everywhere — only the calling context differs.

Where you'll see it

Object Shape

The admin object always contains request metadata. Identity fields (uid, username, token, claims) are present only when the caller has a valid session cookie.

Property When Authenticated When Not Authenticated
uid User's unique ID undefined
username User's username, or "" for an account that has none (demo and email-only accounts) undefined
email User's email, falling back to username if no separate email is stored undefined
token Session token from cookie undefined
claims Custom claims object (e.g., { role: "admin" }) undefined
urlParams Parsed query string parameters Parsed query string parameters
cookies Parsed cookies object Parsed cookies object
referer Referer header (or "") Referer header (or "")
userAgent User-Agent header (or "") User-Agent header (or "")
ip Client IP address Client IP address
url Request URL path Request URL path

Common Patterns

Reading admin in different contexts
// In a callable
addCallable("getProfile", (data, admin, sessionId) => {
  if (!admin.uid) return { status: "Failed", message: "Login required" };
  return get(["users", admin.uid]).data;
});

// In a connection trigger
addConnectionTrigger("open", (admin, sessionId) => {
  console.log("Connected:", admin.uid || "anonymous", "from", admin.ip);
});

// In a postWithBody handler (req.admin)
nukebase.app.postWithBody("/api/me", (res, req) => {
  if (!req.admin.uid) return res.send(JSON.stringify({ status: "Failed" }), "401 Unauthorized");
  res.send(JSON.stringify({ uid: req.admin.uid, claims: req.admin.claims }));
});

// In a raw post handler (manual checkAuth)
nukebase.app.post("/api/me-raw", (res, req) => {
  const admin = checkAuth(req, res);
  res.end(JSON.stringify({ uid: admin.uid }));
});

// In a security rule (rules.js)
module.exports = {
  "users": {
    "$userId": {
      "write": "admin.uid == $userId",
      "private": { "read": "admin.uid == $userId" }
    },
    "adminPanel": {
      "read": "admin.claims.role == 'admin'"
    }
  }
};

Anonymous callers still get an admin object. Identity fields will be undefined, but request metadata (ip, userAgent, cookies, etc.) is always populated. Always check admin.uid before assuming the caller is logged in.

"root" and the three states in a trigger

Server-side data and file operations default to admin = "root", a sentinel that bypasses security rules. Rules never see it — checkRead/checkWrite short-circuit on it before evaluating anything — so admin.uid inside a rule is always safe to reach for.

Triggers are different: they run after the write, and handing them the raw sentinel would turn ctx.admin.uid into a crash on every server-side write. So "root" becomes null there, leaving three states a handler can tell apart:

ctx.adminMeans
nullA server-side call — no request, no user
An object with no uidAn anonymous client (still has ip, userAgent, referer)
An object with a uidA signed-in client

ctx.admin is the immediate caller, not whoever set the change in motion. A write made inside a callable or a POST handler runs as root, so a trigger on it sees null even though a person plainly caused it. To carry real identity through a privileged path, pass it explicitly into the write: set(path, value, admin).

Database Triggers

Run server code in response to database changes using addDbTrigger:

Database trigger example
// Create a trigger for when a request is updated
addDbTrigger("update", ["requests", "$requestId"], function(context) {
  // The context object contains all relevant information about the change
  const beforeNotes = context.dataBefore?.notes;
  const afterNotes = context.dataAfter?.notes;
  // Replace "pizza" with pizza emoji
  const newNotes = afterNotes.replaceAll("pizza", "🍕");
  // Avoid infinite loop by checking if we already replaced
  if (newNotes === afterNotes) {
    return;
  }
  // Update the data with our modified version
  update(context.path, { notes: newNotes });
});

Key components of database triggers:

  • addDbTrigger(eventType, pathArray, callbackFunction)
  • Path arrays use wildcards like $userId to match any value at that position

Event Types

  • "set" - Triggered when data is created or completely replaced
  • "update" - Triggered when data is partially updated
  • "remove" - Triggered when data is deleted
  • "value" - Triggered for all changes (set, update, remove)

increment() fires as "update". There is no separate increment event — register on "update" (or "value") to see it.

Triggers run after the write is committed to memory, and their dataBefore snapshot is captured before it. For a { durable: true } write, they run after the disk flush completes, alongside the subscriber notifications — so a durable write's trigger never observes state that failed to persist.

Wrap risky work in your own try/catch. Database triggers are not individually guarded, so a throw from one aborts the remaining triggers for that write. The write itself has already been applied and its success reply already sent, so the client can then receive a second, uncorrelated status: "Failed" message for an operation it was already told succeeded. (On the durable path each item is caught and logged instead. Connection triggers and file triggers are individually guarded, because they run inside socket callbacks and HTTP handlers where an uncaught throw would take down more than the one operation.)

Path Patterns

Use an array path with wildcards to match specific data paths:

  • ["users", "$userId"] - Matches any user path like ["users", "john"] or ["users", "alice"]
  • ["posts", "$postId", "comments", "$commentId"] - Matches any comment on any post

Context Object

Your callback function receives a context object containing:

  • context.path — The path matched by the trigger pattern, truncated to the trigger pattern's depth. If you register a trigger on ["users", "$userId"] and a write happens at ["users", "john", "email"], context.path is ["users", "john"] — not the deeper write path. Wildcard segments are filled in with the actual key from the write.
  • context.dataAfter — The post-write state at the trigger's path (i.e. at context.path, not the originating write path). May be undefined for remove operations or when the path no longer exists.
  • context.admin — The auth context of the caller who made the write, or null when the write came from server-side code running as root. See "root" and the three states in a trigger.
  • context.dataBefore — A synthesized partial snapshot, not the full prior subtree. It is built from the leaves of the new value (so it captures prior values at the locations the write actually touched), then any unchanged sibling fields are filled in from the post-write state. Do not use !context.dataBefore to detect a freshly-created resource — for a brand-new set, dataBefore is populated with the new values rather than being null.

Detecting "is this new?": Because context.dataBefore mirrors the leaves of the new value and falls back to the post-write state for unchanged keys, it is rarely null in practice — even for first-time creation. If you need a one-time-init pattern, register the trigger on the "set" action and check for the absence of a marker field on context.dataAfter (a field your trigger itself sets), rather than testing dataBefore.

Deletions are not visible in dataBefore: Because dataBefore is keyed by the leaves of the new value, fields that existed before the write but are absent from the new value will not appear in dataBefore at all. For example, replacing {item:"y", price:5} with set(..., {item:"x"}) yields dataBefore = {item:"y"} — the removed price field is not surfaced.

Important: When modifying data within a trigger that affects the same path you're watching, always implement safeguards to prevent infinite loops, as shown in the example.

Complete Example: Order Processing

Processing new orders
// React to orders being set (created OR fully replaced).
// Because context.dataBefore is unreliable for "is this new?" (see warnings
// above), use a marker field on dataAfter to ensure one-time initialization.
// Bonus: registering on "set" (not "update") prevents this from refiring
// when the trigger's own update() call below runs.
addDbTrigger("set", ["orders", "$orderId"], function(context) {
  if (context.dataAfter && !context.dataAfter.processingStart) {
    const orderId = context.path[1];  // wildcard segment, filled in
    update(context.path, {
      status: "processing",
      processingStart: Date.now()
    });
  }
});

Callable Functions

Define server functions that clients can invoke remotely using addCallable. Clients call them via callableFunction(name, data):

Callable definition
addCallable("getUsersCount", async function (data, admin, sessionId) {
  //get all users
  var res = get(["users"])
  //Count how many users
  count = Object.keys(res.data).length
  //return number
  return count
});

Callback Arguments

Your callable receives (data, admin, sessionId):

  • data - Payload sent by the client (second argument to callableFunction())
  • admin - The standard auth context object — see Auth Context for the full shape
  • sessionId - The caller's WebSocket session ID

Return Value

Callables may return synchronously or as a Promise (use async). The return value is delivered to the client as response.data.

A callable runs as root unless you say otherwise. Data and file operations inside it default to admin = "root" and bypass security rules entirely — which is the point of a callable, but it means you are the only thing standing between the caller and the data. Check admin.uid yourself, and pass admin through to any operation you want rule-checked:

addCallable("updateProfile", (data, admin) => {
  if (!admin.uid) return { status: "Failed", message: "Login required" };

  // Runs as root — rules do NOT apply. The check above is the whole gate.
  set(["users", admin.uid, "profile"], data);

  // Or hand the caller through and let rules decide:
  set(["users", data.targetUid, "profile"], data, admin);
});

Errors. A rejected promise from an async callable answers { status: "Failed", action, requestId, message } with the error's message and logs it server-side. A callable name that was never registered answers { status: "Failed", action: "unknown" }. Returning a value is always status: "Success" — if you need to signal a business-logic failure, return your own object and check it on the client.

sessionId identifies the WebSocket connection, not the user. It is stable for the life of one socket and changes on reconnect — including the automatic reconnect after login(). Use admin.uid for identity and sessionId for per-connection state, the same value connection triggers receive.

Connection Triggers

Run server code when a client connects or disconnects using addConnectionTrigger:

Connection trigger handlers
// When a client connects
addConnectionTrigger("open", function (admin, sessionId) {
    // Record session start time
    update(["sessions", admin.uid, sessionId], {
        start: Date.now()
    });
});

// When a client disconnects
addConnectionTrigger("close", function (admin, sessionId) {
    // Record session end time
    update(["sessions", admin.uid, sessionId], {
        end: Date.now()
    });
});

Action Types

  • "open" - Fires when a client establishes a WebSocket connection
  • "close" - Fires when a client disconnects (browser close, network drop, or explicit close)

Callback Arguments

Your callback receives (admin, sessionId):

  • admin - The standard auth context object — see Auth Context for the full shape
  • sessionId - Unique ID for this WebSocket session

Note: Connection triggers fire for every WebSocket session, including unauthenticated visitors. Check admin.uid if you only care about logged-in users — the example above writes to ["sessions", admin.uid, sessionId], which for an anonymous visitor means admin.uid is undefined and the path is invalid.

A throwing handler is logged, not fatal. These run directly inside the socket's open/close callbacks, where an uncaught throw would end the process — not just the connection. The engine catches and logs instead. There is nothing to refuse at either point anyway: the socket is already open, or already closing.

admin is captured at upgrade time and never refreshes. The auth context is read once from the request's cookies when the WebSocket is established, so a user who signs in after connecting still shows as anonymous to that socket's triggers — and to its security rules. The client SDK reconnects automatically after login()/logout() for exactly this reason, which fires close then open with the new identity.

Custom POST Endpoints

Define POST routes by attaching handlers to nukebase.app. Two flavors are available:

  • app.postWithBody(path, handler) - POST endpoint with automatic body parsing and authentication.
  • app.post(path, handler) - Lightweight POST handler with no automatic parsing.
Parameter order: All handlers receive (res, req)res first, then req. This matches the underlying uWebSockets convention and is the opposite of Express.

For serving static files (HTML, CSS, JS, images), see Serving Static Files.

postWithBody (POST with Body Parsing)

Use postWithBody to create POST endpoints with automatic body parsing and authentication. It automatically calls checkAuth(req, res) and populates req.admin with the authenticated user's information.

postWithBody — automatic auth via req.admin
// postWithBody automatically parses the body AND runs checkAuth()
nukebase.app.postWithBody('/api/contact', (res, req) => {
  // req.admin is automatically populated by checkAuth()
  if (!req.admin.uid) {
    return res.send(JSON.stringify({ status: "Failed", message: "Not authenticated" }));
  }

  const { name, email, message } = req.body;

  // Save to database with the authenticated user's ID
  set(["contactForms", generateRequestId()], {
    name,
    email,
    message,
    userId: req.admin.uid,
    timestamp: Date.now()
  });

  res.send(JSON.stringify({ status: "Success" }));
});

// req object includes:
// req.admin    - Auth object from checkAuth() (always present)
// req.body     - Parsed request body
// req.host     - Request host header
// req.method   - HTTP method
// req.getHeader(name) - Get any request header
//
// req.admin includes (always present):
// req.admin.cookies   - Parsed cookies object
// req.admin.urlParams - Parsed query string parameters
// req.admin.referer, req.admin.userAgent, req.admin.ip, req.admin.url
//
// req.admin includes (only when authenticated):
// req.admin.uid, req.admin.username, req.admin.token, req.admin.claims

Supported content types for postWithBody:

  • application/json - Parsed as JSON object
  • application/x-www-form-urlencoded - Parsed as key-value pairs
  • multipart/form-data - Parsed with file upload support (file fields become arrays of { filename, type, data: Buffer })
  • text/plain - Available as req.body.text
res.send() helper: res.send(body, status) writes the status (default "200 OK") and ends the response in one call. Only available in postWithBody handlers — raw post uses res.writeStatus() + res.end() directly.

Body size limits

postWithBody caps request bodies at 1 MB by default (change the global default with the HTTP_MAX_BODY_BYTES environment variable). Pass a third argument to raise or lower it for one route:

Per-route body cap
nukebase.app.postWithBody('/api/import', (res, req) => {
  // req.body holds the parsed payload, up to 10 MB
  res.send(JSON.stringify({ status: "Success" }));
}, { maxBytes: 10 * 1024 * 1024 });

Over-size requests get 413 Payload Too Large and the connection is closed. The check runs twice: once against the declared Content-Length so an oversized request is rejected before any bytes are buffered, and again against the bytes that actually arrive so a lying header cannot get past it. Your handler is never invoked.

For file uploads, prefer the file store. multipart/form-data through postWithBody buffers the whole request in memory and is bounded by the cap above. The file store streams to disk with backpressure, allows 64 MB by default, and is governed by fileWrite/fileValidate rules rather than hand-written checks.

Body parsing never crashes the request. A body that fails to parse — malformed JSON, a multipart payload with no boundary, JSON nested deeper than 64 levels, or one containing a prototype-pollution key — answers 400 Bad Request and the handler is not called. A handler that throws (or returns a rejected promise) answers 500 Internal Server Error and logs the error.

Raw post (Lightweight)

Use raw post for lightweight endpoints that don't need body parsing. You must manually call checkAuth(req, res) to get authentication information:

Raw post — manual checkAuth(req, res)
// Raw post — no automatic parsing, no req.admin
nukebase.app.post('/api/status', (res, req) => {
  // Must manually call checkAuth(req, res) to get auth info
  const auth = checkAuth(req, res);

  if (!auth.uid) {
    res.writeStatus("401 Unauthorized");
    return res.end(JSON.stringify({ status: "Failed", message: "Not authenticated" }));
  }

  res.end(JSON.stringify({
    status: "Success",
    user: auth.username,
    role: auth.claims.role
  }));
});

// Raw post only has access to raw uWebSockets methods:
// req.getHeader(name) - Get a request header
// req.getUrl()        - Get the URL path
// req.getQuery()      - Get the raw query string
// req.getMethod()     - Get the HTTP method

Auth Context

Both req.admin (in postWithBody) and the return value of checkAuth(req, res) (in raw post) return the standard auth context object — see Auth Context for the full property list and shape.

When to use which?

  • Use postWithBody when you need to read the request body (JSON, form data, file uploads). Authentication is handled automatically via req.admin.
  • Use raw post when you don't need body parsing (simple status checks, redirects, lightweight responses). Call checkAuth(req, res) manually to get auth info.

Connecting to External Databases

If you need to connect to another NukeBase database from your server (for example, a shared service or microservice architecture), you can use the server/serversdk.js module.

When to use serversdk.js:

  • Connecting to a separate NukeBase instance
  • Building microservices that communicate with each other
  • Aggregating data from multiple database servers
  • Server-to-server real-time synchronization
Using serversdk.js to connect to another database
module.exports = ({ get, set, update, addCallable, startDB, addDomain, ... }) => {

  // Import the server SDK for external connections
  const createServerClient = require('../sys/serversdk.js');

  // Connect to an external NukeBase database
  // Note: External connections ARE async (like client-side)
  createServerClient('wss://other-project.nukebase.com').then(externalDb => {
    console.log('Connected to external database');

    // Use the external database with async operations
    addCallable("getExternalData", async function(data, admin, sessionId) {
      // Local database (sync)
      const localUser = get(["users", admin.uid]);

      // External database (async - requires await)
      const externalData = await externalDb.get(["sharedData", data.itemId]);

      return {
        local: localUser.data,
        external: externalData.data
      };
    });

    // Subscribe to changes on external database
    externalDb.getSub({
      event: "value@",
      path: ["notifications"]
    }, (event) => {
      // When external data changes, update local database
      set(["cache", "externalNotifications"], event.data);
    });

  }).catch(err => {
    console.error('Failed to connect to external database:', err);
  });

  // Set up local domain
  const nukebase = addDomain({
    authPath: ["users"]
  });

  startDB(nukebase);
};

Important differences:

  • Local operations (via destructured get, set, etc.) are synchronous
  • External operations (via serversdk.js) are asynchronous and require await

This is because external connections go over the network via WebSocket, just like client connections.

Storage & Backups

Your database lives in server/data/ as a tree of data.json files. The server keeps the entire tree in memory and writes dirty subtrees back to disk on a 5-second debounce (or sooner under load). You don't normally need to touch these files — but it helps to know how they're laid out.

The split tree

To keep individual JSON files from growing unbounded, NukeBase splits any object that would exceed 128 MB serialized into a subdirectory. The parent file references the child via a $split marker:

On-disk layout
server/data/
├── data.json                  # { "users": "$split", "posts": [...] }
└── users/
    ├── data.json              # { "alice": {...}, "bob": "$split" }
    └── bob/
        └── data.json          # { ...bob's subtree... }

Splits happen automatically when an object grows past the threshold, and the inverse — coalescing back into the parent file — happens automatically on startup if a child has shrunk under the threshold. Migration from a legacy single server/data.json file is also automatic on first run.

Don't hand-edit while the server is running. The in-memory tree is the source of truth; on the next flush, your edits will be overwritten. Stop the server first, edit the relevant data.json, then restart.

Atomic writes & crash safety

Each data.json is written via a tmp + fsync + rename sequence, so a crash mid-write leaves either the previous file or the new file on disk — never a partially-written one. Any leftover .tmp files from a crashed write are swept on startup before the data is loaded. Pending in-memory writes are flushed on SIGTERM/SIGINT for graceful shutdown.

Daily backup

Every 24 hours the server flushes pending writes and copies server/data/ to server/backup/<YYYY-MM-DD>/. Same-day re-runs overwrite the existing snapshot. No configuration is required.

Bad-disk recovery: If a $split subdirectory is missing or corrupt at startup, the server fails fast by default — it won't load partial data and silently re-flush, which would commit data loss. Set DB_ALLOW_MISSING_SPLITS=1 to downgrade this to a logged warning and treat the missing subtree as empty (useful for recovering from a partial restore, but use with care).

"$split" is a reserved value. Because a bare marker on disk can only ever mean a real split, the engine refuses to store the string "$split" as a value anywhere in your data. Without that rule, a parent file saying {"k":"$split"} whose directory was missing would load as the literal string, replace the whole subtree, and the next flush would commit the loss — silently, with exit code 0. Refusing it at the door means a missing shard is unambiguous and can be reported instead.

Where files live

The file store is separate from the JSON tree and does not participate in any of the above. Files live under server/files/ (override with FILES_DIR), laid out as a plain directory tree that mirrors the path arrays you address them with — ["posts","p1","hero.png"] is server/files/posts/p1/hero.png.

Uploads use the same tmp + fsync + rename discipline: bytes land in a $tmp- prefixed file beside the destination and are renamed into place, so a reader sees the old file or the new one and never a partial write. Because a leading $ is a rejected path segment, an in-flight upload has no addressable path. Leftovers from a crash are swept at startup.

The daily backup covers server/data/ only. The file store is not copied — it is typically far larger than the JSON tree, and duplicating it daily on the same disk protects against very little. Back up server/files/ with whatever you already use for bulk storage.

Complete Server Example

Here's a minimal but complete server setup:

Complete server configuration example
module.exports = ({
  // Data
  get, set, update, increment, remove, query, data,
  // Files
  getFile, setFile, removeFile, listFiles, fileStat, fileUrlFor, FILES_DIR,
  // Extension points
  addDbTrigger, addFileTrigger, addCallable, addConnectionTrigger,
  // Server
  addDomain, startDB, checkAuth, withBody, hashToken, generateRequestId
}) => {

// Set up a domain
const nukebase = addDomain({
  authPath: ["users"],  // Path where user authentication data is stored
  host: "127.0.0.1", // optional
  port: 3000 // optional
});

// Enable username/password auth (/login, /createuser, /changepassword).
// Omit it and the app is passwordless — /logout and session validation
// are in core either way.
require("./extensions/auth")({
  app: nukebase.app,
  authPath: nukebase.authPath,
  get, set, update, remove, query, generateRequestId, hashToken
});

// Configure middleware for serving static files
const path = require('path');
nukebase.app.serveStatic("/*", path.join(__dirname, "../public"),
  (res, req) => { return true; }
);

// Add a database trigger for important changes
addDbTrigger("value", ["orders", "$orderId"], function(context) {
  // Only trigger if data has actually changed
  if (JSON.stringify(context.dataAfter) !== JSON.stringify(context.dataBefore)) {
    set(["logs", generateRequestId()], {
      path: context.path,
      timestamp: Date.now(),
      oldValue: context.dataBefore,
      newValue: context.dataAfter,
      change: "Important data changed"
    });
  }
});

// Add a callable for client calculations
addCallable("addNumbers", function(data, admin, sessionId) {
  // Extract numbers from the request
  const { num1, num2 } = data;
  // Perform the calculation on the server
  const sum = num1 + num2;
  // Return the result to the client
  return sum;
});

// Atomic counter — no read-modify-write race
addDbTrigger("set", ["orders", "$orderId"], function(context) {
  increment(["stats", "ordersPlaced"], 1);
});

// Keep a per-user storage ledger the file rules can read.
// reserve books the cost before the bytes; release refunds every path
// that doesn't commit. See File Storage for why both are needed.
addFileTrigger("reserve", ["uploads"], (ctx) => {
  increment(["usage", ctx.path[1], "bytes"], ctx.file.size);
});
addFileTrigger("release", ["uploads"], (ctx) => {
  increment(["usage", ctx.path[1], "bytes"], -ctx.file.size);
});

// Track user connections
addConnectionTrigger("open", function(admin, sessionId) {
  // Record when user connects
  update(["sessions", admin.uid, sessionId], {
    start: Date.now()
  });

  // Update user status
  update(["users", admin.uid], {
    online: true,
    lastSeen: Date.now()
  });
});

// Handle user disconnections
addConnectionTrigger("close", function(admin, sessionId) {
  // Record when user disconnects
  update(["sessions", admin.uid, sessionId], {
    end: Date.now()
  });

  // Update user status
  update(["users", admin.uid], {
    online: false,
    lastSeen: Date.now()
  });
});

startDB(nukebase);
console.log("🚀 NukeBase server running on http://127.0.0.1:3000");
};

Note: This example demonstrates best practices including:

  • Domain setup with authPath, host, and port configuration
  • Static file serving with serveStatic
  • Real-time database triggers
  • Custom WebSocket functions
  • Connection tracking
  • Server initialization with startDB(nukebase)

Client-Side API

NukeBase's client library provides a real-time connection to your database through WebSockets. The client handles connection management, request tracking, and event dispatching automatically.

Looking for get/set/update/increment/remove/query? Those work the same on client and server and are documented once in CRUD Operations and Query Operations. The client returns Promises (use await); otherwise the API is identical.

Everything createClient() returns

MethodDoes
Data — see CRUD Operations
get(path)Read the value at a path
set(path, data, opts?)Create or replace. opts.durable waits for the disk flush
update(path, data, opts?)Deep-merge into the existing value
increment(path, data, opts?)Apply a numeric delta, or a nested object of deltas, atomically. Resolves with the resulting value(s)
remove(path, opts?)Delete the value at a path
query(msg)Search a collection. Takes childPath, orderBy, desc, limit, startAt, cursor, count — see Sorting and Pagination
Subscriptions — see Real-time Subscriptions. Each returns an unsubscribe function
getSub({event, path}, fn)Live value at a path
getSubChanged({event, path}, fn)Same, but deltas after the first payload
querySub({…}, fn)Live query result, optionally windowed with orderBy/limit
querySubChanged({…}, fn)Same, but only the records each write touched
Files — see File Storage
getFile(path, opts?)Download. Resolves with a Blob
setFile(path, body, opts?)Upload a Blob, File, ArrayBuffer, TypedArray or string
listFiles(path, opts?)One directory level as an array of entries
removeFile(path, opts?)Delete; directories need { recursive: true }
fileUrl(path)Synchronous. The URL for <img src> or <a href> — rules still apply when the browser fetches it
Server functions and auth
callableFunction(name, data)Invoke a server callable — see Calling Callables
login(username, password)Sign in, or resume a cookie session with no arguments
logout()Clear cookies and revoke the session token
createUser(username, password)Create an account. Its endpoint ships commented out — accounts are created by the email flows instead, so this 404s unless you re-enable /createuser
changePassword(newPassword)Change the signed-in user's password
magicLink(email)Request a passwordless sign-in link
reconnect()Tear down and re-open the socket. Called automatically after a successful login/logout so the new cookies are picked up

Two different error conventions, on purpose. Data methods, subscriptions and callables resolve with { status: "Failed" } — they never reject on a refusal, so you must check status. The four file methods reject with a NukeBaseFileError carrying the HTTP .status, because a file transfer's failure modes (403, 404, 409, 413, 429) are exactly what you want to branch on. See Response Format.

Auth methods depend on extensions being mounted. login and changePassword need extensions/auth on the server; magicLink needs extensions/magic-link; createUser additionally needs its route uncommented. Only logout is backed by the core engine. A method whose endpoint isn't routed falls through to the static handler and gets a 404, which surfaces as a JSON parse error rather than a clear message. See Authentication.

Connection Setup

The client automatically establishes a secure WebSocket connection:

Basic connection
<script type="module">
  import createClient from './sdkmod.js';

  // ============================================
  // PATTERN 1: Full client object
  // ============================================
  const db = await createClient();

  // Use methods with db. prefix
  await db.set(['users', 'john'], { name: 'John', age: 30 });
  const user = await db.get(['users', 'john']);

  // ============================================
  // PATTERN 2: Destructured methods (recommended)
  // All examples below use this pattern
  // ============================================
  const { set, get, update, increment, remove, query,
          getSub, querySub, getSubChanged, querySubChanged,
          getFile, setFile, listFiles, removeFile, fileUrl,
          callableFunction, reconnect,
          login, logout, createUser, changePassword, magicLink } = await createClient();

  console.log("Connected and ready to use NukeBase");

  // Use methods directly without prefix
  await set(['users', 'alice'], { name: 'Alice', age: 28 });
  const userData = await get(['users', 'alice']);

  // ============================================
  // PATTERN 3: Attach to window (global access)
  // Useful for multi-file apps or console debugging
  // ============================================
  const client = await createClient();

  // Attach full client object
  window.db = client;

  // Optional: expose individual helpers directly
  Object.assign(window, client);

  // Now use from anywhere: window.db.get(...) or just get(...)
</script>

Important: The example above shows all three patterns for demonstration. In practice, choose ONE pattern for your application. Each pattern creates its own WebSocket connection, so using multiple would create multiple connections.

Key Features:

  • Promise-based initialization: Wait for connection before using the client
  • Automatic Reconnection: Reconnects every 5 seconds after disconnection
  • Subscription Restoration: Automatically restores all active subscriptions after reconnect
  • Tab Focus Recovery: Reconnects when browser tab regains focus
  • Encapsulated State: Multiple client instances can coexist independently

Connecting elsewhere. createClient() with no argument derives the WebSocket URL from the current page (wss:// on HTTPS, ws:// otherwise), preserving the path and query string. Pass an explicit URL to connect to a different project: await createClient('wss://other-project.nukebase.com').

Requests time out after 30 seconds. If the server never answers, the promise rejects with Request timeout: <action> <requestId>. Subscriptions are exempt — they have no single reply to wait for.

reconnect() and cookies. The admin context of a WebSocket is fixed at the moment the socket is upgraded, so a session established after connecting is invisible to the existing socket. login(), logout() and createUser() therefore call reconnect() for you on success. If you establish a session some other way — a magic-link redirect, a custom endpoint — call reconnect() yourself, or the socket keeps operating as the previous identity.

Connection State Indicators

The SDK provides console messages to track connection state:

  • ✅ Connected to [url] - WebSocket connection established
  • ❌ Disconnected from [url] - Connection lost
  • 🔁 Reconnecting... - Attempting to reconnect
  • 🔄 Restoring subscriptions... - Resubscribing after reconnect

Real-time Subscriptions

Looking for get/set/update/remove/query? Those work the same on client and server — see CRUD Operations and Query Operations. The subscriptions below build on those primitives to deliver live updates.

Important: All subscription functions (getSub, getSubChanged, querySub, and querySubChanged) immediately send the current data when the subscription is created. This ensures your UI can display the current state right away, before any changes occur.

Basic Subscriptions

Get real-time updates when data changes. All subscription functions immediately send the current data when the subscription is created, then continue to send updates whenever the data changes:

Basic subscription examples
// Subscribe to changes on a path
const unsubscribe = getSub({
    event: "value@",
    path: ["users", "john"]
}, event => {
    // This fires immediately with current data, then on every change
    console.log("User data:", event.data);
});

// When finished listening
unsubscribe();

Query Subscriptions

Subscribe to data matching specific conditions:

Query subscription examples
// Subscribe to active users
const unsubscribe = querySub({
    event: "value@",
    path: ["users"],
    query: "child.status == 'online'"
}, event => {
    // Receives all currently online users immediately, then updates
    const onlineUsers = event.data;
    updateOnlineUsersList(onlineUsers);
});

Windowed Subscriptions

querySub and querySubChanged take the same window parameters as query()orderBy, desc, limit, startAt and cursor. The server re-runs the window on every write beneath path and pushes the current contents, so a live leaderboard or a "latest 50" feed is one subscription rather than a poll loop.

A live top-10 leaderboard
const unsubscribe = querySub({
    path: ["players"],
    query: "child.active == true",
    orderBy: ["score"],
    desc: true,
    limit: 10
}, event => {
    // Fires immediately with the current top 10, then on every write
    // beneath ["players"] that changes it.
    // Read them IN ORDER through event.order — object key order is not
    // something to depend on.
    renderLeaderboard(event.order.map(k => event.data[k]));
});

// A live feed of the 50 newest items, as a delta stream
const unsub2 = querySubChanged({
    path: ["events"],
    query: "true",
    orderBy: ["ts"],
    desc: true,
    limit: 50
}, event => {
    // The FIRST payload is the whole window — a starting state to merge into.
    // Every payload after that contains only the records this write touched
    // AND that fall inside the window.
    Object.assign(feed, event.data);
});

Subscribers asking for the same window share one query run. The server buckets subscriptions by their exact window, and a bucket costs one query and one serialization per write no matter how many sessions are in it. A thousand clients watching the same leaderboard cost the same as one. That sharing is the whole performance model — which is why every reply echoes its window back (childPath, query, orderBy, desc, limit, startAt, cursor), so the SDK routes a notification to exactly the handler that asked for it.

A windowed subscription over a large collection needs an index. With an index on the orderBy field the server binary-searches to the window and walks limit entries — cheap and bounded, per write. Without one it is a full scan plus a sort of the entire collection, on every write, and the client has no way to see that it asked for that.

So above 1,000 records, the engine refuses a windowed subscription that would not get an index-order plan, and the failure message names the field to declare:

A windowed subscription over 48,210 records needs an index on ["score"].
Without one every write beneath this path would re-scan and re-sort the whole
collection for this subscription. Add "index": [["score"]] to this path in
rules.js, or drop orderBy/limit and use querySubChanged.

Smaller collections are waved through — scanning a few hundred records costs less than an unwindowed querySub, which has always been allowed. See Indexes.

count is query-only. It carries no data, and it would fire on every write beneath the path even when the number hadn't moved. Subscriptions reject it.

Subscription Limits

A querySub matching more than 1,000 records is refused — and dropped if it grows past that later. Every write beneath the path would re-send the entire result set, so a broad querySub over a big collection is a fan-out amplifier. The server answers with status: "Failed" and an explanation:

querySub matches 12,480 records, over the 1000 limit. Every write beneath this
path would re-send all of them. Narrow the query, add a limit, or use
querySubChanged, which sends only the records a write touched.

If an already-running subscription crosses the threshold, subscribers get the same notice and the subscription is removed rather than left re-sending. Handle a status: "Failed" payload in your handler.

Other limits:

  • 256 subscriptions per WebSocket session. Exceeding it throws Subscription limit exceeded.
  • 64 distinct query-subscription windows per (path, event, action). Joining an existing bucket is free; opening a new distinct window adds a query run to every future write on that path, so the count of distinct windows is bounded directly.
  • 16 MB per WebSocket message, and 500,000 messages per second per connection before the socket is closed with a rateLimit notice.

Query Subscriptions with childPath

Just like regular queries, subscriptions can use childPath to subscribe only to specific nested portions of your data:

childPath subscription examples
// Subscribe to public profiles only (excludes private data)
const unsubscribe = querySub({
    event: "value@",
    path: ["users"],
    childPath: ["public"],
    query: "child.verified == true"
}, event => {
    // Receives the public subtree wrapped under its childPath key,
    // mirroring the DB shape. "private" is never traversed or sent.
    // Response: { matt123: { public: { verified: true, name: "Matt", ... } } }
    displayVerifiedUsers(event.data);
});

// Subscribe to inventory changes for low stock items
const unsubscribe2 = querySub({
    event: "value@",
    path: ["products"],
    childPath: ["inventory"],
    query: "child.stock < 10"
}, event => {
    // Only the inventory subtree is fetched and pushed; product details
    // outside "inventory" never enter the payload.
    // Response: { productA: { inventory: { stock: 5, ... } } }
    showLowStockAlert(event.data);
});

// Use with querySubChanged for efficient updates
const unsubscribe3 = querySubChanged({
    event: "value@",
    path: ["users"],
    childPath: ["profile"],
    query: "child.country == 'USA'"
}, event => {
    // Only fires when USA profiles change
    // Only returns the profile portion that changed
    console.log("Updated USA profiles:", event.data);
});

Benefits of childPath with subscriptions:

  • Reduced bandwidth: Only transmit the data portions you need
  • Narrower payload: Fields outside the childPath are never walked or sent — but this is about shape and bandwidth, not access control. The subscription is authorized once at the collection level; see Query authorization.
  • Performance: Smaller payloads mean faster real-time updates
  • Clean data: Clients receive exactly the structure they expect

Changed-Only Subscriptions

Despite the name, these subscriptions ALSO receive the initial data immediately when created, then only fire again when data actually changes:

Important for getSubChanged and querySubChanged: What you receive depends on what path you're watching:

  • If watching "users" and John updates his name, you get John's COMPLETE object (all fields)
  • If watching "users.john" and a field changes, you get ONLY the changed field (e.g., just {name: "New Name"})
  • If watching "users.john.name" and it changes, you get just the new name value
  • The deeper your watch path, the more specific the change data
Changed-only subscription examples
// getSubChanged - watching a collection
const unsubscribe = getSubChanged({
    event: "value@",
    path: ["users"]
}, event => {
    // Initial: all users
    // If John updates his email:
    // event.data = { john: { name: "John", email: "new@email.com", age: 25 } }
    // You get John's COMPLETE object
    updateChangedUsers(event.data);
});

// getSubChanged - watching a specific user
const unsubscribe2 = getSubChanged({
    event: "value@",
    path: ["users", "john"]
}, event => {
    // Initial: John's complete data
    // If John's email changes:
    // event.data = { email: "new@email.com" }
    // You get ONLY the changed field
    Object.assign(currentUser, event.data);  // Merge changes
});

// getSubChanged - watching a specific field
const unsubscribe3 = getSubChanged({
    event: "value@",
    path: ["users", "john", "status"]
}, event => {
    // Initial: "online"
    // If status changes:
    // event.data = "offline"
    // You get just the new value
    updateStatusIndicator(event.data);
});

// With query filtering - returns only the changed items
const unsubscribe4 = querySubChanged({
    event: "value@",
    path: ["users"],
    query: "child.age > 21"
}, event => {
    // If user John (age 25) updates only his name:
    // event.data = { john: { name: "John Doe", age: 25, email: "john@example.com" } }
    // You get John's COMPLETE object, not just the changed name field
    console.log("Users that changed:", event.data);
});

// Example: monitoring low stock products
const unsubscribe5 = querySubChanged({
    event: "value@",
    path: ["products"],
    query: "child.stock < 5"
}, event => {
    // If product ABC updates its price, you get:
    // { ABC: { name: "Widget", stock: 3, price: 29.99 } }
    // The complete product object for ONLY the product that changed
    Object.keys(event.data).forEach(productId => {
        updateSingleProduct(productId, event.data[productId]);
    });
});

Operation-Specific Subscriptions

Listen for specific types of operations by prefixing your path with an operation type:

Available operation types:

  • value@ - Fires on any change (set, update, or remove)
  • set@ - Fires only when data is created or completely replaced
  • update@ - Fires only when existing data is partially updated
  • remove@ - Fires only when data is deleted

Compatibility: Operation prefixes work with all subscription functions: getSub, getSubChanged, querySub, and querySubChanged.

Operation-specific subscription examples
// Listen only for updates to user data
const unsubscribe = getSub({
    event: "update@",
    path: ["users", "john"]
}, event => {
    console.log("User was updated:", event.data);
});

// Listen for new data being set
const unsubscribe2 = getSub({
    event: "set@",
    path: ["orders"]
}, event => {
    console.log("New order created:", event.data);
});

// Listen for data removal
const unsubscribe3 = getSub({
    event: "remove@",
    path: ["users"]
}, event => {
    console.log("A user was deleted:", event.path);
});

// Operation-specific with getSubChanged
const unsubscribe4 = getSubChanged({
    event: "set@",
    path: ["products"]
}, event => {
    // Only fires when NEW products are created (not updates)
    console.log("New products added:", event.data);
});

// Operation-specific with queries
const unsubscribe5 = querySub({
    event: "update@",
    path: ["users"],
    query: "child.status == 'premium'"
}, event => {
    // Only fires when premium users are UPDATED (not created or deleted)
    console.log("Premium users updated:", event.data);
});

// Combining with querySubChanged
const unsubscribe6 = querySubChanged({
    event: "remove@",
    path: ["tasks"],
    query: "child.completed == true"
}, event => {
    // Only fires when completed tasks are DELETED
    console.log("Completed tasks removed:", event.data);
});

// Default behavior without prefix (same as value@)
const unsubscribe7 = getSub({
    path: ["users", "john"]
}, event => {
    // Fires on ANY change: set, update, or remove
    // event parameter defaults to "value@" if not specified
    console.log("Something changed:", event.data);
});

Subscription Bubble-Up Behavior

Understanding how subscription changes propagate is crucial for designing efficient real-time applications. NukeBase subscriptions follow a "bubble-up" pattern:

Key Concept: Changes Bubble UP, Not DOWN

  • Bubble UP ✅: Changes at child paths trigger parent subscriptions
  • No Trickle DOWN ❌: Changes at parent paths do NOT trigger child subscriptions
Bubble-up behavior example
// Set up subscriptions at different levels
getSub({
    event: "value@",
    path: ["calls"]
}, (event) => {
    console.log("1. Calls level:", event.data);
});

getSub({
    event: "value@",
    path: ["calls", "123"]
}, (event) => {
    console.log("2. Specific call:", event.data);
});

getSub({
    event: "value@",
    path: ["calls", "123", "answer"]
}, (event) => {
    console.log("3. Answer level:", event.data);
});

// Scenario 1: Change at deep level (bubbles UP)
await set(["calls", "123", "answer"], { type: "answer", sdp: "..." });
// ✅ Fires: 1. Calls level (bubbled up)
// ✅ Fires: 2. Specific call (bubbled up)
// ✅ Fires: 3. Answer level (direct match)

// Scenario 2: Change at middle level (bubbles UP, not DOWN)
await update(["calls", "123"], { status: "active" });
// ✅ Fires: 1. Calls level (bubbled up)
// ✅ Fires: 2. Specific call (direct match)
// ❌ NOT fired: 3. Answer level (no trickle down)

// Scenario 3: Change at top level (no trickle DOWN)
await set(["calls"], { "456": { offer: {...} } });
// ✅ Fires: 1. Calls level (direct match)
// ❌ NOT fired: 2. Specific call (no trickle down)
// ❌ NOT fired: 3. Answer level (no trickle down)

Practical Implications:

  • Parent subscriptions are "catch-all": Watching users will fire for ANY change in ANY user or their properties
  • Child subscriptions are specific: Watching users.john.email only fires when that exact path or its children change
  • Performance consideration: Higher-level subscriptions fire more frequently due to bubble-up
  • Data replacement warning: If you set() at a parent level, child subscriptions may stop working as their paths no longer exist

Calling Callables

Invoke server-side callables from the client using callableFunction(name, data). Callables are defined on the server with addCallable:

Calling a server callable
// Call the server callable
const response = await callableFunction("addNumbers", { num1: 5, num2: 7 });
console.log(`The sum is: ${response.data}`);  // Output: The sum is: 12

The first argument is the callable's name (the string you passed to addCallable on the server). The second is any payload — it arrives as the data argument inside the callable. The server's return value is delivered as response.data.

Ultra-Low Latency Performance

Callables run over the existing WebSocket connection, providing the fastest possible way to communicate with your server. No HTTP handshake, no new connection — perfect for real-time games, live collaboration, and any application where milliseconds matter.

Callables are especially powerful when you need to:

  • Aggregate data from multiple database paths
  • Perform complex calculations server-side
  • Validate game moves or business logic
  • Return processed results without exposing raw data

Example Use Cases: Game state calculations, leaderboard generation, real-time analytics, complex permission checks, or any scenario where you need to fetch multiple database values, process them, and return a calculated result.

Authentication

NukeBase provides a built-in cookie-based authentication system. When you configure authPath: ["users"] in your domain setup, authentication endpoints are automatically available and cookies are handled seamlessly.

How it works:

  1. Configure authPath: ["users"] in your domain setup
  2. Use the built-in authentication endpoints from your client
  3. Server automatically sets HTTP cookies (uid, token)
  4. WebSocket connections automatically use these cookies
  5. User information populates the admin object for security rules

What's in core, and what's an extension

Configuring authPath turns on the parts of authentication that every sign-in method needs, regardless of how the user proved who they are:

  • Session validationcheckAuth reads the uid/token cookies on every request and WebSocket upgrade, and populates admin.
  • POST /logout — clears the cookies and revokes the current session token.
  • Hourly token cleanup — expired entries are swept from auth.tokens automatically.

The endpoints that create a session are opt-in extensions you mount from app.js. Pick the one that matches how you want people to sign in — or mount more than one; they all mint the same session cookies and share the same user records.

ExtensionAddsNeeds
extensions/auth POST /login, POST /changepassword Nothing — username + password, hashed with argon2id
extensions/easy-login POST /auth/email, GET /auth/callback, POST /auth/me Nothing — passwordless email links delivered by NukeBase's hosted mail service
extensions/magic-link POST /magic-link, POST /magic-signup, GET /magiclink Your own SendGrid key and sender address
Mounting the password extension in app.js
const nukebase = addDomain({ authPath: ["users"] });

// Enables /login and /changepassword.
// (/createuser ships commented out — accounts are created by the email flows.)
// Omit this and your app is passwordless — /logout and session
// validation keep working either way.
require("./extensions/auth")({
  app: nukebase.app,
  authPath: nukebase.authPath,
  get, set, update, remove, query, generateRequestId, hashToken,
  sessionTokenTtlMs: 24 * 60 * 60 * 1000   // optional, default 24h
});

Not mounted means not routed. If extensions/auth isn't mounted, a POST /login falls through to the static handler and returns 404 — the client SDK's login() will fail on JSON parsing rather than telling you the endpoint is missing. Same for createUser(), changePassword() and magicLink().

Authentication Endpoints

The endpoints below are provided by the extensions in the table above.

Available Endpoints:

  • POST /login - Login with username/password, or resume session via cookies (can also upgrade a demo account that has not yet set credentials by providing username/password). Requires the auth extension.
  • POST /createusercurrently disabled. The route is commented out in extensions/auth.js: account creation is email-only. Use POST /auth/email (easy-login) or POST /magic-signup (magic-link) instead, both of which create the account on first sign-in. The SDK's createUser() will get a 404 until you uncomment it.
  • POST /magic-signup - Create a passwordless account (username/email only). The server creates the account and emails a magic-link sign-in. The user is NOT logged in by this call. Requires the magic-link extension.
  • POST /logout - Clear authentication cookies and revoke the current session token. Built into the engine — always available once authPath is set, no extension required.
  • POST /changepassword - Change user password (requires an active password-backed session). Requires the auth extension.
  • POST /auth/email - Email a sign-in link using NukeBase's hosted mail service — no email provider of your own. Requires the easy-login extension.
  • GET /auth/callback?code=... - Landing URL for an easy-login link. Verifies the code, finds or creates the account, sets session cookies, and 302-redirects.
  • POST /auth/me - Returns { email } for the current session, or { email: null }. Requires the easy-login extension.
  • POST /magic-link - Email a one-time sign-in link to an existing account (15-minute expiry). See Magic Link Authentication.
  • GET /magiclink?token=... - Public landing URL the magic-link email points to. Validates the token, sets session cookies, and 302-redirects to redirectPath (configured when mounting the extension).

Cookies set on success: all auth endpoints that establish a session set two cookies — uid and token — with attributes HttpOnly; Secure; SameSite=Strict; Max-Age=86400; Path=/. That gives you a 24-hour session backed by a server-side hashed token. /logout sets matching Max-Age=0 cookies to clear them.

Because Secure is required, the cookies will not be set over plain http://. Use https:// in production and http://localhost in development (browsers exempt localhost from the Secure requirement).

Built-in security — you don't need to add these yourself:

  • Rate limiting: 5 attempts per 60-second window per client IP on /login, /magic-link and /auth/email. A successful login clears the limiter for that IP.
  • Argon2 password hashing: passwords are stored as argon2id hashes. Plaintext passwords from legacy data are auto-migrated on first successful login.
  • SHA-256 hashed session tokens: the cookie holds the raw token, but only its SHA-256 hash is stored under auth.tokens. A leak of the database does not leak usable session tokens.
  • Hourly token cleanup: a background job sweeps expired entries from auth.tokens and from the rate-limit map. No manual housekeeping is required.
  • Username enumeration resistance: /magic-link returns the same response whether or not the address corresponds to an existing account.

Username and password format constraints:

  • Username (/login): matches /^[a-z0-9_.]{1,32}$/ — lowercase letters, digits, underscore and dot, up to 32 characters. No @, so a password-login username is a handle, not an email address.
  • Password: 8–128 characters, enforced on /changepassword.
  • Email (the passwordless flows): a normal address pattern — +, uppercase and long domains are all accepted. Addresses are lowercased before lookup, and stored under auth.email rather than auth.username.

The two identity fields are separate. Password login looks a user up by auth.username; both email flows look them up by auth.email. A user created by an email flow has no username and cannot sign in through /login, and vice versa — unless you populate both fields yourself.

Behind a reverse proxy? Nothing to configure. The engine decides whether to believe x-real-ip / x-forwarded-for from where the connection actually came from: a Unix socket or a private/loopback peer means a proxy you put there and the header is honored; a public peer address means the open internet and the header is ignored in favour of the peer. A client on a public port therefore cannot rotate headers to reset its own login budget. See Environment Variables for the TRUST_PROXY override, which exists only for unusual topologies.

Login

Use the /login endpoint to login with a username and password. If valid auth cookies are already on the request, the session is resumed automatically — no DB lookup against the password. The endpoint can also upgrade a demo account: if the cookie-session belongs to an account that has no username and no password yet (i.e. a true demo account that has never been upgraded), passing (username, password) attaches credentials to that same UID. Once an account has a username or password, this upgrade path is no longer available — subsequent calls just resume the existing session.

Login
// Import and destructure the methods you need
import createClient from './sdkmod.js';
const { login } = await createClient();

// Login with username and password
const result = await login("username", "password");
if (result && result.status === "Success") {
    console.log('Authenticated as:', result.username);
}

// Resume session (if cookies are already set)
const result2 = await login();
if (result2 && result2.status === "Success") {
    console.log('Session resumed:', result2.uid);
}

// Upgrade a demo account to a full account
// Preconditions: valid demo cookies AND the account currently has
// no username and no password set. After the first upgrade, calling
// login(...) again just resumes the existing session — it will not
// overwrite the username/password.
const result3 = await login("newUsername", "newPassword");
if (result3 && result3.status === "Success") {
    console.log('Demo account upgraded:', result3.username);
}

Create Account

/createuser is commented out in extensions/auth.js as shipped, so everything in this section is inert until you uncomment it. Accounts are created by the email flows instead — /auth/email or /magic-signup — both of which create the user on first sign-in with no separate signup step.

If you re-enable it, /createuser supports:

  • No arguments — creates a demo (anonymous) account and immediately logs the user in.
  • Username + password — creates a full account and immediately logs the user in.

Both modes fail with "Already signed in, logout first" if the caller already has a valid session cookie. For passwordless (email-only) signup, use POST /magic-signup instead — see Magic Link Authentication.

Create account
import createClient from './sdkmod.js';
const { createUser } = await createClient();

// Create a demo/anonymous account (no credentials) — logs in immediately
const result = await createUser();
if (result && result.status === "Success") {
    console.log('Demo account created:', result.uid);
}

// Create a full account with username and password — logs in immediately
const result2 = await createUser("myUsername", "myPassword");
if (result2 && result2.status === "Success") {
    console.log('Account created:', result2.username);
}
// Note: Fails if already signed in - logout first
// For passwordless signup, use POST /magic-signup instead

Logout

Clear authentication cookies to log out the user:

Logout function
import createClient from './sdkmod.js';
const { logout } = await createClient();

const result = await logout();
if (result && result.status === "Success") {
    console.log('Logged out successfully');
}

Change Password

Allow authenticated users to change their password:

Change password function
import createClient from './sdkmod.js';
const { changePassword } = await createClient();

const result = await changePassword("newPassword123");
if (result && result.status === "Success") {
    console.log('Password changed successfully');
} else {
    console.log('Failed to change password');
}

Easy Login — passwordless email, no mail provider

The easy-login extension gives you passwordless email sign-in without setting up SendGrid, Mailgun or any provider of your own. NukeBase delivers the sign-in emails on your behalf (billed to your project) and tells your server which address clicked the link. Everything else stays yours: the accounts live under your authPath, and the sessions are your app's sessions.

Mounting easy-login
const nukebase = addDomain({ authPath: ["users"] });

require("./extensions/easy-login")({
  app: nukebase.app,
  authPath: nukebase.authPath,
  get, set, query, generateRequestId, hashToken,
  redirectPath: "/dashboard",          // optional, default "/"
  brandName: "Acme",                   // optional, shown in the email
  sessionTokenTtlMs: 24 * 60 * 60 * 1000  // optional, default 24h
});
Signing in from the browser
// 1. Ask for a link
const r = await fetch("/auth/email", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "matt@example.com" })
});
const { status, message } = await r.json();
// Always the same generic response, whether or not the account exists

// 2. The user clicks the link in their inbox. It lands on
//    /auth/callback?code=..., which creates the account if it's new,
//    sets the session cookies, and redirects to redirectPath.

// 3. Anywhere after that:
const me = await (await fetch("/auth/me", { method: "POST" })).json();
// → { email: "matt@example.com" }  or  { email: null }

// 4. Sign out with the core endpoint
await fetch("/logout", { method: "POST" });

Credentials come from sys/config.json (API, uid, project, mailKey), written when the project was created. The mailKey is a scoped NukeBase token that can only send sign-in emails for this project — it is not a mail-provider key, and it can be rotated from the dashboard. If any of the four is missing, the extension still mounts but /auth/email returns { status: "Failed", message: "Login not configured" } and logs a warning at startup.

Custom domains just work. The redirect target is built from the Host the request actually arrived on, so a user signing in from your custom domain lands back on it rather than the *.nukebase.com subdomain. NukeBase re-validates that host against the project's known domains, so a spoofed Host header cannot be used as a redirect target.

New addresses create accounts. /auth/callback finds the user by email or creates one — there is no separate signup step. Rate limited to 5 emails per 60-second window per IP, and the response to /auth/email is identical whether or not the address exists, so it cannot be used to enumerate users.

Use this instead of easy-login when you want to send the emails yourself — your own SendGrid account, your own sender address, your own template.

NukeBase has built-in passwordless sign-in via emailed one-time links. Two endpoints power the flow, and account creation can also bootstrap into it.

Setup requirement: Magic-link authentication is an optional extension that requires SendGrid. Mount it in your app.js after addDomain():

const nukebase = addDomain({ authPath: ["users"] });

// Mount magic-link extension
require("./extensions/magic-link")({
  app: nukebase.app,
  authPath: nukebase.authPath,
  query, set, generateRequestId, hashToken,
  sendGridKey: "SG.your-sendgrid-api-key",
  fromEmail: "noreply@yourdomain.com",
  domain: "https://your-app.nukebase.com",
  redirectPath: "/dashboard",       // optional, default "/"
  errorRedirect: "/",               // optional, default "/"
  ttlMs: 15 * 60 * 1000,           // optional, token expiry (default 15 min)
  sessionTokenTtlMs: 24*60*60*1000, // optional, session length (default 24h)
  cookieMaxAgeSeconds: 86400        // optional, cookie Max-Age (default 86400)
});

Without this extension mounted, the /magic-link, /magic-signup, and /magiclink endpoints will not exist. The extension requires sendGridKey, fromEmail, and domain — it throws on startup if any are missing.

POST /magic-link — request a sign-in link for an existing account

Request a magic link
const r = await fetch("/magic-link", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "matt@example.com" })
});
const { status, message } = await r.json();
// status === "Success" whether or not the address exists (anti-enumeration)

Body: { email: string }. The address must match the standard username regex (lowercased internally). The response is the same regardless of whether the account exists, so a bad actor cannot use this endpoint to enumerate users. The IP-based rate limiter applies (5 / 60s).

GET /magiclink?token=... — consume a sign-in link

This is the URL that the email points at; users don't call it from code. On a valid, unexpired token the server:

  1. Generates a new 32-byte session token, stores its SHA-256 hash under users.<uid>.auth.tokens with a 24-hour expiry,
  2. Sets uid and token cookies (HttpOnly; Secure; SameSite=Strict),
  3. Issues a 302 Found redirect to ${domain}${redirectPath} (both configured when mounting the extension).

Tokens are single-use (deleted on consumption) and expire 15 minutes after issue. An invalid or expired token redirects to ?error=invalid_or_expired; a missing token redirects to ?error=missing_token.

Passwordless account creation via /magic-signup

Use the /magic-signup endpoint to create an account that exists only for magic-link sign-in. The server creates the account and immediately sends a sign-in link — the response does not log the user in; they have to click the link in their email. This endpoint is provided by the magic-link extension.

Passwordless signup
// Email-only signup via /magic-signup — user must click the link in their inbox
const r = await fetch("/magic-signup", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username: "matt@example.com" })
});
const result = await r.json();
// result.status === "Success", result.message tells the user to check email
// (no uid / token returned here — the session is established by /magiclink)

Using Authentication in Security Rules

Once authenticated, the admin object is available in your security rules:

Security rules with authentication
// In your rules.js
module.exports = {
  "users": {
    "$userId": {
      // Only the user themselves can edit
      "write": "admin.uid == $userId",

      // Don't grant read at $userId — it would cascade and expose "private".
      // Split the data into public/private subnodes and grant read on each.
      "public":  { "read": "true" },                    // Anyone can read public profile
      "private": { "read": "admin.uid == $userId" }     // Only the user
    }
  },

  "adminPanel": {
    // Only users with admin role can access
    "read":  "admin.claims.role == 'admin'",
    "write": "admin.claims.role == 'admin'"
  }
};

Security Notes:

  • Use HTTPS in production — the auth cookies are Secure-flagged and will not be set over plain HTTP (browsers exempt localhost for development).
  • Rate limiting on /login, /magic-link and /auth/email is built in (5 attempts per 60-second window per client IP). The client IP is derived from the connection's peer address, so it is already correct behind a reverse proxy and cannot be spoofed by a directly-exposed client — no configuration needed.
  • Expired session tokens are swept automatically every hour — no cleanup script needed.
  • Passwords are hashed with argon2id; session tokens are SHA-256 hashed before storage. Legacy plaintext passwords auto-upgrade on first successful login.
  • generateRequestId(bytes) uses crypto.randomBytes and returns a hex string. Defaults to 8 bytes (16 hex chars); session tokens use 32 bytes (64 hex chars).

Custom Claims

Custom claims let you attach arbitrary data (roles, permissions, plan tiers, etc.) to a user's auth record. Claims are available in security rules and callable functions via admin.claims.

Managing claims from app.js
// Set all claims at once
set(["users", uid, "auth", "claims"], { role: "admin", plan: "pro" });

// Update or add a single claim
update(["users", uid, "auth", "claims"], { role: "editor" });

// Remove a single claim
remove(["users", uid, "auth", "claims", "role"]);
Using claims in security rules
module.exports = {
  "adminPanel": {
    "read": "admin.claims.role == 'admin'",
    "write": "admin.claims.role == 'admin'"
  },
  "premiumContent": {
    "read": "admin.claims.plan == 'pro'"
  }
};

Important: Claims are read when a WebSocket connection is established. If you change a user's claims while they are connected, the changes won't take effect until their next connection (page reload, reconnect, or new login). Users without any claims will have admin.claims default to an empty object {}.

Database Structure for Authentication

The authentication system expects user data to be structured like this:

User data structure

  "users": {
    "ML96SDE5": { // Unique user UID (hex, generated by generateRequestId)
      "auth": {
        "username": "matt123",                    // Lowercased username/email (optional for demo accounts)
        "password": "$argon2id$v=19$m=65536,...", // argon2id hash — never plaintext
        "tokens": {
          // Keys are SHA-256 hashes of the raw session token (the cookie value).
          // Values are expiration timestamps in ms since epoch.
          "8f3a...e21c": 1748357368415,
          "b12d...07ff": 1748357670935
        },
        "claims": { // Optional custom claims (free-form object)
          "role": "admin",
          "plan": "pro"
        }
      }
    }
  }

What's actually stored vs. what the cookie holds:

  • Password: stored as an argon2id hash. A legacy plaintext value will be auto-upgraded to a hash on the next successful login.
  • Session tokens: the cookie holds the raw 32-byte hex token; the database stores only its SHA-256 hash as the key under auth.tokens. You cannot reconstruct a valid cookie from the database alone.
  • Don't try to read tokens out of auth.tokens at runtime — they're hashes, not the values you'd put back into a cookie.

Token cleanup is automatic. A background sweep runs hourly and removes any auth.tokens entries whose expiry has passed, plus stale entries from the in-memory rate-limit map. You don't need to schedule your own cleanup.

Response Format

Every request over the WebSocket gets exactly one reply, correlated by requestId. The base shape is the same for all of them; queries and subscriptions add fields on top of it.

Standard response format
{
  // The operation performed
  action: "get", 
  
  // Data from the operation
  data: {
    "user123": { name: "John", age: 32 },
    "user456": { name: "Jane", age: 28 }
  },
  
  // For tracking the request
  requestId: "RH8HZX9P",
  
  // Success or Failed
  status: "Success"
}

When an error occurs, the response includes:

Error response format
{
  status: "Failed",
  action: "set",
  requestId: "RH8HZX9P",
  message: "Error description here"
}

A rule denial carries no message. When a read/write/validate rule refuses an operation the reply is { status: "Failed", action, requestId } and nothing else — deliberately, so the response cannot be used to probe which rule refused or what data it looked at. A message is present only when the engine itself threw (a bad path, an invalid cursor, a limit exceeded, a non-numeric increment target).

Status values

StatusMeaning
"Success"The operation completed.
"Failed"Refused by a rule, or the engine threw. Check for a message.
"Path not found"From get — nothing exists at that path. data is null. Note the exact spelling differs by operation: remove answers "path not found", lowercase.
"Incomplete"From query — a partial result, stopped against a hard scan limit. truncated is true. Treat the data as valid but not exhaustive.
"Error"Server-side calls only. A synchronous call from app.js that threw returns { status: "Error", data: <message>, requestId } rather than throwing at your call site.

Check status, not just data. Client data methods (get, set, update, increment, remove, query, callableFunction) resolve on failure — they do not reject. An await set(...) that a rule refused looks exactly like a success unless you read status. The file methods are the deliberate exception: they reject with a NukeBaseFileError carrying an HTTP .status.

Query responses

A query that uses any window parameter (orderBy, limit, startAt, cursor, count) carries execution metadata alongside data. A plain query({path, query}) keeps the two-field shape above.

Windowed query response
{
  status: "Success",
  action: "query",
  requestId: "RH8HZX9P",

  data:  { p14: {...}, p3: {...}, p91: {...} },
  order: ["p14", "p3", "p91"],   // the authoritative ordering — read through this

  plan:      "index-order",      // scan | index-filter | index-order | index-union | index-count
  examined:  11,                 // records the engine had to look at
  truncated: false,              // true ⇒ status is "Incomplete"
  nextCursor: "W1s0MiwicDkxIl0"  // absent when there are no more results
}

A count: true query returns data: null and puts the number in count. See Query response metadata.

Subscription payloads

Subscription messages are not request replies: they carry no requestId and are routed to your handler by action + event + path. That includes the first payload, the one delivered immediately on subscribe.

getSub vs querySub payloads
// getSub / getSubChanged
{
  status: "Success",
  action: "getSub",
  event:  "value@",
  path:   ["users", "john"],
  data:   { name: "John", age: 32 }
}

// querySub / querySubChanged — the window is echoed back so the SDK can
// route the notification to exactly the handler that asked for it.
{
  status: "Success",
  action: "querySub",
  event:  "value@",
  path:   ["players"],
  data:   { p14: {...}, p3: {...} },

  childPath: [],                 // echoed window ↓
  query:     "child.active == true",
  orderBy:   ["score"],          // null means "ordered by record key"
  desc:      true,
  limit:     10,
  startAt:   null,               // absent parameters echo as null, not omitted
  cursor:    null,

  order: ["p14", "p3"],          // plus plan/examined/truncated/nextCursor
  plan:  "index-order"           // for windowed subscriptions
}

A subscription can fail after it starts. If a querySub grows past 1,000 matching records the server sends a status: "Failed" payload with an explanatory data string and then drops the subscription. Your handler receives that payload like any other — check status inside subscription handlers, not only on request replies. See Subscription Limits.

Cancelling a subscription answers { status: "Success", action: "unSubscribe", requestId }.

Connection-level messages

Two messages come from the connection itself rather than from an operation. Both may arrive with no requestId to correlate, so they surface through the subscription dispatcher rather than settling a pending promise.

MessageWhen
{ status: "Failed", action: "parseError", requestId?, message } The message could not be parsed, nested deeper than 64 levels, contained a forbidden key, or was not a JSON object. The server answers rather than dropping it silently — a dropped message leaves the caller's promise pending forever. requestId is echoed when it could still be recovered from the payload.
{ status: "Failed", action: "rateLimit", message: "Too many messages" } The connection exceeded 500,000 messages per second. The socket is closed with code 1008 immediately afterwards.

Durable writes have one extra failure mode. A { durable: true } write whose disk flush fails returns the normal reply with status overwritten to "Failed" and message: "Durable flush failed" — even though the in-memory state was updated and subscribers already saw it. A retry timer reconciles the disk later. See Durable Writes.

Client requests time out after 30 seconds. If no reply arrives, the promise rejects with Request timeout: <action> <requestId>. This is a client-side timer, not a server response.

Complete Client NukeBase SDK with createClient()

Here's a complete example using the new modular SDK:

Complete client implementation
<script type="module">
  import createClient from './sdkmod.js';

  // Destructure all the methods you need
  const { set, get, update, increment, query, callableFunction,
          getSub, querySub, getSubChanged, querySubChanged,
          setFile, getFile, listFiles, removeFile, fileUrl } = await createClient();

  console.log('✅ Connected to NukeBase');

  // Set data
  await set(["users", "matt"], {
    name: "Matt",
    color: "red",
    count: 0
  });

  // Get data
  const sessions = await get(["sessions"]);
  console.log('Sessions:', sessions.data);

  // Update data
  await update(["users", "matt"], {
    leadsSent: "Pending"
  });

  await update(["users", "matt", "count"], 5);

  // Query data
  const results = await query({
    path: ["sessions"],
    query: "child.count > 0"
  });
  console.log('Query results:', results.data);

  // Atomic counter — no read-modify-write race, and the new value comes back
  const bumped = await increment(["users", "matt", "count"], 1);
  console.log('New count:', bumped.data);

  // Sorted, paged query. Read results through .order — object key order
  // is not something to depend on.
  const top = await query({
    path: ["sessions"],
    query: "child.count > 0",
    orderBy: ["count"],
    desc: true,
    limit: 10
  });
  top.order.forEach(k => console.log(k, top.data[k].count));

  // Upload a file, then link to it
  const picker = document.querySelector('input[type=file]');
  if (picker?.files[0]) {
    try {
      await setFile(["users", "matt", "avatar.png"], picker.files[0], {
        onProgress: p => console.log(Math.round(p * 100) + '%')
      });
      document.querySelector('img').src = fileUrl(["users", "matt", "avatar.png"]);
    } catch (err) {
      // File methods THROW (unlike the data methods above, which resolve
      // with { status: "Failed" }). Branch on the HTTP status.
      console.error('Upload failed:', err.status, err.body);
    }
  }

  // Call a server callable
  const functionResult = await callableFunction("custom1", 23);
  console.log('Function result:', functionResult);

  // Subscribe to changes
  const unsubscribe1 = getSub({
    event: "value@",
    path: ["sessions"]
  }, data => {
    console.log('Sessions updated:', data);
  });

  // Query subscription
  const unsubscribe2 = querySub({
    event: "value@",
    path: ["sessions"],
    query: "child.count == 4"
  }, data => {
    console.log('Matching sessions:', data);
  });

  // Changed-only subscription
  const unsubscribe3 = getSubChanged({
    event: "value@",
    path: ["sessions"]
  }, data => {
    console.log('Changed sessions:', data);
  });

  // Query changed subscription
  const unsubscribe4 = querySubChanged({
    event: "value@",
    path: ["sessions"],
    query: "child.count != 4"
  }, data => {
    console.log('Changed query results:', data);
  });

  // Later, to unsubscribe:
  // unsubscribe1();
  // unsubscribe2();
  // unsubscribe3();
  // unsubscribe4();

</script>