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

```ts
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` — your sessions

```ts
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.

```ts
authenticate: async (req) => {
  const claims = await verifyJwt(req.headers.authorization);
  if (!claims) return null;
  return { memberId: claims.sub, deviceId: claims.did };
};
```

## `Directory` — your users

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

```ts
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.

```ts
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:

```ts
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.

## `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.

```ts
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`.

## `onEvent` — metrics and audit

Every event is safe to log. None carries ciphertext, record IDs, or key
material.

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

## Blob storage

```ts
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.

## Quotas and rate limits

```ts
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:
>
> ```ts
> 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.

## Migrations

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.

## The dev server

```bash
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.

---
