Skip to content
rifleDB
Docs: Protocol

Protocol overview

rifleDB speaks two protocols on the same paths: a Socket.IO connection for realtime use (what the SDK uses) and a REST API for everything else (curl, server code, other languages). This page is a map; the binding definitions are in architecture.md §4, §7 and §8, and the shared types, signing-string builders and pure ops live in @rifledb/protocol (packages/protocol/src).

Paths and values

  • Paths are slash-separated segments of [A-Za-z0-9_~.-] (1–128 characters, not ./..), at most 16 deep and 512 characters. Top-level _ (system endpoints), system and users are reserved; segments starting with @ name actions.
  • Values are JSON: no non-finite numbers, no __proto__/constructor/prototype keys, depth ≤ 8, ≤ 64 KB serialized (folders may set a lower maxValueBytes). Fast folders take only primitives and arrays of ≤ 256 primitives, ≤ 2 KB.
  • Every stored value has a rev: one global, increasing sequence. Together with the server's epoch it identifies a version: ETags are "<epoch>:<rev>". The epoch changes on every server boot (INCR in Redis; a server with its own non-persistent in-memory store seeds it from the clock so it never repeats; architecture.md §3), so an ETag from before a restart no longer matches.

Realtime: Socket.IO at /v1/_ws

WebSocket transport only (long-polling is opt-in). Browsers must connect from an allowed origin (RIFLE_ALLOWED_ORIGINS); clients that send no Origin (Node) are admitted. Payloads are capped at 64 KB.

  1. In the handshake the client may send auth: {slot: <32 random bytes, base64url>} (Socket.IO's auth option): a secret it keeps for its lifetime. One address may hold 64 connections at once; further handshakes are refused.
  2. The server greets with hello {v: 1, epoch, nonce, serverTime, origin, limits, slot}. slot is 'p_' + b64url(sha256('rifle/slot/v1\n' + secret)).slice(0, 22) (random without a secret): the key segment this connection may write in a publicWrite: 'own' folder. The same secret gives the same slot on every connection, and nobody who only sees the slot can claim it.
  3. The client signs rifle/ws-auth/v1\n<origin>\n<nonce>\n<serverTime>\n<kid> with each device or folder key and sends auth {proofs: [{kid, pub, sig}], register?}. Anonymous sessions skip this and can read public data and call public actions. The SDK re-authenticates after every reconnect.
  4. Every client message is acknowledged: {ok: true, …} or {ok: false, code, msg, retryAfter?}.
Client → serverWhat it does
sub / unsub {path, prefix?, since?}Subscribe to a key or a prefix (never across into a nested folder); the ack has the snapshot, or with since: {epoch, rev} only the changes after it
get {path, prefix?}One-off read
op {id, path, op, args?, ifRev?}One op (set, del, inc, toggle, push, unshift, removeAt, removeWhere, merge, strAppend, strPrepend); ifRev makes it conditional (E_CONFLICT). Ack {rev, v?}; on fast folders also f (the folder)
batch {id, ops}Several ops in one folder, all or nothing
act {id, folder, name, args}Call a server action
pub {path, name, data}An ephemeral event (needs write access)
ondc {path, op, args?}An op the server runs when this socket disconnects (presence cleanup), after everything the socket sent before has finished, in registration order within a folder. A set/del only replaces what this socket wrote last, with an op, a batch or an action it ran: a key written after that (e.g. by the same client over a newer connection or over REST, or by server code) is left alone
share.grants / share.grant.add / share.grant.remove {folder, principal?, perms?}List, set or remove a folder's grants (callers who manage the folder) → {grants}
share.invite.create {folder, perms, maxUses, ttlMs, baseUrl?} / share.invite.revoke {tokenHash}Claim invites → {link, fragment, exp, tokenHash} / {revoked}
share.key.register {folder, pub, perms, label, expiresAt?} / share.key.revoke {kid}Folder keys (the private half stays with the client) → {kid, folder, perms, label} / {kid, revokedAt}
stepup.*, passkey.reg.*, passkey.list / passkey.remove, recovery.*, admin.bootstrap, device.link.*Passkeys, step-up, account recovery, admin bootstrap, device linking (Access control)
Server → client
chg {f, e: [[path, value, rev, flags]]}Changes of one folder f (flags: 1 = deleted, 2 = fast folder)
evt {folder, name, data}An event emitted by an action or pub
access {path, code}A subscription ended (revoked key, expired elevation, …)
elev {until}Elevation started/extended (until in ms) or ended (0)

Op ids make retries safe: a replayed id returns the original result (kept 5 minutes). Fast folders coalesce updates per path at hz per second (default 30) and send them as volatile messages, but deletes and each key's settled final value are always delivered.

sub acks carry seq: the server's sequence (highest rev assigned) when the snapshot was read. Every change with a rev at or below it is already in entries/dels, so a chg entry with rev <= seq arriving later is dropped — also for keys absent from the snapshot.

E_RATE acks carry retryAfter (ms) and, when the server knows it, scope: which limit refused.

scopeLimitWhat the SDK does
connthe connection's message bucket (200/s); refusal is sticky (≥ 250 ms)keeps the request in place, pauses every request, halves window
ipthe address's message bucket (2000/s), sticky like connsame
publicanonymous publicWrite buckets: 30/s per session, 200/s per address (fast folders: 1000/s per address)drops a fast-folder write a newer one of the same key supersedes; otherwise pauses only that folder's requests, backing off
actionthe action's own raterejects to the caller
folderthe folder's limits.opsPerSec (all writers, every transport, actions)rejects to the caller

REST: /v1/…

RequestEffect
GET /v1/<path>{v, r} with ETag; If-None-Match → 304
GET /v1/<path>?prefix=1{folder, epoch, entries: [[rel, v, rev]…]}
GET /v1/<path>?stream=1[&prefix=1]Server-sent events: snapshot, then chg (same tuples as the socket), : ping every 15 s, access when access ends
PUT /v1/<path>set with the raw JSON body (Content-Type: application/json)
PATCH /v1/<path>application/rifle-op+json: {op, args?} or an array (one atomic batch); or application/merge-patch+json (RFC 7396)
POST /v1/<path>push with {item, max?}
POST /v1/<folder>/@<action>Call an action; body = its arguments (empty = {}) → {result}
DELETE /v1/<path>del
GET /v1/_/health, GET /v1/_/time{ok, epoch, store, healthy, uptimeS, version}, {serverTime}
POST /v1/_/stepup/{options,verify}, /v1/_/passkey/…Passkeys over REST; a step-up returns an elevationToken for the Rifle-Elevation header
POST /v1/_/invites/claim, /v1/_/device-link/{create,claim}Invites and device linking
GET /v1/_/share/grants?folder=…, POST /v1/_/share/{grant/add,grant/remove,invite/create,invite/revoke,key/register,key/revoke}Sharing, as the socket's share.* messages (signed, by someone who manages the folder)

Preconditions: If-Match: "<epoch>:<rev>" writes only if the key is still at that version (412 otherwise); If-None-Match: * only if it does not exist. Validation and access checks run before anything is written: a refused request leaves no trace.

Signed requests

Requests that need an identity carry:

Rifle-Timestamp: <ms since epoch>
Rifle-Nonce: <≥16 random bytes, base64url>
Content-Digest: sha-256=:<base64 of sha256(body)>:      (with a body; RFC 9530)
Rifle-Signature: <kid>=<base64url Ed25519 signature>[, …] (up to 4 keys)
Rifle-Elevation: <token>                                 (optional, after a REST step-up)

over the string

rifle/http/v1\n<METHOD>\n<host>\n<path and query exactly as sent>\n<timestamp>\n<nonce>\n<Content-Digest or ->\n<sha256(elevation token) base64url or ->\n<kid>

The server checks, in this order: timestamp within ±60 s (401), nonce unused (409 E_REPLAY; nonces are kept 3 minutes), digest matches the raw body (422), Host is one of RIFLE_PUBLIC_HOSTS (403), signatures (401). The SDK's HTTP client and createRifleServer do all of this; e2e/tests/support.ts and server/smoke.ts show it in about 30 lines with WebCrypto.

Errors

RFC 9457 application/problem+json: {type, title, status, detail, code, retryAfter?, scope?} (scope on E_RATE as on the socket, when known), with Retry-After on 429 and WWW-Authenticate: Rifle-Signature on 401.

CodeHTTPMeaning
E_INVALID422Bad path, value, op or arguments
E_AUTH_REQUIRED401Needs a (valid) signature
E_ELEVATION_REQUIRED401Needs a passkey step-up
E_FORBIDDEN403Signed in, but not allowed
E_AUTHORITY403A server-only path
E_NOT_FOUND404No such key, folder or action
E_CONFLICT412If-Match / ifRev did not match
E_REPLAY409Nonce used before
E_FULL409Action-specific (the demo's full island)
E_LIMIT413Too large, too many keys, over a user's storage budget
E_RATE429Rate limit; retryAfter says when to try again
E_UNAVAILABLE503Storage unreachable (writes to normal folders are refused)

Other codes an action raises with abort answer 422; unexpected server errors 500 E_INTERNAL without details.

CORS: only RIFLE_ALLOWED_ORIGINS, ETag exposed, Rifle-* and Content-Digest headers allowed, never credentials (there are no cookies anywhere, so there is nothing to forge cross-site).