Skip to content

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: (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 };
};

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, isActiveMember is where to refuse.

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.

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

new SqliteBlobStore(db, stmts); // default — bytes in the relay's own database
new FileBlobStore('data/blobs'); // one file per blob

Implement BlobStore for S3 or anything else. The bytes are already sealed when they arrive, so the store is a dumb byte bucket by construction.

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

MemoryRateLimiter is per process. Correct for one relay, wrong for four behind a load balancer. RateLimiter is an interface — back it with Redis before you scale out.

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.

Terminal window
SKM_DEV_AUTH=1 npm -w @skm/relay run dev # a relay on :8787

It refuses to start without the environment variable, because it authenticates nobody. It is for poking at the wire, not for anything else.