Operations

Security

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

Table of Contents

Sirannon gives you secure primitives and a server that starts closed. It accepts no SQL from the network, serves only the reads and writes you registered, and runs your authenticate hook before every database route. 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 accepts no statement from the network until you set acceptSql: true. By default it serves registered operations, so a caller reaches only the statements you named and the arguments 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-amara', { userId: 'u_amara' }],
  ['Bearer session-lukas', { userId: 'u_lukas' }],
])
 
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-amara', { userId: 'u_amara' }],
  ['Bearer session-lukas', { userId: 'u_lukas' }],
])
 
const TICKETS = new Map<string, Identity>([
  ['ticket-amara', { userId: 'u_amara' }],
  ['ticket-lukas', { userId: 'u_lukas' }],
])
 
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 === '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.

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