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 '@argala/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: 'argala.poke' }),
onEvent: (evt) => metrics.count(`argala.${evt.type}`),
quotas: { maxRecordsPerGraph: 100_000 },
});
},
{ prefix: '/api/argala' },
);
authenticate: (req) => Promise<ArgalaIdentity | null> | ArgalaIdentity | null;
// ArgalaIdentity = { memberId: string; deviceId: string }

Return null to reject with 401. The authentication mechanism can be a bearer token, session cookie, or mTLS; argala does not impose one.

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 approach is to mint a device-scoped session when a device first registers and include 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 };
};

The directory answers three questions about your users and none about argala:

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: 'argala.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 a rising rate of auth.rejected events. The callback runs synchronously, so keep it cheap. It must never throw (the relay swallows it if it does, but do not rely on that behaviour).

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, // 512 MB
maxBlobBytes: 25 * 1024 * 1024, // 25 MB
maxBlobBytesPerGraph: 2 * 1024 * 1024 * 1024, // 2 GB
maxDevicesPerMember: 32,
maxPushBytes: 8 * 1024 * 1024, // 8 MB
};

Rate limits default to per-member token buckets: push and pull at 600/minute (burst 1,200), blobs at 120/minute (burst 240), and keys at 300/minute (burst 600).

MemoryRateLimiter is per process. It is correct for one relay but wrong for four behind a load balancer: four relays mean four allowances. Use SharedRateLimiter once you run more than one, backed by any counter you can increment and read:

import { SharedRateLimiter, type CounterStore } from '@argala/relay';
const redisCounter: CounterStore = {
async increment(key, by, ttlMs) {
const n = await redis.incrBy(key, by);
await redis.pExpire(key, ttlMs);
return n;
},
async read(key) {
return Number((await redis.get(key)) ?? 0);
},
};
rateLimits: {
push: new SharedRateLimiter(redisCounter, { perMinute: 600 });
}

argala does not depend on a Redis client — it is arithmetic, and a library holding other people’s keys does not get to add a dependency for arithmetic.

Schema changes are append-only migrations under PRAGMA user_version. They run when openDb is called. 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
ARGALA_DEV_AUTH=1 npm -w @argala/relay run dev # a relay on :8787

It refuses to start without the environment variable because it authenticates nobody. It is for experimenting with the wire protocol, not for deployment.