Operations

Security

Understand the built-in protections, then put the right boundary around the server before anything untrusted can reach it.

Sirannon gives you secure primitives and a server that refuses SQL from the network until you turn it on. That refusal covers the statement routes and their WebSocket messages, so the registered operations, the subscriptions, the snapshots, and the backup routes stay open behind guards of their own. Device sync stays off until you turn it on as well. Your authenticate hook runs before every database route and before every WebSocket upgrade. This page covers what you get for free, what you still have to add yourself, and how to build the boundary. It builds two files, server.ts and client.ts, step by step.

Built-in protections

  • The server refuses POST /db/:id/query, /execute, /transaction, /batch, and /load with 403 SQL_NOT_ACCEPTED until you set acceptSql: true, and it refuses the five WebSocket messages of those names in the same way. A caller reaches your registered operations for statements, so the only SQL that runs is the SQL that you named, with the arguments that you declared.
  • The WebSocket handshake selects the plain sirannon.v1 subprotocol, so a credential a browser offers there never appears in the response. A refused upgrade closes with code 4401 or 4403, which the client reads as a refusal and stops retrying.
  • An operation can fill an argument from the authenticated identity through fromIdentity, and the server refuses a caller that supplies such an argument with ARGUMENT_NOT_ALLOWED. Ownership rules therefore stay on the server, where no request argument can reach them.
  • The driver layer keeps parameterised values separate from the SQL text. Keep user input in params and never concatenate it into SQL strings.
  • Sirannon validates every CDC table and column name against a strict allowlist regex and escapes it, so change tracking never becomes an injection path.
  • Migration and backup paths reject null bytes, .. segments, and control characters before filesystem access.
  • The server caps HTTP request bodies and WebSocket messages at 1 MB by default, and maxBodyBytes raises or lowers that single limit across both transports.
  • Remote errors return a machine-readable code and message with stack traces and internal details stripped.
  • Read and write operations use separate connection pools, and read-only databases enforce immutability at the connection level.

Choose a deployment boundary

The default registry is already an application boundary. A registered write named placeOrder is a domain action, and a caller who has it cannot turn it into a different statement. Decide what goes in front of that.

  • Registered operations alone are enough for a browser or mobile client, as long as authenticate identifies the caller and every ownership rule comes from fromIdentity and never from a request argument.
  • A private network boundary suits trusted service-to-service traffic where only your own processes reach the Sirannon server.
  • A server-side application layer in front of Sirannon suits anything that needs work beyond a statement, such as calling a payment provider before it writes.

Setting acceptSql: true removes the first of those, because such a server runs whatever statement a caller sends. Keep it for trusted service code on a private network, never for a browser.

Sirannon writes the database file into a directory you create yourself, so make one before you run any of the code below.

mkdir -p data

Identify every caller

authenticate runs before every /db/:id request, including the WebSocket upgrade, and returns the caller's identity. GET /health, GET /health/ready, and GET /capabilities skip it. Start server.ts with one open database and a registry that scopes its only read to the caller who owns the rows.

server.ts
import { RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
 
interface Identity {
  userId: string
}
 
const SESSIONS = new Map<string, Identity>([
  ['Bearer session-lily', { userId: 'u_lily' }],
  ['Bearer session-tom', { userId: 'u_tom' }],
])
 
const verifySessionToken = async (header: string | undefined): Promise<Identity | undefined> =>
  header === undefined ? undefined : SESSIONS.get(header)
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
await sirannon.open('orders', './data/orders.db')
 
const server = createServer<Identity>(sirannon, {
  port: 9876,
  operations: {
    orders: {
      reads: {
        myOrders: {
          fromIdentity: { ownerId: 'userId' },
          columns: ['id', 'total', 'status'],
          statement: ({ ownerId }) => ({
            sql: 'SELECT id, total, status FROM orders WHERE owner_id = ? ORDER BY id',
            params: [ownerId],
          }),
        },
      },
    },
  },
  authenticate: async ({ headers }) => {
    const identity = await verifySessionToken(headers.authorization)
    if (!identity) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Invalid or missing token')
    return identity
  },
})
 
await server.listen()

The SESSIONS map stands in for whatever already issues sessions in your application. The hook may be async, so a database lookup or a remote token check fits the same shape.

Two rules decide whether that boundary holds:

  • Refuse by throwing RequestDeniedError(status, code, message), which the package root exports. Every value the hook returns becomes the identity, so returning { status: 401, code: 'UNAUTHORIZED', message: '...' } fails the request with HOOK_ERROR. The server reads that shape as a mistake precisely because accepting it would pass a refusal object to fromIdentity as though it were a user.
  • Read the owner from the identity, never from an argument. myOrders declares no args at all, so the only value reaching its WHERE clause is the userId your hook returned. A caller asking for someone else's orders has no field to ask with.

A matching client.ts sends the token through headers, so every HTTP route reaches the server already authenticated.

client.ts
import { operationRef } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
 
const myOrders = operationRef<Record<string, never>, { id: number; total: number; status: string }>('myOrders')
 
const client = new SirannonClient('https://db.example.com', {
  transport: 'http',
  headers: { Authorization: `Bearer ${process.env.SIRANNON_SESSION_TOKEN}` },
})
 
const orders = await client.database('orders').query(myOrders, {})

Authenticate WebSocket upgrades

A Node client attaches headers to the WebSocket upgrade as well as to its HTTP requests, so the hook above identifies a server-side caller on both transports. A browser attaches no header to a WebSocket, so that same hook turns every browser socket away. Widen it: read method and path to spot the upgrade, validate the Origin header against an explicit allowlist, and accept a short-lived ticket in Sec-WebSocket-Protocol, which a browser can send. Checking the header first on the same upgrade keeps Node clients working.

server.ts
import { RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
 
interface Identity {
  userId: string
}
 
const SESSIONS = new Map<string, Identity>([
  ['Bearer session-lily', { userId: 'u_lily' }],
  ['Bearer session-tom', { userId: 'u_tom' }],
])
 
const TICKETS = new Map<string, Identity>([
  ['ticket-lily', { userId: 'u_lily' }],
  ['ticket-tom', { userId: 'u_tom' }],
])
 
const verifyWebSocketTicket = async (offered: readonly string[]): Promise<Identity | undefined> => {
  for (const value of offered) {
    const identity = TICKETS.get(value)
    if (identity) return identity
  }
  return undefined
}
 
const verifySessionToken = async (header: string | undefined): Promise<Identity | undefined> =>
  header === undefined ? undefined : SESSIONS.get(header)
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
await sirannon.open('orders', './data/orders.db')
 
const server = createServer<Identity>(sirannon, {
  port: 9876,
  operations: {
    orders: {
      reads: {
        myOrders: {
          fromIdentity: { ownerId: 'userId' },
          columns: ['id', 'total', 'status'],
          statement: ({ ownerId }) => ({
            sql: 'SELECT id, total, status FROM orders WHERE owner_id = ? ORDER BY id',
            params: [ownerId],
          }),
        },
      },
    },
  },
  authenticate: async ({ headers }) => {
    const identity = await verifySessionToken(headers.authorization)
    if (!identity) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Invalid or missing token')
  authenticate: async ({ headers, method, path }) => {
    const isWebSocketUpgrade = method.toUpperCase() === 'GET' && path.startsWith('/db/')
    if (!isWebSocketUpgrade) {
      const identity = await verifySessionToken(headers.authorization)
      if (!identity) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Invalid or missing token')
      return identity
    }
 
    if (headers.origin !== 'https://app.example.com') {
      throw new RequestDeniedError(403, 'FORBIDDEN_ORIGIN', 'Forbidden origin')
    }
 
    const offered = (headers['sec-websocket-protocol'] ?? '').split(',').map((value) => value.trim())
    const identity = (await verifySessionToken(headers.authorization)) ?? (await verifyWebSocketTicket(offered))
    if (!identity) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Invalid WebSocket credentials')
    return identity
  },
})
 
await server.listen()

A ticket returns the same Identity a session token does, so a live query opened over the socket reads the same userId an HTTP read would. Whatever your application mints for this must be short-lived and URL-safe, because it goes in a header a browser can set and appears in the handshake.

The offered list holds one value your hook can ignore. The client offers the plain identifier sirannon.v1 ahead of your ticket, and the server selects that one, so a credential never comes back in the handshake response. Selecting a protocol matters because the WebSockets Standard fails a connection whose response acknowledges none of the subprotocols the client offered. The server refuses an upgrade that offers subprotocols without sirannon.v1, answering 400 UNSUPPORTED_SUBPROTOCOL. An upgrade that offers no subprotocol connects, which is how a hand-written client with its credential in a header reaches the socket.

A browser client passes the ticket through webSocketProtocols. The URL stays https://, because the client derives the WebSocket URL from it and rejects a ws:// or wss:// URL outright.

client.ts
import { operationRef } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
 
const myOrders = operationRef<Record<string, never>, { id: number; total: number; status: string }>('myOrders')
 
const client = new SirannonClient('https://db.example.com', {
  transport: 'http',
  headers: { Authorization: `Bearer ${process.env.SIRANNON_SESSION_TOKEN}` },
  transport: 'websocket',
  webSocketProtocols: [String(process.env.SIRANNON_WS_TICKET)],
})
 
const orders = await client.database('orders').query(myOrders, {})

A browser client that needs both passes headers alongside webSocketProtocols, because the headers reach every HTTP request while the ticket authenticates the socket. Passing headers alone on the WebSocket transport fails in a browser with INVALID_ARGUMENT, because that credential would never reach the server. The client reports it at construction, before the first refused connection.

Tell a refused connection from a dropped one

No WebSocket client can read the status of a refused handshake. A RequestDeniedError your hook throws with status 401 or 403 therefore closes the socket with an application close code the client can read: 4401 for a caller the server cannot identify, and 4403 for one it identifies but does not permit. The close reason states your own code and message, so the client raises UNAUTHORIZED or FORBIDDEN.

The client then leaves that connection closed, so an expired ticket surfaces as an authentication error once. Reconnecting on a credential the server already refused would drive a retry loop against your authentication path, and a stale browser tab would run that loop for as long as it stays open. Any other close code reports a network fault, so reconnection resumes as usual. Refuse an upgrade with status 401 or 403, because a refusal you raise with any other status keeps its HTTP status response, which no socket client can read.

Guard the cluster endpoint

GET /db/:id/cluster reports the address of every node in the replication group, the current primary, and the primary term. That is routing metadata for your operators and for the topology-aware client. authorizeClusterStatus gates it separately from authenticate, and a request it refuses receives 404, which reveals nothing about the endpoint.

const authorizeClusterStatus = ({ headers }: { headers: Record<string, string> }): boolean =>
  headers.authorization === `Bearer ${process.env.SIRANNON_OPERATOR_TOKEN}`

Pass that alongside getClusterStatus when you build the server. An error thrown inside the gate becomes HOOK_ERROR, so return false to refuse.

Keep the backup routes to operators

Sirannon serves seven routes under /db/:id/backup. Between them they start a backup, report how far the cycle has got, list the chain your destination holds, verify one stored backup, name the pieces you may delete, rebuild the database from a moment you choose, and report how that rebuild is going. All seven are operator work, and Sirannon defines no separate authoriser for them, so your authenticate hook is the only check in front of them.

That hook receives ctx.method and ctx.path on every request, so one hook can admit your application on the data routes and refuse it on these.

const isOperator = (headers: Record<string, string>): boolean =>
  headers.authorization === `Bearer ${process.env.SIRANNON_OPERATOR_TOKEN}`
 
const refuseBackupRoutes = ({ headers, path }: { headers: Record<string, string>; path: string }): void => {
  if (path.includes('/backup') && !isOperator(headers)) {
    throw new RequestDeniedError(403, 'HOOK_DENIED', 'Only an operator may reach the backup routes')
  }
}

Call that at the top of your authenticate hook, before it returns an identity. A hook without such a check admits every identity it accepts to all seven routes, so a user token would let its holder list every backup you hold and start a copy whenever they chose.

The restore route is stricter again. It stays shut until you set acceptBackupRestore: true, and a server that sets it without an authenticate hook refuses to start with INVALID_BACKUP_RESTORE. Sirannon refuses on purpose, since cross-origin rules govern what a page may read back from a response and a browser sends the request either way, which would leave a server with that route open and no hook in front of it rebuilding its database for whoever asked. The server guide covers what a network restore does to the database it replaces.

Guard the destination itself as well. A backup holds every row your database holds, so give the credentials your BackupDestination uses the treatment you give a database password. Scope them to the one bucket or prefix they write to, grant write access alone to anything that only writes, and rotate them on the schedule you already keep.

Guard subscriptions, snapshots, and device sync

One orders file holds the rows of both users, so myOrders is what scopes each caller to their own rows. A subscription and three more routes reach that same file, and each one takes a rule of its own. HookDeniedError, BeforeSubscribeHook, BeforeSnapshotHook, and BeforePushHook all come from the package root.

The subscriber chooses which table to watch and which filter to apply, so a subscription that carries no filter receives every change on that table. onBeforeSubscribe runs before the subscription attaches, once for each table, and it receives the database, the table, that filter, and the identity that your authenticate hook returned. The server applies the filter to every change before it sends one, so require a filter that names the caller. A server with acceptSql off serves a table subscription only once the registry has this hook, and until then it refuses every table subscription with SQL_NOT_ACCEPTED.

const onBeforeSubscribe: BeforeSubscribeHook = ({ filter, identity }) => {
  const caller = identity as Identity | undefined
  if (!caller || filter?.owner_id !== caller.userId) {
    throw new HookDeniedError('beforeSubscribe', 'Subscribe with a filter on your own orders')
  }
}

The browser passes that filter through the subscription builder. Where the hook refuses, the client receives an error message carrying HOOK_DENIED, and the socket stays open.

await client
  .database('orders')
  .on('orders')
  .filter({ owner_id: 'u_lily' })
  .subscribe((event) => {
    console.log(event.type, event.row)
  })

A snapshot copies whole tables: POST /db/:id/snapshot lists them and POST /db/:id/snapshot/page reads their rows. onBeforeSnapshot runs before both routes and receives the database, the table, and the identity. That hook decides one table at a time, so serve a snapshot only to a caller that you would let read every row in the tables it copies. No client of this server downloads a snapshot, so it refuses every one with 403 HOOK_DENIED.

const onBeforeSnapshot: BeforeSnapshotHook = () => {
  throw new HookDeniedError('beforeSnapshot', 'This database serves no snapshots')
}

POST /db/:id/changes applies a batch of device changes, so it inserts, updates, and deletes rows in any table outside Sirannon's own. Your registered writes do not cover that route. Until you set acceptDeviceSync: true, the server answers that route, and every other device sync route, with 403 DEVICE_SYNC_NOT_ACCEPTED. A server that sets the option without an authenticate hook refuses to start, with INVALID_DEVICE_SYNC.

Once you turn device sync on, you decide in onBeforePush which caller may push for which device. The server calls it once for each table in a batch, before it writes any row, with the database, the table, the deviceId that sent the batch, and the identity that your authenticate hook returned. When the hook throws, the server refuses the whole batch with 403 HOOK_DENIED. The hook below ties each device to the first account that pushes from it.

const deviceOwners = new Map<string, string>()
 
const onBeforePush: BeforePushHook = ({ deviceId, identity }) => {
  const caller = identity as Identity | undefined
  const owner = deviceOwners.get(deviceId) ?? caller?.userId
  if (!caller || owner !== caller.userId) {
    throw new HookDeniedError('beforePush', 'This device belongs to another account')
  }
  deviceOwners.set(deviceId, caller.userId)
}

Keep that record in your own database, because the map in this example empties whenever the process restarts. A device subscribes to whole tables with no filter, so the onBeforeSubscribe above refuses its pull. The hook receives the same deviceId on a device's subscription, which means that a server serving device sync can admit the owner of that device there.

Each batch also holds the timestamp that the conflict resolver compares, so a caller that dates a change ahead can overwrite the row that it names. When a batch holds a change stamped more than 300,000 milliseconds ahead of the server's clock, the server refuses it with 400 DEVICE_CLOCK_AHEAD, which means that a caller can date a change at most five minutes ahead.

Pass the three hooks to the registry, which runs them on every database that it opens.

const sirannon = new Sirannon({ driver, hooks: { onBeforeSubscribe, onBeforeSnapshot, onBeforePush } })

TLS, CORS, and the SQL escape hatch

The built-in server binds plain HTTP and WebSocket. For any traffic outside a trusted local network, terminate TLS upstream with a reverse proxy or platform edge, and give the client an https:// URL, which it uses for HTTP requests and turns into wss:// for the socket. Without TLS, credentials, arguments, and CDC payloads cross the network in cleartext.

The server disables CORS by default. Enable it only for browser clients that need direct HTTP access, and restrict origins to trusted domains; cors: true allows every origin, so keep it to local development. CORS does not protect WebSocket upgrades, so validate the Origin header in authenticate as the example above does.

Authentication identifies the caller; it does not make arbitrary SQL safe. A server running with acceptSql: true runs any statement an authenticated caller sends, including one that reads another tenant's rows. Keep that server on a private network, and let browsers and mobile apps reach registered operations only.

Checklist

  • Bind the server to 127.0.0.1 or a private interface unless a proxy enforces TLS and access control.
  • Use HTTPS and WSS for non-local traffic.
  • Leave acceptSql off, and register the reads and writes each client needs.
  • Authenticate every HTTP database route and every WebSocket upgrade, and refuse by throwing RequestDeniedError with status 401 or 403 so the client can read the refusal.
  • Give a browser socket a short-lived ticket through webSocketProtocols, and keep bearer headers for HTTP requests and Node clients.
  • Take every ownership and tenant identifier from fromIdentity, never from a caller-supplied argument.
  • Validate the WebSocket Origin against an explicit allowlist.
  • Gate GET /db/:id/cluster with authorizeClusterStatus.
  • Admit only an operator's identity to the seven /db/:id/backup routes, testing ctx.path inside authenticate.
  • Set acceptBackupRestore only where an operator has to rebuild a database over the network, and scope the destination's credentials to what they write.
  • Require a filter that names the caller in onBeforeSubscribe, because a subscription without one streams every change on the table.
  • Refuse both snapshot routes with onBeforeSnapshot unless the caller may read every row in the tables it copies.
  • Leave acceptDeviceSync off wherever you run no device sync, and where you do, tie each device to its owner in onBeforePush, because a pushed batch writes rows straight into your tables.
  • Keep user input in SQL parameters, never in interpolated strings.
  • Restrict CORS to known origins.
  • Redact authorisation headers, cookies, and WebSocket tickets from logs, and avoid logging full SQL when it can contain sensitive data.