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.)
  • public/sdkmod.js: The browser SDK — an engine file, refreshed by nukebase update
  • public/sw.js: The service worker — push delivery and the page cache. Also an engine file; never move or edit it
  • sys/deploy.js: A stub that forwards to the CLI. The CLI itself is the nukebase npm package (npx --yes nukebase)
  • sys/config.json: Deployment configuration: which account owns the project (uid, account), exclude and backup. It holds no sign-in
  • node_modules/: Dependencies (auto-generated)
  • package.json: NPM package configuration
  • package-lock.json: Dependency lock file

Setup and Initialization

Getting started takes one command. Open a terminal — or Claude Code — in an empty folder and run:

Create your project
npx --yes nukebase start

# What it does, in order:
#   1. signs you in — a browser tab opens, click Confirm once
#      (no account yet? it creates one on the free plan, no card)
#   2. creates the project and puts it online
#   3. downloads it into this folder and runs npm install
#
# Name and size up front if you like:
npx --yes nukebase start myapp 256mb      # sizes: 128mb, 256mb, 512mb, 1gb

Instant Deployment: the project is provisioned, deployed and live before the command returns, at
https://<your-id>-<project>.<region>.nukebase.com — the command prints the exact address, the dashboard shows it too, and a custom domain can be added later with npx --yes nukebase domain.

No build steps, no server configuration. Using Claude Code? Paste the command, click Confirm in your browser, then tell Claude what to build — the folder it is sitting in is now the project.

Other ways to create a project. From the dashboard, create a project and it downloads as a ZIP (the Download ZIP button fetches it again later); extract it and run npm install inside. Or split the steps in the terminal: npx --yes nukebase login then npx --yes nukebase create myapp 256mb. To connect a folder you already have to an existing project, npx --yes nukebase link <name>.

Day-to-day: develop locally, push to deploy

Nothing is installed globally — npx --yes nukebase fetches the CLI as needed. The --yes matters when an AI agent runs the command for you: without it npx stops to ask "Ok to proceed?", and an agent with a terminal attached waits there forever. Inside the project folder:

Everyday commands
node server/database.js      # run the whole thing locally at http://127.0.0.1:3000

npx --yes nukebase push            # upload your changes to the live server and restart it
npx --yes nukebase pull            # download the live server's code to this folder
npx --yes nukebase logs --follow   # stream the live server's output
npx --yes nukebase start           # inside a project: restart it (also resumes a paused one)

# Tired of typing npx? Install once per computer and drop the prefix:
npm i -g nukebase
nukebase push

Any editor works. In VS Code: File → Add Folder to Workspace, pick the project folder, then Terminal → New Terminal and the commands above run from there.

NukeBase CLI Commands:

  • nukebase push - Upload your local changes to the live server
  • Before uploading, nukebase push runs node --check on every server-side .js file it is about to send and refuses the push if one does not parse, naming the file and line — a syntax error would otherwise go live and only fail on the server's next start. Browser code under public/ and node_modules are skipped. --no-check pushes anyway.
  • Large pushes, pulls and backup run over 4 parallel connections: one connection is often capped well below a home line's speed, and several together fill it. Set "streams" (1–8) in sys/config.json, or --streams <n> for one command; small transfers (under 32 MB and 200 files) always use one. Only changed files are sent, and an interrupted push or pull resumes: run it again and files that already arrived intact are skipped, while a file cut off mid-transfer is sent again.
  • nukebase pull - Download the latest changes from the live server

Accounts: sign-ins are kept in ~/.nukebase/credentials.json on your computer, never in the project. sys/config.json only names the account that owns the project, and every command in that folder runs as that account, so several accounts can be signed in at once (nukebase login --email you@example.com adds one, nukebase accounts lists them, nukebase logout you@example.com signs one out, plain nukebase logout signs out all). A CLI sign-in can deploy and manage projects but not billing, and lasts until you sign it out here or under Signed-in devices on the dashboard. For CI, create a build token there and set NUKEBASE_TOKEN and NUKEBASE_UID. No browser on the machine? nukebase login --device shows a code to type on the website.

Nothing on the live site changes until you run nukebase push: develop locally, then push to deploy.

Push and pull delete files that are absent on the other side, so keep the running app's own state out of the sync with exclude in sys/config.json — the template ships ["sys", ".env", "server/data", "server/backup", "server/files"]. Review the printed add/remove lists before confirming.

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)

Deploying: Local edits go live when you run nukebase push. A push uploads the changed files and restarts your app, so changes to server/app.js and server/rules.js take effect, and files in /public are served from disk as soon as they arrive.

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
sendPushSend a push notification to every device saved at a path, a $-wildcard path, or a list of paths — see Push Notifications
disconnectUserClose every open WebSocket signed in as a uid, now — for a ban or a forced sign-out. Returns how many were closed. Claim changes do not need it; see Custom Claims
configurePushOptional push settings at startup: hardcode your own VAPID keys (publicKey, privateKey) and a contact — see Push Notifications: Keys

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
DOMAIN—Public origin used to build magic-link URLs and console output (e.g. https://your-app.nukebase.com).
SOCKET—If 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 $split marker whose subdirectory (or its data.json) is missing or empty from a fatal startup error to a logged warning, treating the subtree as empty. A data.json that does not parse is always fatal. Default is fail-fast so a bad disk state can't silently produce data loss on the next flush.
BACKUP_KEEP_DAYS7How many days of daily backups to keep under server/backup/. Dated directories older than this are removed after each successful backup; nothing else in that directory is touched. 0 keeps every backup forever.

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.

On the hosted platform

A hosted project's process is started by the platform, so its environment has to come from the platform too. Keep a .env file in your project root — the same file node --env-file=.env server/database.js loads locally — and push it separately from your code:

Hosted environment
nukebase env push          # make the hosted environment equal to ./.env, then restart
nukebase env list          # variable names currently set (add --show to print values)

Every variable in the file lands in process.env — your own settings and any of the engine tunables above. env push replaces the whole set, so removing a line locally and pushing removes it on the server. .env is never carried by an ordinary push or pull; on the server it is kept outside the project directory, root-owned, where no sync, ZIP download or restore point can reach it. SOCKET and DOMAIN are set by the platform and are refused. Values are single-line and up to 8 KB each; encode anything multi-line, such as a PEM key, before putting it in the file.

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_MAX10000WebSocket messages per second per connection. Exceeding it sends a rateLimit notice and closes the socket. Also caps { durable: true } writes per session per second. Raise it for bulk loaders.
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.
SPLIT_THRESHOLD_MB128Size in MB above which a data.json is split into per-key subdirectories. A flush re-serializes each dirty file in full, so this also bounds how long one flush can stall the engine (roughly 2.7 ms per MB). Lower it to trade more, smaller files for shorter stalls.
FLUSH_CACHE_MB256Memory budget for the flush cache, which keeps the serialized text of each top-level key so a flush only re-serializes keys written since the last one. Costs about the on-disk size of the cached files. Once full, further keys are simply re-serialized as before. 0 disables it.
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. It exits with process.exitCode (0 unless you set it): to flush and report a failure — from an uncaughtException handler, say — set process.exitCode = 1 before sending yourself SIGTERM, so a supervisor can tell a crash from a deliberate restart.

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 — HEAD answers with the headers and the file's Content-Length and no body, and an empty file is answered immediately with an empty body:

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.
  • Validators: Every file carries a weak ETag (from size and mtime) and a Last-Modified header. A request with a matching If-None-Match (or, without one, If-Modified-Since) is answered 304 Not Modified with no body, so checking whether a file changed costs a round trip and no bytes.
  • Cache-Control: no-cache for most files, which means "always revalidate" — browsers never serve a stale copy on their own, and a push shows up on the next load. Files with image/* content types keep public, max-age=31536000 (1 year), which is why an icon is renamed rather than replaced. Files served by a mount whose auth callback read who the caller is (see below) get private, no-store.

Files gated on a person are never cached. If the auth callback reads any identity field — req.uid, req.username, req.email, req.token, req.claims or req.cookies — the response is marked private, no-store, so neither the browser's HTTP cache nor the service worker's page cache keeps it, and it can never be shown to the next person on a shared device. The template's () => true reads nothing, so an ordinary site stays cacheable. Reading req.ip or req.url (for logging, say) does not make a file private.

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). get(path, admin) and remove(path, admin) take it second; query({ path, query, admin }) takes it as a property.

Server-side get returns a live reference, not a copy. The value is the engine's own in-memory object. Treat it as read-only: a field changed in place is never persisted, because nothing told the engine that key is dirty and the flush only re-serializes keys it was told about. To change data, call set, update, increment or remove; to hold a snapshot, copy it first (structuredClone).

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" and the write is rolled back in memory — "Failed" means it was not applied, so retrying it is safe (an increment is not applied twice). Subscribers are not notified and triggers do not run for a rolled-back write. The restore covers the written path, so a non-durable write to the same subtree that landed in the same instant is unwound with it.

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
  • Durable writes per WebSocket session: the same per-second budget as messages (WS_RATE_LIMIT_MAX); over it the write fails with Durable rate limit exceeded
  • 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.
  • Reserved key: a key or path segment that is exactly "$" is refused, both in a path (Invalid path) and anywhere inside written data. "$" is the placeholder queries are authorized through (see Query Authorization), so a real record under that key could be mistaken for it. Keys that merely start with $ are unaffected.

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.

child is the only name a query can see. A query cannot reach other parts of the database (there is no data or root here, unlike in security rules) — any other name reads as undefined, so a condition on it matches nothing. To filter on data outside the record, do the read server-side in a callable.

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.

Live queries (querySub / querySubChanged) apply that same check to every update they deliver, not only to the first result — so a collection readable through a rule on its $id level keeps streaming updates.

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
  }
}

At the $ level and below, a read rule always sees data as undefined during a query check — it is judging a hypothetical record, never whatever real record happens to be stored under the key "$". A rule such as "data.public === true" therefore denies the query rather than being satisfied by one planted record. (Since engine 1.5.1; the key "$" also can no longer be written — see the limits above.)

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, COM1–COM9, LPT1–LPT9, 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-Disposition — inline 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. DELETE is the one exception: it checks existence before rules, so a missing path answers 404 and a denied delete of an existing file answers 403.

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; delete of an existing path refused by fileWrite
404Not found or not permitted (download, listing); for delete, not found only
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.signal; setFile and getFile also accept opts.onProgress. onProgress is called with a fraction from 0 to 1 (upload progress for setFile, download progress for getFile); listFiles and removeFile have no transfer to report on. 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

Four file operations, two synchronous helpers and the FILES_DIR constant are destructured from your app.js module argument. Unlike the synchronous data operations, the four 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 rule denies, and logs. This holds for file rules and data rules alike: the error text never leaves the process (it would otherwise name the exact field a rule keys on), the failure is written to the server log so a broken rule is diagnosable rather than mysteriously silent — and 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.
  • write and remove never fire for an operation that was refused. reserve can: an upload that passes the pre-byte checks is booked, and if the authoritative post-byte check then rejects it, release fires to unbook it — the pair always nets to zero.
  • 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 10,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

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.

A rule that compiles but throws at evaluation time (a ReferenceError, or data.ownerId when data is undefined) denies: a read/write level that throws grants nothing and the walk continues, a validate level that throws fails the write. The error is logged server-side and never sent to the client. Use ?. whenever a rule walks the tree.

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.

On update() and increment(), newData is the merged record — the stored value with the patch applied, exactly what will be written — not just the fields being changed. So "validate": "newData.title && newData.body" accepts a partial update of a valid record and rejects one that would make it invalid. Which rules run is still decided by the patch: a rule on a field the update does not touch is not re-run.

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 == root.posts[$postId]?.authorId" }  // Only the author. `data` here is the draft itself, so reach the post via root
  }
}

// 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 rule — file or data — 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
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, email, 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 immediately before it — after the write and validate rules have passed, so a denied write costs no snapshot. 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 (a failed flush rolls the write back and runs no triggers).

A throwing trigger is contained. Each database trigger runs in its own try/catch: a throw (or a rejected promise from an async trigger) is logged with the trigger's path and the remaining triggers for that write still run. The write itself stands — it was applied and acknowledged before triggers run — so the client sees one Success reply and the failure is visible only in the server log. If a trigger's work must succeed, handle and retry it inside the trigger. Connection triggers and file triggers are guarded the same way.

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 deep copy of the value at context.path as it was immediately before the write, or undefined if nothing existed there. It is captured before the write is applied and cloned, so nothing that happens afterwards can alter it. Because it is the whole prior subtree at the trigger's path, fields the write removed are still present in it, and context.dataBefore === undefined is a reliable test for "this path was just created".

Detecting "is this new?" and "what was removed?": both fall out of the snapshot. context.dataBefore === undefined means nothing existed at context.path before this write. Comparing the keys of dataBefore and dataAfter shows which fields a set replaced away: replacing {item:"y", price:5} with set(..., {item:"x"}) gives dataBefore = {item:"y", price:5} and dataAfter = {item:"x"}. The snapshot is taken at the trigger's depth — a trigger on ["orders", "$id"] sees the whole order before and after, even when the write touched one field of it.

A write above the trigger's path does not fire it. Matching is by prefix of the write path: a trigger on ["users", "$uid", "email"] fires for writes at or beneath users.<uid>.email, but not for set(["users", "john"], {...}) even though that replaces the email. Register at the shallowest path whose changes you care about — bubble-up applies to subscriptions, not to triggers.

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 created. dataBefore is undefined when nothing existed
// at the order's path before this write — that is the "is this new?" test.
// Registering on "set" (not "update") also keeps this from refiring when the
// trigger's own update() call below runs.
addDbTrigger("set", ["orders", "$orderId"], function(context) {
  if (context.dataBefore === undefined && context.dataAfter) {
    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.

A WebSocket connection's identity ends with its session. The user is fixed when the socket connects, and the session token it connected with is re-checked on every message and every 30 seconds. Once that token is revoked (/logout) or expires, the server sends { status: "Failed", action: "sessionExpired" } and closes the socket with code 4001; the SDK's reconnect then comes back as whoever the current cookies say — anonymous after a logout.

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.

The identity on a socket is fixed at upgrade time. Who the user is 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. (Their claims, by contrast, are refreshed while they stay connected; see Custom Claims.) 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.email, 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 sys/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 path = require('path');
  const createServerClient = require('../sys/serversdk.js');

  // Connect to an external NukeBase database
  // Note: External connections ARE async (like client-side)
  // The second argument is REQUIRED: a JSON file holding { uid, token } for the
  // session to connect as. Use a build token (dashboard → Signed-in devices →
  // Create build token) for the account that owns the other project; keep the
  // file out of public/. The SDK sends them as cookies.
  createServerClient('wss://other-project.nukebase.com',
                     path.join(__dirname, 'external-session.json')).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.

serversdk.js covers the data operations, subscriptions, callableFunction and the auth endpoints. It has no file methods (use fetch against /_file/… with the session cookies) and no request timeout — a request the remote never answers stays pending.

Push Notifications

Send notifications to your users' phones and computers, even when your app isn't open. Free to send, no app store, no email provider.

Push is built into the engine, like the file store. There's nothing to install or mount, and nothing runs until your app first uses it. It works in two halves:

  • Devices are ordinary data. When someone turns notifications on, the browser's subscription is saved wherever your app chooses: under the user, a team, a group of fans. Your security rules decide who may save it where, like any other write.
  • Sending points at an area. sendPush(["teams", teamId, "pushTokens"], …) notifies every device saved there.

The engine handles the hard parts:

  • Keys. Your app's signing keys (VAPID) are created the first time push is used and kept in your database. Or hardcode your own with configurePush.
  • Encryption. Every message is encrypted for the device it's going to (RFC 8291).
  • Cleanup. A device that was uninstalled or turned notifications off is removed from the exact place it was found.
  • No duplicates. A phone saved under both a user and a team gets one notification when you send to both.
  • The service worker. /sw.js ships with the engine and is updated by nukebase update.

No npm packages are needed: the engine uses Node's built-in crypto.

Push Notifications: Where it works

Device / browserWorks?Notes
Android: Chrome, Edge, Firefox, SamsungYesWorks in the browser. Installing the app is optional.
Windows, Mac, Linux: Chrome, Edge, FirefoxYesNotifications arrive while the browser is running.
Mac: Safari 16+Yes
iPhone / iPad: Safari, iOS 16.4+After installOnly after the user taps Share, then Add to Home Screen, and opens the app from there. client.push.status() returns "install-first" until then.
Facebook / Instagram / TikTok in-app browsersNoThese browsers can't install apps or receive push. status() returns "unsupported", so ask the user to open the page in Safari or Chrome.

Plan for people who never turn it on. Many users won't install the app or allow notifications. Use push for helpful nudges. Anything a user must not miss (receipts, password resets, legal notices) still needs email.

Push Notifications: Quick start

1. Let users save their device (rules)

server/rules.js
"users": {
  "$uid": {
    "pushTokens": { "write": "$uid == admin.uid" }   // each user saves only their own devices
  }
}

2. Add a button to your page

Browsers only show the permission prompt after a tap, so put it behind a button, never on page load.

public/index.html
document.querySelector("#notify").onclick = async () => {
  const status = await client.push.enable(["users", myUid, "pushTokens"]);
  if (status === "on") showMessage("Notifications are on");
  if (status === "install-first") showMessage("Tap Share, then Add to Home Screen, then open the app from your Home Screen");
  if (status === "blocked") showMessage("Notifications are blocked in your settings for this site");
};

3. Send from the server

server/app.js
module.exports = ({ get, set, addDbTrigger, sendPush /* , … */ }) => {

  // anywhere on the server: a trigger, a callable, a timer
  sendPush(["users", uid, "pushTokens"], {
    title: "Your order is ready",
    body: "Pick it up any time before 5pm.",
    url: "/orders"
  });

Push Notifications: Pointing at an area

The first argument to sendPush says where the devices are. It's a path, like every other data operation.

TargetWho gets it
["users", uid, "pushTokens"]Every device of one user.
["teams", teamId, "pushTokens"]Every device saved to that team.
["users", "$uid", "pushTokens"]Everyone. $ segments are wildcards, as in triggers and rules.
["fans"]Every device saved directly under fans.
[pathA, pathB]Several areas at once. A device found in both is sent to once.

What counts as a device

At each node you point at, the engine accepts one of two things:

  • A map of devices, like pushTokens below. This is what client.push.enable writes.
  • A single device: one subscription object with endpoint and keys.

It doesn't search any deeper. Pointing at ["users"] finds nothing, because the devices are two levels further down. Use the wildcard ["users", "$uid", "pushTokens"] instead. This keeps every send fast: the engine only looks where you told it to, however big the rest of your database is.

["users", uid] with two devices (values shortened)
"pushTokens": {
  "-u5r83BoVpsmbv4AmSUnmA": {                       // an Android phone (Chrome)
    "endpoint": "https://fcm.googleapis.com/fcm/send/cmJI…",
    "expirationTime": null,
    "keys": { "p256dh": "BDF4XAl5…", "auth": "-u5r83BoVpsmbv4AmSUnmA" }
  },
  "AA6TAGG9CDXjJHKtGKMTjg": {                       // an iPhone (Home Screen app)
    "endpoint": "https://web.push.apple.com/QKVLecOK…",
    "keys": { "p256dh": "BK2nrxlT…", "auth": "AA6TAGG9CDXjJHKtGKMTjg" }
  }
}

Each device is the exact object the browser produces (PushSubscription.toJSON()), stored unchanged and keyed by its auth value. That value is unique per device and stays the same when the device re-subscribes, so turning notifications on twice updates the same entry. If your app already stores subscriptions in this shape, sendPush can use them as they are.

Push Notifications: Server API

FunctionReturnsDescription
sendPush(target, payload, opts?)Promise<Result>Sends to every device found at target (see Pointing at an area), 10 at a time.
configurePush({ publicKey, privateKey, contact })nothingOptional; call once at startup. publicKey and privateKey hardcode your own key pair (see Keys). contact is how Apple and Google can reach you about your messages, and defaults to your app's own address (https:// + DOMAIN). Most apps never call it.
Result
{
  sent: 2,      // accepted by the push service
  removed: 1,   // devices that are gone (uninstalled, permission revoked); deleted where they were found
  failed: 0     // temporary errors (push service down, rate limited); not retried automatically
}

Send options

OptionDefaultMeaning
ttl86400Seconds the push service keeps the message if the device is offline. Use a short value for time-sensitive messages ("your table is ready").
urgency"normal""very-low", "low", "normal" or "high". Phones in battery saver may hold back low-urgency messages.
topicnoneA short name (up to 32 letters and digits). A newer message with the same topic replaces an undelivered older one, for example "score".

The engine serves one route for the browser SDK: GET /push/key, your app's public key. There are no subscribe routes, because saving a device is an ordinary database write. To switch push off completely, set PUSH_DISABLED=1 in your .env and run nukebase env push.

Push Notifications: The payload

FieldRequiredDescription
titleyesThe bold first line. Every push shows a notification, so sendPush rejects a payload without a title.
bodynoThe text under it. Phones show about 4 lines.
urlnoWhere tapping opens, as a path in your own app like "/orders/42". Links to other sites are ignored and open / instead.
iconnoA path to an image in public/. Defaults to the icon in your manifest.
tagnoA notification with the same tag replaces the one already on screen instead of stacking.
badgenoA number to show on the app's icon, like unread messages. 0 clears it. See App icon badge.
datanoAny small JSON your page may want when it opens.

The whole payload must be 3 KB or less once converted to JSON. sendPush rejects anything bigger with a clear error rather than letting the push service drop it. Send a short message and a url, and let the page load the details.

Push Notifications: App icon badge

The badge is the red number on an installed app's icon. Set it in any push with badge, and from the page with client.push.setBadge(). It's a plain number you choose, such as unread messages or open orders, and 0 clears it.

WhereBadge?Notes
iPhone / iPad, installed app (iOS 16.4+)YesShown once the user has allowed notifications.
Windows / Mac, installed Chrome or Edge appYesOn the taskbar or Dock icon.
Mac, Safari web app in the DockYes
AndroidDot onlyAndroid shows its own dot while a notification is unread. The number is ignored.
A normal browser tabNoBadges need the installed app.

Where badges aren't supported, badge is silently ignored and the notification still shows. Your code never has to check.

Set it with a push

The number is the same for every device in one send. For a count that differs per person, like each user's unread messages, send to each user separately:

New message: notify and update the unread count
addDbTrigger("set", ["chats", "$chatId", "messages", "$msgId"], (context) => {
  if (context.dataBefore !== undefined) return;
  const msg = context.dataAfter;
  const unread = increment(["users", msg.to, "unread"], 1).data;   // the recipient's new total
  sendPush(["users", msg.to, "pushTokens"], {
    title: msg.fromName,
    body: msg.text,
    url: `/chats/${context.path[1]}`,
    badge: unread
  });
});

Clear it when they've caught up

Opening the app doesn't clear the badge on its own. Set it when the user has seen things, and keep your stored count in step:

public/index.html
async function openInbox() {
  showInbox();
  await client.set(["users", myUid, "unread"], 0);
  client.push.setBadge(0);          // this device, right now
}

setBadge only changes the device it runs on. If the user also has a laptop, its badge updates with the next notification you send them, which carries the new count. There are no silent, badge-only pushes: every push shows a notification, because Apple cancels apps that receive pushes without one.

Push Notifications: Client API

The browser SDK gains a client.push object.

MethodReturnsDescription
client.push.enable(path)Promise<status>Must be called from a tap or click. It registers /sw.js, asks permission if needed, subscribes, and saves this device at [...path, auth] with a normal set, so your rules apply. Call it again with another path to save the same device somewhere else too. Device problems never throw; they come back as a status. Only a missing or non-array path throws, as a programming error.
client.push.status()Promise<status>Tells you what to show, without asking the user anything.
client.push.setBadge(n)Promise<void>Sets the number on this device's app icon. 0 clears it. Does nothing where badges aren't supported.
client.push.disable(path?)Promise<"off">With a path, removes this device from that one place and keeps notifications on elsewhere. Without one, removes it from every place this browser saved it and turns push off on this device.
StatusMeaningWhat to show
"on"This device will receive notifications."Notifications on", plus a Turn off option.
"off"Supported, but not enabled yet.A "Turn on notifications" button.
"blocked"The user said no, or blocked it in settings. Browsers won't ask again.How to allow it in the phone's or browser's settings.
"install-first"iPhone or iPad Safari, not installed yet."Tap Share, then Add to Home Screen, then open the app from your Home Screen."
"not-saved"Permission was given, but your rules refused the write to path.Usually the user isn't signed in, or the path is wrong. Check your rules.
"unsupported"An in-app browser, or a very old browser."Open this page in Safari or Chrome."

Users never have to turn it on twice. The SDK remembers every path this browser saved the device to. Each time the app opens, if the browser or phone renewed the subscription behind the scenes, the SDK quietly saves the new one to those same paths. Updates to sw.js and to your app keep both the permission and the subscription.

Push Notifications: Rules

Saving a device is a normal write, so your rules decide where each device may be saved. Three common patterns:

server/rules.js
module.exports = {
  // 1. Each user saves their own devices
  "users": { "$uid": { "pushTokens": { "write": "$uid == admin.uid" } } },

  // 2. Team members save a device to their team
  "teams": { "$teamId": { "pushTokens": {
    "write": "root.teams[$teamId].members?.[admin.uid] === true"
  } } },

  // 3. No sign-in needed: anyone may add a device, but only something shaped like one
  "fans": { "$device": {
    "write": "true",
    "validate": "newData == null || (typeof newData.endpoint === 'string' && newData.endpoint.startsWith('https://') && newData.keys?.auth === $device)"
  } }
};

A stored device can't be misused. A subscription can't be used to send anything without your app's private key, which never leaves the server. Anything at a target that isn't shaped like a subscription is ignored, and a subscription with broken keys is removed on the next send. Still, keep devices private like any other user data: don't give browsers read access to everyone's records.

Only real push services are contacted. Devices are written by browsers, and the server sends to them, so the engine only posts to public https:// addresses. An endpoint on an IP address, on a local-only name, or on a name that resolves to a private network address is never contacted. IP and local-name entries are removed, and private-resolving ones are counted as failed. Real subscriptions always point at the browsers' push services (Apple, Google, Mozilla, Microsoft), so this never affects them.

Never let the browser choose the target of sendPush. A callable that sends to whatever path or uid the caller names lets anyone notify all your users. The server always decides the area, as in the examples below.

Push Notifications: Examples

Notify when something is created (DB trigger)

New repair request: tell the landlord
addDbTrigger("set", ["repairs", "$id"], (context) => {
  if (context.dataBefore !== undefined) return;          // only brand-new requests
  const repair = context.dataAfter;
  const landlord = get(["buildings", repair.building, "owner"]).data;
  sendPush(["users", landlord, "pushTokens"], {
    title: `New repair: ${repair.flat}`,
    body: repair.summary,
    url: `/repairs/${context.path[1]}`,
    tag: `repair-${context.path[1]}`
  });
});

A whole team

Team run moved: tell everyone on the team
addDbTrigger("update", ["teams", "$teamId", "nextRun"], (context) => {
  const teamId = context.path[1];
  sendPush(["teams", teamId, "pushTokens"], {
    title: "Saturday's run has changed",
    body: `Now ${context.dataAfter.time} at ${context.dataAfter.place}`,
    url: `/teams/${teamId}`,
    topic: "next-run"                                  // a newer change replaces an undelivered older one
  });
});

Reminders at a set time

There's no built-in scheduler. Store the time on the record and check every minute. Mark each reminder as sent before sending, so a restart can never send it twice.

Booking reminders, 24 hours ahead
setInterval(() => {
  const soon = Date.now() + 24 * 60 * 60 * 1000;
  const due = query({ path: ["bookings"], query: `child.at <= ${soon} && !child.reminded` }).data || {};
  for (const [id, b] of Object.entries(due)) {
    update(["bookings", id], { reminded: true });         // mark first: never send twice
    sendPush(["users", b.uid, "pushTokens"], {
      title: "Appointment tomorrow",
      body: `${new Date(b.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} with ${b.groomer}`,
      url: `/bookings/${id}`
    });
  }
}, 60 * 1000);

Staff notify a customer (callable)

"Your order is ready" button for staff
addCallable("orderReady", async (data, admin) => {
  if (!admin.claims?.staff) return { status: "Failed", message: "Staff only" };
  const order = get(["orders", data.orderId]).data;
  if (!order) return { status: "Failed", message: "No such order" };
  update(["orders", data.orderId], { status: "ready" });
  // The server picks the target from the order, never from the caller.
  return sendPush(["users", order.customerUid, "pushTokens"], { title: "Your order is ready", url: `/orders/${data.orderId}` });
});

Everyone, or fans without accounts

New show announced
// Browser, no sign-in (uses rule 3 above):
await client.push.enable(["fans"]);

// Server: every fan device, plus every signed-in user
addDbTrigger("set", ["shows", "$id"], (context) => {
  if (context.dataBefore !== undefined) return;
  sendPush([["fans"], ["users", "$uid", "pushTokens"]], {
    title: "New show announced",
    body: context.dataAfter.venue,
    url: "/shows"
  });
});

Push Notifications: Make the app installable

iPhones only deliver push to apps added to the Home Screen, and an installed app also looks and feels like a real app on Android. Two things make that possible: a manifest file and an icon. New projects include both. For an existing project, add them like this:

public/manifest.webmanifest
{
  "name": "Pawsbook",
  "short_name": "Pawsbook",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4361ee",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
In the <head> of every page
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icon-192.png">
<meta name="theme-color" content="#4361ee">

Changing the icon needs a new file name. The engine tells browsers to cache images for a year, so replacing icon-192.png in place leaves phones showing the old one. Save the new icon as icon-192-v2.png and update the manifest.

Push Notifications: The service worker

public/sw.js is an engine file, like sdkmod.js. The SDK registers it on every page, whether or not the app uses push. It does five things:

  • Keeps a page cache. See below: the app opens instantly from the last copy seen, and opens at all with no signal.
  • Sets the badge. When a push carries badge, it updates the app icon number, even if the app is closed.
  • Shows every push. It always displays a notification, even for a damaged payload (falling back to "New update"). Apple cancels subscriptions for apps that receive pushes without showing one.
  • Handles taps. It focuses an open window of your app or opens the payload's url, and only ever within your own site.
  • Updates itself immediately. A new version takes over the next time the app is opened.

Never move, rename or edit /sw.js. A service worker at a different address counts as a new one with no subscriptions, and every user would have to turn notifications on again. nukebase update replaces this file, so edits are lost. If you need your own worker logic, add sw.js to updateExclude and keep the push handlers intact.

The page cache

On by default. Every same-origin GET the page makes — the HTML, sdkmod.js, your scripts, styles, images and manifest — is answered from the worker's cache when a copy exists, and the current copy is fetched in the background and stored for next time. This is stale-while-revalidate: a page the user has seen before opens at once, even on a slow connection, and with no network at all it still opens. A page never opened on that device gets a small built-in "You're offline" page instead of the browser's error.

Nobody is left on an old build. Every load re-fetches in the background (serveStatic answers 304 cheaply when nothing changed), and when the fetched copy differs from the stored one the worker tells every open window. The SDK reloads the page by itself only when that happens within a few seconds of load and before the user has typed, tapped or scrolled — on a fast connection that is an invisible refresh. After that it is your app's call through client.onUpdate, which is the place for a "new version available" notice:

public/index.html
const client = await createClient();

client.onUpdate(() => {
  showBanner("A new version is ready", { action: "Reload", onClick: () => location.reload() });
});

// Or turn the page cache off for this app. Push keeps working.
const client = await createClient({ pageCache: false });

What is never cached: anything cross-origin; anything but GET; the WebSocket, which the worker never sees; the file store under /_file/, which is rule-governed; any response carrying Cache-Control: private or no-store, which is how a route of your own stays out; any non-200; anything over 8 MB. The cache keeps at most 500 entries, oldest dropped first. Files served to a signed-in person by an auth-gated serveStatic mount are marked private by the engine and never stored — see Serving Static Files.

The first visit is never offline-ready. The worker stores what passes through, so a page is available offline from the second visit on. It stores no list of files in advance, which is what keeps it from ever serving a build you did not just push.

The page cache is not the database. It holds files. Data read over the WebSocket is not cached by the worker; an app opened offline renders, connects when it can, and shows client.online === false until then. For data that survives being offline — cached reads and queued writes — opt in to offline mode.

Page cache: troubleshooting

SymptomLikely causeFix
After a push, a user still sees the old pageStale-while-revalidate serves the last copy first. The page reloads itself only within a few seconds of load and before any input; after that the new build waits for the next load.Expected. Register client.onUpdate and show a "new version, reload" notice so a user mid-task can refresh when they choose.
A members-only page opens offline for someone else on the same deviceThe serveStatic auth callback for that mount did not read any identity field, so the engine treated its files as public and the worker stored them.Gate on req.uid or req.claims in the callback. Files served that way are answered private, no-store and never cached. For a route of your own, send that header yourself.
A page you changed on the server never updates in one browserThat browser is offline, or the file is an image: images carry a one-year cache header and the browser keeps the old one.Rename a changed image (icon-192-v2.png) and update what references it. Everything else refreshes on the next online load.
The app opens offline but shows no dataThe page cache holds files only. Database reads go over the WebSocket, and the socket is down.Expected today. Use client.onConnectionChange to show an offline state until the socket is back.
A user who has opened the app before gets the "You're offline" pageThe copy aged out: the cache keeps 500 entries and drops the oldest, or they opened the app only before this engine version, when nothing was cached.One online visit fills the cache again. Nothing to change.
You want the old behaviour, no cache at all—createClient({ pageCache: false }). The worker drops its cache and only handles push. Nothing else changes.

Push Notifications: Keys

Every app has one VAPID key pair: a public key the browser subscribes with, and a private key that signs each message. You can let the engine make them, or bring your own.

Automatic (the default)

The first time push is used, the engine creates a key pair and stores it in your database at ["_nukebase", "pushKeys"]. Because they're part of your data, the keys are included in daily backups, nukebase backup and restore points, and they move with your data to another region.

_nukebase is ordinary data. Your rules decide who can reach it, like any other path, and rules deny by default, so browsers can't read it unless a rule grants it. Don't write a rule that does: anyone holding the private key and your saved devices could send notifications as your app. Your server code can read, set or remove it:

  • Setting a new { publicKey, privateKey } there switches keys at once, with no restart.
  • Removing it means a fresh key pair is made the next time push is used, which disconnects every subscribed device.

Your own keys (hardcoded in app.js)

To use keys you already have (from an existing app, web-push or Firebase), or to fix the generated ones in your code, pass them to configurePush near the top of server/app.js. Keys given here always take priority.

server/app.js
configurePush({
  publicKey:  "BPaste-your-65-byte-public-key-here…",   // 65 bytes, base64url
  privateKey: "paste-your-32-byte-private-key-here…",   // 32 bytes, base64url
  contact:    "mailto:hello@pawsbook.com"          // optional
});

When the engine creates keys automatically, the server log shows this exact line, ready to paste. A key that's malformed, or a public and private key that don't belong together, stops your app at startup with a clear message, instead of every send being refused later.

Keep your project's code private. Anyone with the private key and your saved devices could send notifications as your app. The devices are in your database, not your code, so the key alone isn't enough. Still, don't publish your code in a public repository.

Keys are for life. Changing them, or removing a configurePush key pair the app was already using, disconnects every subscribed device until each user turns notifications on again. The engine logs a warning at startup if your configured keys differ from the stored ones.

Local and hosted apps. A subscription belongs to one web address, so testing on localhost never reaches your real users, whichever keys you use.

Changing address means users turn notifications on again. Moving to a new domain, or nukebase regiontransfer to another region without your custom domain, gives your app a new web address, and existing subscriptions don't follow it. Your app's status() shows "off" for those users.

Push Notifications: Limits & delivery

  • Payload: 3 KB of JSON at most.
  • Delivery isn't guaranteed. Phones in low-power mode, Focus modes or with notifications turned off may delay or drop messages silently. The push service only confirms that it accepted a message.
  • Offline devices receive the message when they reconnect, if ttl hasn't run out.
  • Speed: sends go out 10 at a time. Roughly 1,000 devices take a few seconds, and it doesn't block your app while it runs.
  • Wildcards look only at the exact shape you give, so ["users", "$uid", "pushTokens"] reads each user's pushTokens and nothing else of theirs.
  • Cost: free to send. Each message is a few KB of outgoing traffic, metered like any other traffic.

Push Notifications: Troubleshooting

SymptomLikely causeFix
The button does nothing on iPhoneThe page is open in Safari, not installed.Show the "install-first" hint: Share, then Add to Home Screen.
enable() returns "not-saved"Your rules refused the write, often because the user isn't signed in.Check the write rule for that path.
sendPush returns sent: 0The target points one level too high, for example ["users"].Point at the map of devices itself, or use a wildcard: ["users", "$uid", "pushTokens"].
status() is "unsupported" on a phoneThe link was opened inside Facebook or Instagram.Ask the user to open the page in Safari or Chrome.
sent: 1 but nothing appearsNotifications are off for the app in the phone's settings, or Focus mode is on.Check Settings, Notifications, then your app.
Everyone had to turn notifications on againThe keys changed (a different or removed configurePush pair, or _nukebase/pushKeys replaced or removed), /sw.js moved, or the app's address changed.Keep all three stable. A lost key pair can only be recovered from a backup.
sendPush rejects "payload too large"Over 3 KB of JSON.Send a short message plus a url.

Offline Mode

Opt in, and the SDK keeps a local copy of the data this browser has seen in IndexedDB, answers reads from it when the server cannot be reached, and queues writes made offline to replay in order when the connection is back. It is the data half of working offline; the page cache is the files half, and it is on by default.

public/index.html
const client = await createClient({ offline: { enabled: true } });
// or, with a URL:  createClient(url, { offline: { enabled: true, maxBytes: 50 * 1024 * 1024 } })

// Reads answered from the cache say so
const r = await client.get(["notes", "n1"]);
if (r.fromCache) showStaleHint(r.cachedAt);

// Writes made offline are accepted locally and replay later
const w = await client.set(["notes", "n1"], { text: "written on the train" });
if (w.status === "Queued") showPendingBadge();

client.offline.onSync((e) => {
  if (e.type === "failed") toast("A change could not be saved: " + JSON.stringify(e.op.path));
  if (e.type === "drained") hidePendingBadge();
});

Reads

While the socket is open, get and query go to the server exactly as before, and the reply refreshes the cache on its way back. A promise resolves once, and a stale answer on a realtime database is a bug, so online reads are never served from the cache. When the socket is down — or dies with the request in flight — the SDK answers from the cache if it can, and the reply carries fromCache: true and cachedAt (the time the cached copy was fetched). When it cannot, the reply is { status: "Offline" }, as it always was.

The cache is one tree, in the same shape the engine keeps on disk, plus a coverage set: the paths that were read in full. A path is answerable offline when it, or any ancestor, is covered. What covers a path:

  • a get of it (including one that answered "Path not found" — the cache then knows it is absent);
  • the first payload or any push of a getSub on it;
  • a set or remove of it that the server accepted, or that is queued;
  • a query or querySub result covers each matched record, not the collection. So after only a query, a read of one matched record works offline and a read of the whole collection does not.

An update or increment covers its path only when nothing existed there before (then the value is entirely yours). getSubChanged and querySubChanged pushes merge into the tree but cover nothing, because a delta is not a full value.

Queries offline

The server's filter grammar (comparisons, && || !, .includes(), child.field.path) and its ordering rules (type rank, then value, then key; integer-looking keys before others) are implemented in the SDK, so orderBy, desc, limit, startAt and count behave the same offline, over the records the cache holds. An offline query reply carries plan: "cache" and partial: true when the collection itself is not covered, meaning records the browser never fetched cannot be in the result. cursor queries are online-only (a cursor is a position in the server's index) and answer "Offline".

Subscriptions

A subscription made with offline mode on fires once from the cache as soon as it is made, with fromCache: true on the payload, and then exactly as before: the server's first payload when the socket is open, every push after that. The screen has content while a slow socket catches up. The cached payload has the same shape as the server's, including the echoed window fields on querySub. A subscription with nothing cached under it simply waits for the server. Show the flag: a quiet "updating" state keeps values from changing under the user with no explanation.

A queued write also fires the subscriptions on its path and every ancestor, locally, with fromCache: true — the engine's bubble-up, without the engine. Deeper subscriptions are not fired, which is also the engine's rule.

Writes

With the socket open and nothing queued, a write goes straight to the server and the local tree is updated only when the server says "Success". Otherwise — offline, or while a replay is in progress, or when the socket died with the write in flight — the write is applied to the local tree at once, stored in IndexedDB, and resolved { status: "Queued", action, path, opId }. A queued increment carries the locally computed result in data, and the engine's checks (a finite number or an object of them, no more than 256 fields, never against a non-number) run locally so an increment the server would refuse resolves "Failed" right away instead of queueing.

On the next open the queue replays in order, before the subscriptions are restored, so the first server values the app sees already include its own writes. Each op on the wire carries its opId and the browser's clientId; the engine remembers the ops it applied and answers a replay from memory without applying it again. That is what makes a lost reply safe: an increment whose acknowledgement never arrived is sent again and lands exactly once. The durable flag replays with the op; a queued durable write resolves "Queued", never "Success".

What happens to each op at replay:

Server answerWhat the SDK does
"Success"Removes the op. A replayed increment takes the server's resolved counters. Emits sent.
"Failed" (a rule refused it)Removes the op for good, drops coverage beneath and above its path so the next online read refetches the truth, emits failed with the op and the reply. The optimistic value is visible until then; the event is where the app tells the user.
none — the network went againKeeps the op at the head of the queue and stops. The next open starts here.

Conflicts are last-writer-wins, and replays are judged as whoever is signed in at reconnect. A queued set replays over whatever the server has by then, and the server's rules see the replay as the cookies' current user. That is right for a person's own data — notes, a counter, a draft, a personal list — and wrong for anything two people contend for. A booking slot that must never double up, queued offline and replayed an hour later, either double-books or is refused by a validate rule long after the user thought they were done. Keep such writes online-only: check client.online before sending, or leave offline mode off for that app.

One cache per browser, wiped on sign-out

The cache is not namespaced by user. It is emptied — tree, coverage and queue — by logout(), by client.offline.clear(), and when the server closes the socket with code 4001, which it does when a session is revoked or expires while connected. login() and createUser() empty the tree and coverage but keep queued writes, which then replay as the new user. A session that expires while the tab is closed sends no 4001, so on a shared device the next person can see cached values until the server replaces them; it is data that was already on that device, and their own writes are judged by the server as them.

API

MemberDoes
createClient(url, { offline }){ enabled: true, maxBytes }. maxBytes (default 50 MB) caps what is written to IndexedDB: a top-level key that would exceed it is kept in memory for the session and a warning is logged once. With offline on, createClient resolves once the cache is loaded and the socket has opened, failed, or had one second — so a cached UI paints without waiting on a dead network.
client.offline.enabledtrue when offline mode is on. The object exists either way, so app code can call it without checking.
client.offline.status(){ enabled, online, coveredPaths, queued, lastSyncAt, clientId }
client.offline.onSync(fn)fn({ type, op, reply }) with type one of queued, sent, failed, drained. Returns a function that stops listening.
client.offline.flush()Replays the queue now, if online. Resolves when the replay stops. Happens by itself on every open.
client.offline.save()Writes the in-memory tree to IndexedDB now. Normally it is written on a 500 ms debounce, when the page is hidden, and with every queued write.
client.offline.clear()Empties the cache and the queue. Unsent writes are lost.
client.close()Closes the socket and stops reconnecting, for good, after writing the cache out. For tearing a client down; a page that navigates away does not need it.

Storage is one IndexedDB database per origin, nukebase-offline, with stores tree (one record per top-level key, the subtree as it is on the engine's disk), coverage, meta and outbox. A schema change wipes it rather than migrating; nothing in it is the source of truth. The SDK also exports createMemoryStorage(), the adapter the tests use to run offline mode in Node.

Offline mode: troubleshooting

SymptomLikely causeFix
A read offline answers "Offline" although the data was on screenIt came from a query or a getSubChanged, which cover records, not collections or deltas.Read the record itself, or get the collection once while online.
A value changed by itself after reconnectingA queued write was refused by a rule at replay, or another client wrote in between.Handle the failed sync event; show fromCache values as provisional.
A counter went up by twoThe engine is older than 1.7.0 and does not honour opId.nukebase update.
Another person's data showed briefly on a shared deviceThe previous session expired while the tab was closed, so no 4001 arrived.Call client.offline.clear() on your own sign-out path, or leave offline mode off for shared-device apps.
"would take the cache past N bytes" in the consoleOne top-level key is bigger than maxBytes.Raise maxBytes, or read narrower paths so less lands under one key.

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. The timer flush serializes only the top-level keys written since the last flush (see FLUSH_CACHE_MB) and hands the file writes to a background thread, so it no longer stalls clients; durable writes, backups and shutdown still flush synchronously so their on-disk guarantee is unchanged. 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 (SPLIT_THRESHOLD_MB) 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.

Only keys that are safe folder names get their own folder. A split child is stored in a directory named after its key, so a key is split out only if it is 200 bytes or shorter, contains none of / : * ? " < > | or control characters, is not . or .., does not end in a dot or space, is not a Windows device name or data.json, and does not differ only by case from a sibling already split. Any other key — a URL, a date like 2026/09/29, a very long key — simply stays inside its parent's file. Nothing about reading or writing it changes. (Since engine 1.5.1. Data an older engine split under such a name is folded back into its parent at startup; a split key that would point outside its own folder, such as .., stops startup like a missing shard, see Bad-disk recovery below.)

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

Once per calendar day (UTC) the server flushes pending writes and copies server/data/ to server/backup/<YYYY-MM-DD>/. It checks hourly whether today's copy exists, starting a minute after boot, so a server that restarts often still gets one. The copy is written under a temporary name and renamed into place, so a crash mid-copy never leaves a half-written directory posing as a finished backup.

Each backup is a full copy of the database, so they are pruned: after every successful backup, dated directories older than BACKUP_KEEP_DAYS (default 7) are removed. Only directories named like a date are ever touched — anything else you keep in server/backup/ is left alone. Set BACKUP_KEEP_DAYS=0 to keep every backup.

Bad-disk recovery: If a $split marker points at a missing or empty subdirectory 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). A data.json that fails to parse is always fatal — there is nothing safe to substitute for it.

"$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, /changepassword; /createuser ships disabled).
// 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; remove gives the space back. Book the DIFFERENCE —
// an upload over an existing file replaces it. See File Quotas.
const delta = (ctx) => ctx.file.size - (ctx.existing?.size ?? 0);
addFileTrigger("reserve", ["uploads"], (ctx) => {
  increment(["usage", ctx.path[1], "bytes"], delta(ctx));
});
addFileTrigger("release", ["uploads"], (ctx) => {
  increment(["usage", ctx.path[1], "bytes"], -delta(ctx));
});
addFileTrigger("remove", ["uploads"], (ctx) => {
  increment(["usage", ctx.path[1], "bytes"], -(ctx.existing?.size ?? 0));
});

// Track user connections
addConnectionTrigger("open", function(admin, sessionId) {
  // Anonymous visitors connect too, and have no uid to key on
  if (!admin.uid) return;
  // 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) {
  if (!admin.uid) return;
  // 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
onUpdate(fn)A newer build of a file this page loaded has been cached by the service worker — see the page cache. Returns a function that stops listening
close()Close the socket and stop reconnecting, for good. For tearing a client down
Offline mode — see Offline Mode. Present whether or not it is on
offline.enabledtrue when createClient was given { offline: { enabled: true } }
offline.status(){ enabled, online, coveredPaths, queued, lastSyncAt, clientId }
offline.onSync(fn)queued / sent / failed / drained events for the write queue. Returns a function that stops listening
offline.flush()Replay queued writes now, if online
offline.save()Write the cache to IndexedDB now
offline.clear()Empty the cache and the queue

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, onConnectionChange,
          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: createClient() resolves once connected, or with an offline client if the first attempt fails (it keeps trying)
  • Automatic Reconnection: Retries after about half a second, then backs off to 30 seconds, with a little randomness so every open tab doesn't reconnect at once
  • Subscription Restoration: Restores all active subscriptions after reconnect, including ones made while offline
  • Dead-connection detection: A heartbeat notices a connection that looks open but has stopped working (a phone switching networks) within about 15 seconds
  • Tab and network recovery: Reconnects straight away when the tab becomes visible or the device gets its connection back
  • Opens offline: createClient() registers the service worker, whose page cache opens the app instantly from the last copy seen and opens it at all with no signal. Data still needs the socket — client.online says whether it is there — unless the app opts in to offline mode, which answers reads from a local cache and queues writes to replay
  • 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').

Options. A second argument (or a first, when there is no URL) takes { pageCache: false } to turn off the service worker's page cache for this app. It is on by default: createClient() registers /sw.js on every page. { offline: { enabled: true } } turns on offline mode for data — cached reads, cached first payloads for subscriptions, and a replayed queue for writes made offline. That one is off by default.

Requests never hang on a lost connection. A request made while disconnected resolves { status: "Offline" } at once, and requests still waiting when the connection drops resolve the same way. Offline means the SDK doesn't know whether the server received the request: a write may or may not have happened, so check with a read or a subscription once back online.

Requests time out after 30 seconds when the connection is up but the server doesn't answer. The promise then 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

client.online is true while the client is connected and hearing from the server. client.onConnectionChange(fn) calls fn(true) or fn(false) on every change, and once straight away with the current state. It returns a function that stops listening.

An offline banner, and a button that needs the server
const client = await createClient();

client.onConnectionChange((online) => {
  document.querySelector("#offline-banner").hidden = online;
  document.querySelector("#book").disabled = !online;
});

document.querySelector("#book").onclick = async () => {
  const r = await client.callableFunction("bookSlot", { time: "3pm" });
  if (r.status === "Offline") showMessage("You need a connection to book");
};

Read client.online from the client, don't destructure it. const { online } = client copies the value once and never changes. onConnectionChange is fine to destructure.

online is for the screen, not a guarantee. The connection can drop between checking it and sending. The status the request resolves with (Success, Failed or Offline) is what actually happened.

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 10,000 messages per second per connection (WS_RATE_LIMIT_MAX) 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 validation — checkAuth reads the uid/token cookies on every request and WebSocket upgrade, and populates admin.
  • POST /logout — clears the cookies, revokes the current session token, and closes any WebSocket connections opened with it.
  • 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 Mailgun API key, sending domain 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 — the username must not already be taken, and the password must be 8–128 characters). Requires the auth extension.
  • POST /createuser — currently 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 (email only; body { email }). The server emails a magic-link sign-in; the account is created when the link is clicked, which is what proves the address belongs to the person signing up. 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, /magic-signup and /auth/email (each extension keeps its own budget). 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; each extension sweeps its own rate-limit map every five minutes. 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. The username must not belong to any other account (the call answers "That username is taken"), since password login finds a user by username. 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 Mailgun account, your own sending domain, your own sender address.

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 a Mailgun account. 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,
  mailgunKey: "key-your-mailgun-api-key",
  mailgunDomain: "mg.yourdomain.com",
  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)
  brandName: "Acme",                // optional, email subject and heading (default "Sign in")
  brandColor: "#4361ee",            // optional, button colour
  fromName: "Acme",                 // optional, display name on From (default brandName)
  supportEmail: "help@acme.com"     // optional, shown in the email footer
});

Without this extension mounted, the /magic-link, /magic-signup, and /magiclink endpoints will not exist. The extension requires mailgunKey, mailgunDomain, 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 is lowercased and must be an ordinary email address — up to 254 characters, local part up to 64, no quotes or whitespace; + tags, hyphenated domains and long TLDs are all accepted. 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 sends a sign-in link and creates the account only when that link is clicked — so nobody can register an address they don't control. 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({ email: "matt@example.com" })
});
const result = await r.json();
// result.status === "Success", result.message tells the user to check email
// A taken address answers status "Failed", "An account with that email already exists"
// (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, /magic-signup 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'"
  }
};

Claim changes reach open connections. A connected user's claims are re-read every time their session is checked: on every message they send, and every 30 seconds for a connection that only listens. So removing a role takes effect on their next request, and within 30 seconds for their live subscriptions — no reconnect needed. To cut someone off immediately (a ban, a forced sign-out), call disconnectUser(uid) from app.js; revoking their session tokens alone is picked up within the same 30 seconds. 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; the extensions sweep their in-memory rate-limit maps every five minutes. 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.
"Offline"Client SDK only, never from the server: the socket is not open and the request was not sent — or it was in flight when the socket died. Resolves at once rather than hanging.
"Queued"Client SDK only, with offline mode on: the write was applied locally and stored to replay in order when the connection is back. Carries opId. A reply with fromCache: true (any status) was answered from the local cache; cachedAt says when that copy was fetched.

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 WS_RATE_LIMIT_MAX (default 10,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; the write was rolled back and not applied". The in-memory state is restored to what it was before the write, subscribers are not notified and triggers do not run, so the failure is safe to retry. 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>
↑