Microtoll Engine pre-1.0 · the locks, pre-built

@microtoll/blind-store — design for decision (M4)

Status: decided, 2026-09-25 (D-33 to D-36 in DECISIONS.md, all four as recommended). This document is the design the package follows; the README describes what was built.

1. What the package is, in one paragraph

The server the other three packages talk to. It holds only what it cannot read: sealed blobs, pointer rows that name no object, hashed capabilities and hashed link tokens. It authenticates a connection by a signed challenge, authorises writes by capability secrets rather than identity, indexes objects by a coarse public selector the app chooses, pushes live changes by that same selector, sweeps what has expired, and never logs a message body. It is a library with a thin reference server around it (D-23): a host app mounts the library and registers its own handlers beside it; the example app runs the reference server as it is.

2. Decision D-33: the collection model (the server half of D-13/D-30)

An object is a sealed record in a named collection, filed under a coarse public selector and, where the collection has one, a date window: the server half of the model D-30 set for the client side.

2.1 What an object row holds

ColumnNotes
id UUID PKclient-generated random UUID (the row id is inside what the owner signs)
collection TEXTwhich kind of object; validated by the handler against the configured collections; one accepted low-cardinality plaintext
selector TEXTthe coarse public selector: fixed length per collection, set by the app; characters A–Z a–z 0–9 . _ : -
window_start DATE, window_end DATEthe date window, both set or both null (a collection without a window)
sealed_content BYTEAsealed under K_object
sealed_detail BYTEAthe second tier, nullable
key_epoch INT ≥ 1bumped by the server on rotation
admin_capability_hash BYTEA(32)replaced on every rotation, so a co-owner removed by one cannot carry on with the secret they were given
read_capability_hash BYTEA(32)derived from K_object by the client
roster_members_only BOOLEANthe roster needs a row capability, not the read capability
status TEXT'active'; the engine never sets anything else — a host that needs to hide an object (a moderation suspension, say) sets another value from its own code, and every engine read path then treats it as not found and every admin action refuses it

object_members holds one row per member (sealed_row, sealed_object_key, row_capability_hash, key_epoch, status IN ('active','removed_by_admin','left')). **No identity column and no foreign key to users.** A host adds its own columns in its own init file, as it may for users.

2.2 The query, and the cover-traffic obligation

query-events (the protocol's message name, D-27/D-30) takes:

{ collection, selectors: [ ... ] | all: true, windowStart?, windowEnd? }

2.3 Live watches (D-14: kept)

watch-events / unwatch-events take the same query shape and are validated by the same parser; a change to an object or a member row is routed to every connection whose selector covers where it is now or where it just was, re-reading the row before sending (the notification payload carries the selector, never content). A member-row change is pushed as a content-free participation wake-up, debounced 250 ms. watch-imminent carries no parameters: a collection configured with imminentDays: n pushes every change to an object whose window falls within the next n days, whatever its selector — "tonight's plans changed while I was looking elsewhere", with the server owning the window so there is no parameter to grow a membership graph through.

2.4 The wire

2.5 Table names

users, unlock_methods, pointers, objects, object_members, share_links, mailbox_drops, rate_limit_counters. A host extends users by ALTER TABLE in its own later init file.

3. Decision D-34: the bound handshake, server half (D-29), and the transport limits

3.1 The verifier

message   = UTF-8("<ns>/auth/v2") ‖ 0x00 ‖ SHA-256(UTF-8(origin)) ‖ nonce   (nonce: 32 random bytes per connection)
verify    = Ed25519(routingPublicKey, message, signature)   (Node's crypto.verify; RFC 8037 JWK import)

3.2 Transport limits

LimitDefaultFails
frame size4 MiB (ws maxPayload)that socket, 1009
pre-authentication timeout120 sthat socket, 1008
messages per sockettoken bucket: 300, refilled 60/sthat socket, 1008
open sockets2,000the new connection, 503 at upgrade
Originmust be in allowedOrigins; no header allowed403 at upgrade
lookup-unlock-method per socket3 (D-20)rate-limited
per-field ciphertext capsthe table in src/limits.js (identity blob 2 MiB, content 512 KiB, detail 256 KiB, row 64 KiB, pointer 256 KiB, link and mailbox payloads 256 KiB; nested: sealed object key 4 KiB, wrapped root key 1 KiB, label 4 KiB, credential id 1,023 B, salts 256 B)invalid, with the field named
share linksmaxUses ≤ 200, expiry required and ≤ 400 daysinvalid
query answermaxQueryRows 5,000 (§2.2)too-many
daily counterscreate-event 20, create-url-invite 20, send-invite 50, per routing key per day; fail open (a counter outage never refuses a real action); the one place a routing key is written beside an actionrate-limited

All configurable in createBlindStore({ transport, limits, rateLimits }); the defaults, each justified, are in src/limits.js. Every limit is a DoS backstop, not a product rule, and the README says so.

4. Decision D-35: schema rules as tests, the sweep, the database role

4.1 The rules, checked against a live database

A test reads information_schema for the engine's tables and fails on:

The same test is what a host runs against its own extended database.

4.2 The sweep (D-19)

blind_store_sweep() empties the payload of every share link used up or past its expiry, deletes a link a week after its expiry, deletes a mailbox drop once past its expiry, and deletes rate counters older than two days. The library runs it at start and (sweep: { intervalMs }, false to leave it to the host) and logs a count only. Objects past their window are not swept — that is the app's decision; the threat model says what a database copy therefore holds.

4.3 The database role

The server never connects as the schema's owner or a superuser, so a compromised server can touch the engine's tables and nothing else. The schema creates blind_store_app (NOLOGIN NOSUPERUSER NOCREATEROLE NOCREATEDB NOREPLICATION) with exactly the table rights the handlers use and EXECUTE on the sweep; the schema is owned by the deployment's owner role. The reference server and the tests connect as blind_store_app, so a query the role may not run fails in the suite. The login and password are given at deployment, never in the schema file: the Compose kit sets them from a secret file at first start.

5. Decision D-36: the library, the reference server, the deployment kit, the example

5.1 The library

import { createBlindStore } from '@microtoll/blind-store';
const store = createBlindStore({
  namespace: 'myapp',                       // required: the label prefix the handshake verifies
  pool,                                     // a pg Pool connected as blind_store_app
  port: 8020,                               // listens at creation
  allowedOrigins: ['https://app.example'],
  collections: { notes: { selectorLength: 2 } },     // window: false; or events: { selectorLength: 5, window: true, allowAll: true, imminentDays: 2, queryExtras }
  registration: { columns, onRegister, authOkFields },  // optional host policy (D-17)
  live: { connectionConfig, extraChannels },            // optional: LISTEN for the live watches
  sweep: { intervalMs: 3600_000 },
  transport, limits, rateLimits, httpRoutes, onAuthenticated, onSocketClose, onDeleteAccount, log,
});
store.handle('my-type', handler, { auth: 'required' | 'none' | 'any', needsPool });
store.setFallback(async (ctx) => false);
store.close(cb);

createCore is exported as an alias of createBlindStore. The wire helpers a host's own handlers share with the engine's (send, the base64url codecs, blobField, hashField, hashSecret, expiryField, the limits, rateLimit, insertPointer, parsePointerField) are exported as they are.

Dependencies: **ws and pg, pinned exactly**, nothing else. pg-listen is dropped: the LISTEN connection is a plain pg client with a reconnect loop (about forty lines), and a live hub that cannot subscribe logs loudly at start and on every retry, because silent live-update failure is worse than a crash. Ed25519, SHA-256 and random bytes come from node:crypto. Node 24 or later.

5.2 The reference server

packages/blind-store/bin/blind-store.mjs: reads BLIND_STORE_NAMESPACE, BLIND_STORE_PORT, BLIND_STORE_ALLOWED_ORIGINS, BLIND_STORE_COLLECTIONS (JSON), DB_HOST/PORT/USER/NAME and DB_PASSWORD_FILE (a file, never the environment), creates the pool and the live hub, starts the sweep, serves /healthz (a real SELECT 1; 200 or 503, nothing else) and stops on SIGTERM. Logs carry counts and reasons, never a message body or a routing key.

5.3 The deployment kit (deploy/)

5.4 Tests

  1. Without Postgres (always run): the registry and dispatcher rules, the pre-authentication surface, malformed and oversize frames, the token bucket, the pre-authentication timeout, origin refusal, every field cap, the handshake cross-implementation test (§3.1) — real sockets on localhost, pool: null.
  2. "The server cannot decrypt" (always run): the package's runtime dependencies are exactly ws and pg; no @microtoll/* package is imported at runtime; a scan of src/ finds no decrypt, decipher, unwrap, derive-key, HKDF, AES or private-key identifier; and, with Postgres, every sealed fixture from crypto-core/test/fixtures/frozen-v1.json stored through the server reads back byte-identical.
  3. With Postgres (BLIND_STORE_TEST_DB set, or the throwaway container scripts/test-db.mjs up starts; CI runs a postgres:17 service): the whole protocol driven by the real client packages — @microtoll/identity and @microtoll/access as devDependencies — covering every core message: register, lookup and unlock, the blob's compare-and-swap, methods, rotation of the recovery code, sign out everywhere, objects, members, pointers, epoch guards, rotation completeness, links made, counted, redeemed, exhausted, revoked, the mailbox, live watches, the daily caps, deletion, the sweep.
  4. Schema conformance (§4.1) and role conformance (the tests run as blind_store_app).
  5. The example's integration test: the notes app's own client module driven in Node against the running server.

5.5 The example: examples/notes-app

End-to-end-encrypted notes with sharing and revocation, small enough to read in ten minutes, on the reference server unchanged:

6. What stays out (D-14, confirmed)

Reporting and moderation, the public layer, operator disclosure keys, the repeat grant, live signals (the transaction-local blind_store.quiet_push flag a host's trigger may read is kept, one line), Web Push, and terms and age-declaration columns (the registration hook replaces them).

7. Threat model §6, to be completed with the package

The accepted trades, each listed: the collection, selector and window per object; which selectors and windows a connection queried and watched; one object id per fetch-event and per link redemption; routing key × action × day in the rate counters; pointer counts and unlock-method counts per account; the Origin and the request sizes and timing; what a database copy holds until the sweep runs. The database role model; the client obligations for cover traffic; availability limits.