Part 1 — the relay
registerRelay mounts fourteen routes on a Fastify instance you own. It is a
plugin, not a server: mount it inside the app you already have, under whatever
prefix you like.
import Fastify from 'fastify';import { openDb, registerRelay, SqliteDirectory, FileBlobStore } from '@skm/relay';
const db = openDb('data/relay.db');const app = Fastify();
await app.register( async (scope) => { await registerRelay(scope, { db, directory: myDirectory, authenticate: myAuth, blobStore: new FileBlobStore('data/blobs'), onPoke: (evt) => hub.toMembers(evt.memberIds, { type: 'skm.poke' }), onEvent: (evt) => metrics.count(`skm.${evt.type}`), quotas: { maxRecordsPerGraph: 100_000 }, }); }, { prefix: '/api/skm' },);authenticate — your sessions
Section titled “authenticate — your sessions”authenticate: (req) => Promise<SkmIdentity | null> | SkmIdentity | null;// SkmIdentity = { memberId: string; deviceId: string }Return null to reject with 401. Bearer token, session cookie, mTLS — skm does
not care.
The token must bind BOTH the member and the device it was issued to. A token naming only the member is a vulnerability, not a shortcut: it would let any of that member’s devices fetch another device’s wrapped keys. The relay checks the binding it is given; it cannot invent one.
A practical shape: when a device first registers, mint a device-scoped session and put the device id in the token claims.
authenticate: async (req) => { const claims = await verifyJwt(req.headers.authorization); if (!claims) return null; return { memberId: claims.sub, deviceId: claims.did };};Directory — your users
Section titled “Directory — your users”Three questions, all about your users, none about skm:
interface Directory { membersOfGroup(groupId: string): Promise<string[]>; isGroupMember(groupId: string, memberId: string): Promise<boolean>; isActiveMember(memberId: string): Promise<boolean>;}membersOfGroup drives rotation completeness — it is how the relay knows
which devices a new epoch must cover. Get it wrong and rotations either strand
people or include people who left.
class MyDirectory implements Directory { async membersOfGroup(groupId: string) { const rows = await sql`SELECT user_id FROM memberships WHERE team_id = ${groupId}`; return rows.map((r) => r.user_id); } async isGroupMember(groupId: string, memberId: string) { return (await this.membersOfGroup(groupId)).includes(memberId); } async isActiveMember(memberId: string) { const [u] = await sql`SELECT 1 FROM users WHERE id = ${memberId} AND deleted_at IS NULL`; return Boolean(u); }}SqliteDirectory is provided for tests, small deployments, and anything with
no other user table:
const directory = new SqliteDirectory(db);directory.addMember('sam');directory.joinGroup('acme', 'sam');Pairwise graphs are an existence oracle. Addressing
d:<memberId>answers 404 for a member who does not exist and 200 for one who does, so any authenticated member can test whether an id is real. In most apps members already know each other. In one where they do not,isActiveMemberis where to refuse.
onPoke — wiring realtime
Section titled “onPoke — wiring realtime”The relay has no socket layer. When a graph changes it calls onPoke, and the
poke carries no content by design — it says “pull”, never what changed.
onPoke: (evt) => { for (const memberId of evt.memberIds) { hub.send(memberId, { type: 'skm.poke', graphId: evt.graphIdFor(memberId) }); }};evt.graphIdFor(memberId) matters for pairwise graphs: the storage key is
d:alex:sam, but Sam must be told d:alex and Alex must be told d:sam.
onEvent — metrics and audit
Section titled “onEvent — metrics and audit”Every event is safe to log. None carries ciphertext, record ids, or key material.
type RelayEvent = | { type: 'device.register'; memberId; deviceId; fresh } | { type: 'device.revoke'; memberId; deviceId; graphsFlagged } | { type: 'graph.push'; memberId; storageKey; accepted; rejected; bytes } | { type: 'graph.pull'; memberId; storageKey; returned; filtered } | { type: 'graph.rotate'; memberId; storageKey; epoch; devices } | { type: 'graph.grant'; memberId; storageKey; epoch; devices } | { type: 'blob.put'; memberId; storageKey; size; policy } | { type: 'blob.forget'; storageKey; reason } | { type: 'quota.exceeded'; memberId; storageKey; what } | { type: 'ratelimit.hit'; memberId; bucket } | { type: 'auth.rejected'; reason };Alert on quota.exceeded and on a rising auth.rejected. Called
synchronously — keep it cheap, and it must never throw (the relay swallows it
if it does, but do not rely on that).
Blob storage
Section titled “Blob storage”new SqliteBlobStore(db, stmts); // default — bytes in the relay's own databasenew FileBlobStore('data/blobs'); // one file per blobImplement BlobStore for S3 or anything else. The bytes are already sealed
when they arrive, so the store is a dumb byte bucket by construction.
Quotas and rate limits
Section titled “Quotas and rate limits”const DEFAULT_QUOTAS = { maxRecordsPerGraph: 250_000, maxCiphertextBytesPerGraph: 512 * 1024 * 1024, // 512MB maxBlobBytes: 25 * 1024 * 1024, // 25MB maxBlobBytesPerGraph: 2 * 1024 * 1024 * 1024, // 2GB maxDevicesPerMember: 32, maxPushBytes: 8 * 1024 * 1024, // 8MB};Rate limits default to per-member token buckets: push and pull 600/min (burst 1200), blobs 120/min (burst 240), keys 300/min (burst 600).
MemoryRateLimiteris per process. Correct for one relay, wrong for four behind a load balancer.RateLimiteris an interface — back it with Redis before you scale out.
Migrations
Section titled “Migrations”Schema changes are append-only migrations under PRAGMA user_version. They run
on openDb. The relay refuses to start against a database written by a newer
build rather than guessing. Never edit a migration that has shipped.
The dev server
Section titled “The dev server”SKM_DEV_AUTH=1 npm -w @skm/relay run dev # a relay on :8787It refuses to start without the environment variable, because it authenticates nobody. It is for poking at the wire, not for anything else.