diff --git a/apps/keytrace.dev/components/ui/ChatMessageRow.vue b/apps/keytrace.dev/components/ui/ChatMessageRow.vue
new file mode 100644
index 0000000..e3f9e88
--- /dev/null
+++ b/apps/keytrace.dev/components/ui/ChatMessageRow.vue
@@ -0,0 +1,102 @@
+
+
+
+
+ {{ formatTime(message.timestamp) }}
+
+
+
+
+
+ {{ message.platform }}
+
+
+
+
+ {{ message.username }}
+
+
+
+
+ {{ message.text }}
+
+
+
+
+
+ saved
+
+
+
+
+
diff --git a/apps/keytrace.dev/components/ui/NavBar.vue b/apps/keytrace.dev/components/ui/NavBar.vue
index ba15aec..d4e1485 100644
--- a/apps/keytrace.dev/components/ui/NavBar.vue
+++ b/apps/keytrace.dev/components/ui/NavBar.vue
@@ -13,6 +13,10 @@
+
+
+ Relay
+
Add claim
@@ -39,7 +43,7 @@
diff --git a/apps/keytrace.dev/server/api/chat/messages.post.ts b/apps/keytrace.dev/server/api/chat/messages.post.ts
new file mode 100644
index 0000000..711d7ca
--- /dev/null
+++ b/apps/keytrace.dev/server/api/chat/messages.post.ts
@@ -0,0 +1,63 @@
+/**
+ * POST /api/chat/messages
+ *
+ * Receives ALL messages from the Matterbridge ingester.
+ * Broadcasts every message to connected SSE clients on /chat.
+ * If a message contains a DID, also saves it to the relay store.
+ *
+ * Body: { text: string, username: string, userid?: string, account: string, gateway?: string }
+ */
+import { extractDid, extractPlatform } from "@keytrace/relay";
+
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig();
+
+ // Same bearer token auth as relay/ingest
+ const token = config.relayIngestToken;
+ if (token) {
+ const auth = getHeader(event, "authorization");
+ if (auth !== `Bearer ${token}`) {
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
+ }
+ }
+
+ const body = await readBody(event);
+ const { text, username, userid, account, gateway } = body ?? {};
+
+ if (!text || typeof text !== "string") {
+ throw createError({ statusCode: 400, statusMessage: "Missing text" });
+ }
+ if (!username || typeof username !== "string") {
+ throw createError({ statusCode: 400, statusMessage: "Missing username" });
+ }
+ if (!account || typeof account !== "string") {
+ throw createError({ statusCode: 400, statusMessage: "Missing account" });
+ }
+
+ const platform = extractPlatform(account);
+ const did = extractDid(text);
+
+ let saved = false;
+ if (did) {
+ const store = getRelayStore();
+ const result = await store.put(platform, username, did, userid);
+ saved = !!result;
+ console.log(`[chat] DID saved: ${platform}/${userid ?? username} → ${did}`);
+ }
+
+ const msg: ChatMessage = {
+ id: crypto.randomUUID(),
+ text,
+ username,
+ userid: userid || undefined,
+ platform,
+ gateway,
+ timestamp: Date.now(),
+ did,
+ saved,
+ };
+
+ broadcastMessage(msg);
+
+ return { ok: true, id: msg.id, saved };
+});
diff --git a/apps/keytrace.dev/server/api/chat/stream.get.ts b/apps/keytrace.dev/server/api/chat/stream.get.ts
new file mode 100644
index 0000000..3a70eed
--- /dev/null
+++ b/apps/keytrace.dev/server/api/chat/stream.get.ts
@@ -0,0 +1,23 @@
+/**
+ * GET /api/chat/stream
+ *
+ * Public SSE endpoint that streams relay chat messages to browser clients.
+ * On connect, sends the recent message buffer so new visitors see context.
+ */
+export default defineEventHandler(async (event) => {
+ const eventStream = createEventStream(event);
+
+ // Send recent message buffer as initial burst
+ for (const msg of getRecentMessages()) {
+ await eventStream.push({ event: "message", data: JSON.stringify(msg) });
+ }
+
+ // Listen for new messages and forward to this client
+ const remove = addChatListener((msg) => {
+ eventStream.push({ event: "message", data: JSON.stringify(msg) });
+ });
+
+ eventStream.onClosed(() => remove());
+
+ return eventStream.send();
+});
diff --git a/apps/keytrace.dev/server/api/relay/[...path].get.ts b/apps/keytrace.dev/server/api/relay/[...path].get.ts
new file mode 100644
index 0000000..9e9ac9c
--- /dev/null
+++ b/apps/keytrace.dev/server/api/relay/[...path].get.ts
@@ -0,0 +1,39 @@
+/**
+ * GET /api/relay/:platform/:identifier
+ *
+ * Public endpoint for the runner to fetch verified DID messages from the relay store.
+ * The identifier can be a username or a platform-native userid (Signal UUID, etc.).
+ * Both work because the store writes under both keys.
+ */
+export default defineEventHandler(async (event) => {
+ const path = getRouterParam(event, "path");
+ if (!path) {
+ throw createError({ statusCode: 400, statusMessage: "Missing path" });
+ }
+
+ const parts = path.split("/");
+ if (parts.length !== 2) {
+ throw createError({ statusCode: 400, statusMessage: "Expected /api/relay/:platform/:username" });
+ }
+
+ const [platform, identifier] = parts;
+
+ if (!platform || !identifier) {
+ throw createError({ statusCode: 400, statusMessage: "Missing platform or identifier" });
+ }
+
+ const store = getRelayStore();
+ const msg = await store.get(platform, decodeURIComponent(identifier));
+
+ if (!msg) {
+ throw createError({ statusCode: 404, statusMessage: "No verification found" });
+ }
+
+ return {
+ did: msg.did,
+ username: msg.username,
+ userid: msg.userid,
+ platform: msg.platform,
+ timestamp: msg.timestamp,
+ };
+});
diff --git a/apps/keytrace.dev/server/api/relay/ingest.post.ts b/apps/keytrace.dev/server/api/relay/ingest.post.ts
new file mode 100644
index 0000000..7496782
--- /dev/null
+++ b/apps/keytrace.dev/server/api/relay/ingest.post.ts
@@ -0,0 +1,46 @@
+/**
+ * POST /api/relay/ingest
+ *
+ * Receives DID verification messages from the Matterbridge ingester.
+ * Protected by a bearer token (NUXT_RELAY_INGEST_TOKEN).
+ *
+ * Body: { platform: string, username: string, did: string, userid?: string }
+ */
+
+const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._:%-]+$/;
+const PLATFORM_PATTERN = /^[a-z]+$/;
+
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig();
+
+ // Token auth — required in production, optional in dev
+ const token = config.relayIngestToken;
+ if (token) {
+ const auth = getHeader(event, "authorization");
+ if (auth !== `Bearer ${token}`) {
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
+ }
+ }
+
+ const body = await readBody(event);
+ const { platform, username, did, userid } = body ?? {};
+
+ if (!platform || typeof platform !== "string" || !PLATFORM_PATTERN.test(platform)) {
+ throw createError({ statusCode: 400, statusMessage: "Invalid platform" });
+ }
+
+ if (!username || typeof username !== "string") {
+ throw createError({ statusCode: 400, statusMessage: "Invalid username" });
+ }
+
+ if (!did || typeof did !== "string" || !DID_PATTERN.test(did)) {
+ throw createError({ statusCode: 400, statusMessage: "Invalid DID" });
+ }
+
+ const store = getRelayStore();
+ await store.put(platform, username, did, userid);
+
+ console.log(`[relay] Ingested: ${platform}/${userid ?? username} → ${did}`);
+
+ return { ok: true, platform, username, userid, did };
+});
diff --git a/apps/keytrace.dev/server/utils/chat-broadcast.ts b/apps/keytrace.dev/server/utils/chat-broadcast.ts
new file mode 100644
index 0000000..b08f648
--- /dev/null
+++ b/apps/keytrace.dev/server/utils/chat-broadcast.ts
@@ -0,0 +1,39 @@
+export interface ChatMessage {
+ id: string;
+ text: string;
+ username: string;
+ /** Platform-native stable ID (Signal UUID, Telegram numeric ID, etc.) */
+ userid?: string;
+ platform: string;
+ gateway?: string;
+ timestamp: number;
+ did?: string;
+ saved?: boolean;
+}
+
+const MAX_BUFFER = 100;
+const messages: ChatMessage[] = [];
+
+type Listener = (msg: ChatMessage) => void;
+const listeners = new Set();
+
+export function broadcastMessage(msg: ChatMessage): void {
+ messages.push(msg);
+ if (messages.length > MAX_BUFFER) messages.shift();
+ for (const listener of listeners) {
+ listener(msg);
+ }
+}
+
+export function addChatListener(fn: Listener): () => void {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+}
+
+export function getRecentMessages(): ChatMessage[] {
+ return [...messages];
+}
+
+export function getChatListenerCount(): number {
+ return listeners.size;
+}
diff --git a/apps/keytrace.dev/server/utils/relay.ts b/apps/keytrace.dev/server/utils/relay.ts
new file mode 100644
index 0000000..1d14e3a
--- /dev/null
+++ b/apps/keytrace.dev/server/utils/relay.ts
@@ -0,0 +1,13 @@
+import { JsonStore } from "@keytrace/relay";
+import type { RelayStore } from "@keytrace/relay";
+
+let _store: RelayStore | null = null;
+
+/** Get the relay store singleton (JsonStore backed by S3/file storage, read-only from Nuxt's perspective) */
+export function getRelayStore(): RelayStore {
+ if (!_store) {
+ _store = new JsonStore(loadJson, saveJson, deleteJson);
+ console.log(`[relay] Store initialized (${useS3() ? "S3" : "file"})`);
+ }
+ return _store;
+}
diff --git a/apps/keytrace.dev/server/utils/storage.ts b/apps/keytrace.dev/server/utils/storage.ts
index 99389c9..359961b 100644
--- a/apps/keytrace.dev/server/utils/storage.ts
+++ b/apps/keytrace.dev/server/utils/storage.ts
@@ -112,6 +112,29 @@ export async function saveJson(key: string, data: T): Promise {
console.log(`[storage] Saved to file: ${filePath}`);
}
+/**
+ * Delete JSON data from storage (S3 in production, file in development).
+ */
+export async function deleteJson(key: string): Promise {
+ if (useS3()) {
+ await getS3Client().send(
+ new DeleteObjectCommand({
+ Bucket: getS3Config().bucket,
+ Key: key,
+ }),
+ );
+ return;
+ }
+
+ // File storage
+ const filePath = path.join(DATA_DIR, key);
+ try {
+ fs.unlinkSync(filePath);
+ } catch (e: any) {
+ if (e.code !== "ENOENT") throw e;
+ }
+}
+
// S3-based storage for production
class S3SessionStore implements NodeSavedSessionStore {
private prefix = "sessions/";
diff --git a/packages/relay/.env.example b/packages/relay/.env.example
new file mode 100644
index 0000000..dc43512
--- /dev/null
+++ b/packages/relay/.env.example
@@ -0,0 +1,6 @@
+# Matterbridge API token (must match [api] Token in matterbridge.toml)
+MATTERBRIDGE_API_TOKEN=change-me-to-a-secret
+
+# Keytrace chat endpoint (receives all messages for live broadcasting + DID storage)
+KEYTRACE_CHAT_URL=https://keytrace.dev/api/chat/messages
+KEYTRACE_INGEST_TOKEN=your-ingest-token-here
diff --git a/packages/relay/Dockerfile b/packages/relay/Dockerfile
new file mode 100644
index 0000000..8680f4d
--- /dev/null
+++ b/packages/relay/Dockerfile
@@ -0,0 +1,8 @@
+# Ingester sidecar — connects to Matterbridge SSE stream
+# and POSTs DID-containing messages to keytrace.dev
+FROM node:20-slim
+WORKDIR /app
+COPY dist/ ./dist/
+COPY package.json ./
+ENV NODE_ENV=production
+CMD ["node", "dist/ingester.js"]
diff --git a/packages/relay/README.md b/packages/relay/README.md
new file mode 100644
index 0000000..3198106
--- /dev/null
+++ b/packages/relay/README.md
@@ -0,0 +1,135 @@
+# @keytrace/relay
+
+Relay service for verifying identity on private messaging platforms (Signal, Telegram, Discord, WhatsApp, Matrix). Uses [Matterbridge](https://github.com/42wim/matterbridge) to bridge messages from 20+ chat platforms into a single stream, then forwards them to keytrace.dev for live broadcasting and DID verification.
+
+## Architecture
+
+```
+User sends message to bot on Signal/Telegram/Discord/etc.
+ │
+ ▼
+┌───────────────┐ ┌──────────────────┐
+│ Matterbridge │ SSE │ Ingester │
+│ │──────▶│ │
+│ Telegram ──┐ │ │ Forwards ALL │
+│ Signal ────┤ │ │ messages to │───▶ POST /api/chat/messages
+│ Discord ───┤ │◀──────│ keytrace.dev │
+│ WhatsApp ──┘ │ reply │ │
+│ │ │ Replies when │
+│ API :4242 │ │ DID is saved │
+└───────────────┘ └──────────────────┘
+ │
+ ┌───────────┴───────────┐
+ ▼ ▼
+ SSE broadcast DID messages saved
+ to /chat page to relay store
+ │ │
+ ▼ ▼
+ Live public feed GET /api/relay/:platform/:username
+ on keytrace.dev for runner verification
+```
+
+The ingester is the **only** process that talks to Matterbridge. It reads the SSE stream and forwards every message to `POST /api/chat/messages` on keytrace.dev, which:
+
+1. **Broadcasts** every message to connected SSE clients on the `/chat` live feed
+2. **Saves** messages containing a DID to the relay store for identity verification
+3. Tells the ingester whether a DID was saved, so it can reply to the user
+
+## Live chat page
+
+The `/chat` page on keytrace.dev shows all relay messages in real-time — a public, one-way chat room. This makes the relay transparent: users can see their messages arrive and see the "saved" indicator when a DID is stored.
+
+Messages are displayed as:
+
+```text
+12:23 [telegram] alice did:plc:abc123xyz ✓ saved
+12:24 [discord] bob Hello, is this working?
+12:25 [signal] +12345 did:web:example.com ✓ saved
+```
+
+The page connects via `EventSource` to `GET /api/chat/stream`. A 100-message buffer means new visitors see recent context.
+
+## Deployment
+
+The relay runs as two Docker containers side by side:
+
+- **matterbridge** — official image, unmodified, with your config mounted in
+- **ingester** — small Node container that bridges Matterbridge to keytrace.dev
+
+### Setup
+
+```bash
+cd packages/relay
+
+# 1. Build the ingester
+yarn build
+
+# 2. Configure Matterbridge
+cp config/matterbridge.toml.example matterbridge.toml
+# Edit matterbridge.toml — uncomment platforms and add bot tokens
+
+# 3. Set environment variables
+cp .env.example .env
+# Edit .env — set your tokens
+
+# 4. Start
+docker compose up -d
+```
+
+### Environment variables
+
+| Variable | Description |
+|---|---|
+| `MATTERBRIDGE_API_TOKEN` | Must match `Token` in `[api]` section of matterbridge.toml |
+| `KEYTRACE_CHAT_URL` | Chat endpoint (default: `https://keytrace.dev/api/chat/messages`) |
+| `KEYTRACE_INGEST_TOKEN` | Must match `NUXT_RELAY_INGEST_TOKEN` on the keytrace.dev server |
+
+### Matterbridge configuration
+
+The example config at [config/matterbridge.toml.example](config/matterbridge.toml.example) includes commented sections for each supported platform. Uncomment and configure the ones you want:
+
+- **Telegram** — create a bot via @BotFather, add the token
+- **Signal** — requires [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) running alongside
+- **Discord** — create a bot at discord.com/developers
+- **WhatsApp** — uses whatsmeow (built into Matterbridge)
+- **Matrix** — bot username + password on any homeserver
+
+All messages flow through to the ingester — no server-side filtering needed.
+
+## How verification works
+
+1. User messages the bot with their DID (e.g. `did:plc:abc123`)
+2. Matterbridge receives it and streams it to the ingester
+3. Ingester POSTs the message to keytrace.dev, which broadcasts it to the live chat and saves the DID
+4. Ingester sends a confirmation reply back through Matterbridge to the user
+5. The message appears on the `/chat` page with a green "saved" badge
+6. When the user creates a claim on keytrace.dev (e.g. `telegram:alice`), the runner fetches `GET /api/relay/telegram/alice` and checks the DID matches
+
+## Library usage
+
+The package also exports store implementations and utilities for use in the Nuxt app:
+
+```typescript
+import { JsonStore, MemoryStore, extractDid, extractPlatform } from "@keytrace/relay";
+
+// MemoryStore — for dev/testing
+const store = new MemoryStore();
+
+// JsonStore — for production (delegates to S3/file callbacks)
+const store = new JsonStore(loadJson, saveJson, deleteJson);
+
+// Extract DID from message text
+extractDid("my DID is did:plc:abc123"); // → "did:plc:abc123"
+
+// Extract platform from Matterbridge account string
+extractPlatform("telegram.keytrace"); // → "telegram"
+```
+
+## Development
+
+```bash
+yarn test # Run tests
+yarn test:watch # Watch mode
+yarn typecheck # Type check
+yarn build # Compile TypeScript
+```
diff --git a/packages/relay/config/matterbridge.toml.example b/packages/relay/config/matterbridge.toml.example
new file mode 100644
index 0000000..4a38d89
--- /dev/null
+++ b/packages/relay/config/matterbridge.toml.example
@@ -0,0 +1,86 @@
+# Keytrace Relay — Matterbridge Configuration
+#
+# This bridges messages from chat platforms to the Keytrace relay service.
+# All messages are forwarded to the ingester sidecar, which broadcasts them
+# to the live chat page. Messages containing a DID are also saved for verification.
+#
+# Setup:
+# 1. Copy this to packages/relay/matterbridge.toml
+# 2. Fill in bot tokens for each platform you want to support
+# 3. Copy .env.example → .env and fill in tokens
+# 4. docker compose up -d (from packages/relay/)
+
+# --- Internal API (consumed by the ingester sidecar) ---
+[api]
+# Bind to 0.0.0.0 so the ingester container can reach it
+BindAddress = "0.0.0.0:4242"
+Token = "change-me-to-a-secret"
+Buffer = 1000
+
+# --- Telegram ---
+# Get a bot token from @BotFather on Telegram
+# [telegram]
+# [telegram.keytrace]
+# Token = "YOUR_TELEGRAM_BOT_TOKEN"
+# RemoteNickFormat = "{NICK}"
+
+# --- Signal ---
+# Requires signal-cli-rest-api running (https://github.com/bbernhard/signal-cli-rest-api)
+# [signalgo]
+# [signalgo.keytrace]
+# Number = "+1234567890"
+# Url = "http://localhost:8080"
+# RemoteNickFormat = "{NICK}"
+
+# --- Discord ---
+# Create a bot at https://discord.com/developers/applications
+# [discord]
+# [discord.keytrace]
+# Token = "YOUR_DISCORD_BOT_TOKEN"
+# Server = "your-server-id"
+# RemoteNickFormat = "{NICK}"
+
+# --- WhatsApp ---
+# Uses whatsmeow (built into matterbridge)
+# [whatsapp]
+# [whatsapp.keytrace]
+# RemoteNickFormat = "{NICK}"
+
+# --- Matrix ---
+# [matrix]
+# [matrix.keytrace]
+# Server = "https://matrix.org"
+# Login = "your-bot-username"
+# Password = "your-bot-password"
+# RemoteNickFormat = "{NICK}"
+
+# --- Gateway: connect all platforms to the API ---
+[[gateway]]
+name = "keytrace-relay"
+enable = true
+
+ # Uncomment each platform as you configure it above:
+
+ # [[gateway.inout]]
+ # account = "telegram.keytrace"
+ # channel = "keytrace-verify"
+
+ # [[gateway.inout]]
+ # account = "signalgo.keytrace"
+ # channel = "keytrace-verify"
+
+ # [[gateway.inout]]
+ # account = "discord.keytrace"
+ # channel = "keytrace-verify"
+
+ # [[gateway.inout]]
+ # account = "whatsapp.keytrace"
+ # channel = "keytrace-verify"
+
+ # [[gateway.inout]]
+ # account = "matrix.keytrace"
+ # channel = "keytrace-verify"
+
+ [[gateway.inout]]
+ account = "api.relay"
+ channel = "api"
diff --git a/packages/relay/docker-compose.yml b/packages/relay/docker-compose.yml
new file mode 100644
index 0000000..0bd3376
--- /dev/null
+++ b/packages/relay/docker-compose.yml
@@ -0,0 +1,34 @@
+# Keytrace Relay Stack
+#
+# Runs Matterbridge (multi-platform chat bridge) alongside the ingester
+# sidecar that forwards all messages to keytrace.dev for live broadcasting.
+# Messages with a DID are also saved for identity verification.
+#
+# Usage:
+# 1. Copy config/matterbridge.toml.example → matterbridge.toml and configure your platforms
+# 2. Copy .env.example → .env and fill in tokens
+# 3. docker compose up -d
+
+services:
+ matterbridge:
+ image: ghcr.io/42wim/matterbridge:latest
+ restart: unless-stopped
+ volumes:
+ - ./matterbridge.toml:/matterbridge.toml:ro
+ # The API listens on 4242 inside the container.
+ # Only the ingester needs access — no need to expose to host.
+ expose:
+ - "4242"
+
+ ingester:
+ build: .
+ restart: unless-stopped
+ depends_on:
+ - matterbridge
+ environment:
+ # Matterbridge API — use the Docker service name as hostname
+ MATTERBRIDGE_URL: http://matterbridge:4242
+ MATTERBRIDGE_TOKEN: ${MATTERBRIDGE_API_TOKEN}
+ # Where to POST all messages (live chat broadcast + DID storage)
+ KEYTRACE_CHAT_URL: ${KEYTRACE_CHAT_URL:-https://keytrace.dev/api/chat/messages}
+ KEYTRACE_INGEST_TOKEN: ${KEYTRACE_INGEST_TOKEN}
diff --git a/packages/relay/package.json b/packages/relay/package.json
new file mode 100644
index 0000000..5ced151
--- /dev/null
+++ b/packages/relay/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@keytrace/relay",
+ "version": "0.0.10",
+ "files": [
+ "src",
+ "dist"
+ ],
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ }
+ },
+ "bin": {
+ "keytrace-relay-ingest": "./dist/ingester.js"
+ },
+ "scripts": {
+ "build": "tsc",
+ "ingest": "node dist/ingester.js",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "typescript": "^5.7.0",
+ "vitest": "^2.1.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/orta/keytrace",
+ "directory": "packages/relay"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts
new file mode 100644
index 0000000..56600dc
--- /dev/null
+++ b/packages/relay/src/index.ts
@@ -0,0 +1,3 @@
+export { MemoryStore, JsonStore, extractDid, extractPlatform } from "./store.js";
+export type { RelayStore } from "./store.js";
+export type { VerifiedMessage } from "./types.js";
diff --git a/packages/relay/src/ingester.ts b/packages/relay/src/ingester.ts
new file mode 100644
index 0000000..90149ec
--- /dev/null
+++ b/packages/relay/src/ingester.ts
@@ -0,0 +1,161 @@
+/**
+ * Standalone ingester that connects to the Matterbridge SSE stream
+ * and forwards ALL messages to the keytrace.dev chat endpoint.
+ * Messages containing a DID are also saved for identity verification.
+ *
+ * This is the ONLY process that talks to Matterbridge.
+ * Run it as a sidecar alongside Matterbridge in production.
+ *
+ * Environment variables:
+ * MATTERBRIDGE_URL - Matterbridge API base URL (default: http://localhost:4242)
+ * MATTERBRIDGE_TOKEN - Bearer token for Matterbridge API (optional)
+ * KEYTRACE_CHAT_URL - URL to POST all messages to (default: https://keytrace.dev/api/chat/messages)
+ * KEYTRACE_INGEST_TOKEN - Bearer token for the keytrace.dev endpoints
+ */
+
+import { extractDid, extractPlatform } from "./store.js";
+
+interface IngesterConfig {
+ matterbridgeUrl: string;
+ matterbridgeToken: string;
+ chatUrl: string;
+ ingestToken: string;
+}
+
+function getConfig(): IngesterConfig {
+ return {
+ matterbridgeUrl: process.env.MATTERBRIDGE_URL ?? "http://localhost:4242",
+ matterbridgeToken: process.env.MATTERBRIDGE_TOKEN ?? "",
+ chatUrl: process.env.KEYTRACE_CHAT_URL ?? "https://keytrace.dev/api/chat/messages",
+ ingestToken: process.env.KEYTRACE_INGEST_TOKEN ?? "",
+ };
+}
+
+/** Forward a message to the keytrace.dev chat endpoint (broadcasts to SSE + saves DIDs) */
+async function postToChat(
+ config: IngesterConfig,
+ msg: { text: string; username: string; userid?: string; account: string; gateway?: string },
+): Promise<{ ok: boolean; saved?: boolean }> {
+ try {
+ const res = await fetch(config.chatUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(config.ingestToken ? { Authorization: `Bearer ${config.ingestToken}` } : {}),
+ },
+ body: JSON.stringify(msg),
+ });
+
+ if (!res.ok) {
+ console.error(`[ingester] POST to ${config.chatUrl} failed: ${res.status} ${await res.text()}`);
+ return { ok: false };
+ }
+ const body = await res.json();
+ return { ok: true, saved: body.saved };
+ } catch (err) {
+ console.error(`[ingester] POST to ${config.chatUrl} error:`, err);
+ return { ok: false };
+ }
+}
+
+/** Send a reply back through Matterbridge to the user's platform */
+async function sendReply(config: IngesterConfig, gateway: string, text: string): Promise {
+ try {
+ await fetch(`${config.matterbridgeUrl}/api/message`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(config.matterbridgeToken ? { Authorization: `Bearer ${config.matterbridgeToken}` } : {}),
+ },
+ body: JSON.stringify({
+ text,
+ username: "keytrace",
+ gateway,
+ }),
+ });
+ } catch (err) {
+ console.error("[ingester] Failed to send reply:", err);
+ }
+}
+
+async function connectStream(config: IngesterConfig): Promise {
+ const streamUrl = `${config.matterbridgeUrl}/api/stream`;
+ const headers: Record = {};
+ if (config.matterbridgeToken) {
+ headers["Authorization"] = `Bearer ${config.matterbridgeToken}`;
+ }
+
+ console.log(`[ingester] Connecting to ${streamUrl}`);
+
+ const res = await fetch(streamUrl, { headers });
+ if (!res.ok) {
+ throw new Error(`Stream connection failed: ${res.status}`);
+ }
+ if (!res.body) {
+ throw new Error("Stream response has no body");
+ }
+
+ console.log("[ingester] Connected to Matterbridge stream");
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ for await (const chunk of res.body) {
+ buffer += decoder.decode(chunk, { stream: true });
+
+ // SSE messages are separated by newlines — process complete lines
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ try {
+ const msg = JSON.parse(trimmed);
+
+ // Skip system events (like api_connected)
+ if (msg.event) continue;
+
+ const { text, username, userid, account, gateway } = msg;
+ if (!text || !username || !account) continue;
+
+ // Forward ALL messages to the chat endpoint (broadcasts to SSE clients + saves DIDs)
+ const result = await postToChat(config, { text, username, userid, account, gateway });
+
+ // If a DID was saved, send a confirmation reply back through the chat platform
+ if (result.saved && gateway) {
+ const did = extractDid(text);
+ const platform = extractPlatform(account);
+ console.log(`[ingester] DID saved: ${platform}/${username} → ${did}`);
+ await sendReply(
+ config,
+ gateway,
+ `Saved! ${did} is now linked to your ${platform} account "${username}". Verify at keytrace.dev/add`,
+ );
+ }
+ } catch {
+ // Not valid JSON or parse error — skip
+ }
+ }
+ }
+}
+
+async function run(): Promise {
+ const config = getConfig();
+ console.log(`[ingester] Matterbridge: ${config.matterbridgeUrl}`);
+ console.log(`[ingester] Chat URL: ${config.chatUrl}`);
+
+ while (true) {
+ try {
+ await connectStream(config);
+ console.log("[ingester] Stream ended, reconnecting in 5s...");
+ } catch (err) {
+ console.error("[ingester] Stream error:", err);
+ console.log("[ingester] Reconnecting in 5s...");
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ }
+}
+
+run();
diff --git a/packages/relay/src/store.ts b/packages/relay/src/store.ts
new file mode 100644
index 0000000..adb8ad1
--- /dev/null
+++ b/packages/relay/src/store.ts
@@ -0,0 +1,127 @@
+import type { VerifiedMessage } from "./types.js";
+
+/** Regex matching did:plc:xxx or did:web:xxx */
+const DID_PATTERN = /did:(plc|web):[a-zA-Z0-9._:%-]+/;
+
+/** Interface for relay message storage backends */
+export interface RelayStore {
+ get(platform: string, identifier: string): Promise;
+ put(platform: string, username: string, did: string, userid?: string): Promise;
+ delete(platform: string, identifier: string): Promise;
+}
+
+/**
+ * In-memory store for verified DID messages.
+ * Stores under both userid and username keys when userid is available,
+ * so lookups work by either identifier.
+ */
+export class MemoryStore implements RelayStore {
+ private messages = new Map();
+
+ private key(platform: string, identifier: string): string {
+ return `${platform}:${identifier.toLowerCase()}`;
+ }
+
+ async put(platform: string, username: string, did: string, userid?: string): Promise {
+ const validDid = extractDid(did);
+ if (!validDid) return undefined;
+
+ const msg: VerifiedMessage = {
+ platform,
+ username,
+ userid,
+ did: validDid,
+ timestamp: Date.now(),
+ };
+ // Always store under userid if available
+ const primaryKey = this.key(platform, userid ?? username);
+ this.messages.set(primaryKey, msg);
+ // Also store under username so lookups by either work
+ if (userid) {
+ this.messages.set(this.key(platform, username), msg);
+ }
+ return msg;
+ }
+
+ async get(platform: string, identifier: string): Promise {
+ return this.messages.get(this.key(platform, identifier));
+ }
+
+ async delete(platform: string, identifier: string): Promise {
+ return this.messages.delete(this.key(platform, identifier));
+ }
+
+ /** Number of stored messages */
+ get size(): number {
+ return this.messages.size;
+ }
+
+ /** Clear all stored messages */
+ clear(): void {
+ this.messages.clear();
+ }
+}
+
+/**
+ * JSON-file/S3 store for verified DID messages.
+ * Uses a loadJson/saveJson abstraction so it works with both local files and S3.
+ * Stores under relay/{platform}/{userid}.json when userid is available,
+ * with a copy at relay/{platform}/{username}.json for lookup by either.
+ */
+export class JsonStore implements RelayStore {
+ constructor(
+ private load: (key: string) => Promise,
+ private save: (key: string, data: T) => Promise,
+ private del: (key: string) => Promise,
+ ) {}
+
+ private path(platform: string, identifier: string): string {
+ return `relay/${platform}/${identifier.toLowerCase()}.json`;
+ }
+
+ async put(platform: string, username: string, did: string, userid?: string): Promise {
+ const validDid = extractDid(did);
+ if (!validDid) return undefined;
+
+ const msg: VerifiedMessage = {
+ platform,
+ username,
+ userid,
+ did: validDid,
+ timestamp: Date.now(),
+ };
+ const primaryId = userid ?? username;
+ await this.save(this.path(platform, primaryId), msg);
+ // Also write under username so lookups by either work
+ if (userid) {
+ await this.save(this.path(platform, username), msg);
+ }
+ return msg;
+ }
+
+ async get(platform: string, identifier: string): Promise {
+ const msg = await this.load(this.path(platform, identifier));
+ return msg ?? undefined;
+ }
+
+ async delete(platform: string, identifier: string): Promise {
+ try {
+ await this.del(this.path(platform, identifier));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+}
+
+/** Extract the first DID from a text string, or undefined if none found */
+export function extractDid(text: string): string | undefined {
+ const match = text.match(DID_PATTERN);
+ return match?.[0];
+}
+
+/** Extract the platform name from a Matterbridge account string (e.g., "telegram.keytrace" → "telegram") */
+export function extractPlatform(account: string): string {
+ const dot = account.indexOf(".");
+ return dot === -1 ? account : account.slice(0, dot);
+}
diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts
new file mode 100644
index 0000000..05c84a7
--- /dev/null
+++ b/packages/relay/src/types.ts
@@ -0,0 +1,15 @@
+/** A verified DID message received from a chat platform via Matterbridge */
+export interface VerifiedMessage {
+ /** Platform identifier (e.g., "telegram", "signal", "discord") */
+ platform: string;
+ /** Sender's username on that platform (display name, may change) */
+ username: string;
+ /** Platform-native stable ID (Signal UUID, Telegram numeric ID, Discord snowflake, etc.) */
+ userid?: string;
+ /** Extracted DID (did:plc:xxx or did:web:xxx) */
+ did: string;
+ /** Unix timestamp (ms) when the message was received */
+ timestamp: number;
+ /** Full message text */
+ raw?: string;
+}
diff --git a/packages/relay/tests/store.test.ts b/packages/relay/tests/store.test.ts
new file mode 100644
index 0000000..f9df75f
--- /dev/null
+++ b/packages/relay/tests/store.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { MemoryStore, extractDid, extractPlatform } from "../src/store.js";
+
+describe("extractDid", () => {
+ it("extracts did:plc from text", () => {
+ expect(extractDid("my identity is did:plc:abc123xyz")).toBe("did:plc:abc123xyz");
+ });
+
+ it("extracts did:web from text", () => {
+ expect(extractDid("did:web:example.com is me")).toBe("did:web:example.com");
+ });
+
+ it("extracts first DID when multiple present", () => {
+ expect(extractDid("did:plc:first and did:web:second")).toBe("did:plc:first");
+ });
+
+ it("returns undefined for text without DID", () => {
+ expect(extractDid("hello world")).toBeUndefined();
+ });
+
+ it("returns undefined for empty string", () => {
+ expect(extractDid("")).toBeUndefined();
+ });
+
+ it("handles DID as the entire message", () => {
+ expect(extractDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz")).toBe("did:plc:ewvi7nxzyoun6zhxrhs64oiz");
+ });
+});
+
+describe("extractPlatform", () => {
+ it("extracts platform from account string", () => {
+ expect(extractPlatform("telegram.keytrace")).toBe("telegram");
+ });
+
+ it("handles signal account", () => {
+ expect(extractPlatform("signalgo.keytrace")).toBe("signalgo");
+ });
+
+ it("handles account with no dot", () => {
+ expect(extractPlatform("telegram")).toBe("telegram");
+ });
+
+ it("handles multiple dots", () => {
+ expect(extractPlatform("api.relay.v2")).toBe("api");
+ });
+});
+
+describe("MemoryStore", () => {
+ let store: MemoryStore;
+
+ beforeEach(() => {
+ store = new MemoryStore();
+ });
+
+ it("stores and retrieves a message with a DID", async () => {
+ await store.put("telegram", "alice", "did:plc:abc123");
+ const msg = await store.get("telegram", "alice");
+ expect(msg).toBeDefined();
+ expect(msg!.did).toBe("did:plc:abc123");
+ expect(msg!.platform).toBe("telegram");
+ expect(msg!.username).toBe("alice");
+ });
+
+ it("returns undefined for unknown user", async () => {
+ expect(await store.get("telegram", "nobody")).toBeUndefined();
+ });
+
+ it("ignores invalid DID strings", async () => {
+ const result = await store.put("telegram", "alice", "hello world");
+ expect(result).toBeUndefined();
+ expect(await store.get("telegram", "alice")).toBeUndefined();
+ });
+
+ it("overwrites with latest message per user per platform", async () => {
+ await store.put("telegram", "alice", "did:plc:old");
+ await store.put("telegram", "alice", "did:plc:new");
+ expect((await store.get("telegram", "alice"))!.did).toBe("did:plc:new");
+ });
+
+ it("stores different platforms independently", async () => {
+ await store.put("telegram", "alice", "did:plc:tg");
+ await store.put("signal", "alice", "did:plc:sig");
+ expect((await store.get("telegram", "alice"))!.did).toBe("did:plc:tg");
+ expect((await store.get("signal", "alice"))!.did).toBe("did:plc:sig");
+ });
+
+ it("is case-insensitive for usernames", async () => {
+ await store.put("telegram", "Alice", "did:plc:abc123");
+ expect(await store.get("telegram", "alice")).toBeDefined();
+ expect(await store.get("telegram", "ALICE")).toBeDefined();
+ });
+
+ it("deletes messages", async () => {
+ await store.put("telegram", "alice", "did:plc:a");
+ expect(await store.delete("telegram", "alice")).toBe(true);
+ expect(await store.get("telegram", "alice")).toBeUndefined();
+ });
+
+ it("clears all messages", async () => {
+ await store.put("telegram", "alice", "did:plc:a");
+ await store.put("signal", "bob", "did:plc:b");
+ store.clear();
+ expect(store.size).toBe(0);
+ });
+
+ it("extracts DID from longer text passed as did param", async () => {
+ await store.put("telegram", "alice", "hey verify me: did:plc:abc123 thanks");
+ const msg = await store.get("telegram", "alice");
+ expect(msg!.did).toBe("did:plc:abc123");
+ });
+
+ describe("userid support", () => {
+ it("stores under userid and retrieves by userid", async () => {
+ await store.put("signal", "alice", "did:plc:abc123", "uuid-1234");
+ const msg = await store.get("signal", "uuid-1234");
+ expect(msg).toBeDefined();
+ expect(msg!.did).toBe("did:plc:abc123");
+ expect(msg!.userid).toBe("uuid-1234");
+ expect(msg!.username).toBe("alice");
+ });
+
+ it("retrieves by username when userid was provided", async () => {
+ await store.put("signal", "alice", "did:plc:abc123", "uuid-1234");
+ const msg = await store.get("signal", "alice");
+ expect(msg).toBeDefined();
+ expect(msg!.did).toBe("did:plc:abc123");
+ });
+
+ it("stores under username when no userid provided", async () => {
+ await store.put("telegram", "alice", "did:plc:abc123");
+ const msg = await store.get("telegram", "alice");
+ expect(msg).toBeDefined();
+ expect(msg!.userid).toBeUndefined();
+ });
+
+ it("updates by userid even if username changes", async () => {
+ await store.put("signal", "alice", "did:plc:first", "uuid-1234");
+ await store.put("signal", "alice_new", "did:plc:second", "uuid-1234");
+ // Same userid, different username — lookup by userid gets latest
+ const msg = await store.get("signal", "uuid-1234");
+ expect(msg!.did).toBe("did:plc:second");
+ expect(msg!.username).toBe("alice_new");
+ });
+ });
+});
diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json
new file mode 100644
index 0000000..c973386
--- /dev/null
+++ b/packages/relay/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/relay/vitest.config.ts b/packages/relay/vitest.config.ts
new file mode 100644
index 0000000..4b1f9af
--- /dev/null
+++ b/packages/relay/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: "node",
+ include: ["tests/**/*.test.ts"],
+ },
+});
diff --git a/packages/runner/src/serviceProviders/index.ts b/packages/runner/src/serviceProviders/index.ts
index dc03aa3..0ea18ce 100644
--- a/packages/runner/src/serviceProviders/index.ts
+++ b/packages/runner/src/serviceProviders/index.ts
@@ -4,6 +4,7 @@ import activitypub from "./activitypub.js";
import bsky from "./bsky.js";
import npm from "./npm.js";
import tangled from "./tangled.js";
+import { signal, telegram, discord, whatsapp, matrix } from "./relay.js";
import type { ServiceProvider, ServiceProviderMatch } from "./types.js";
export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ProofTarget, ProofRequest, ProcessedURI } from "./types.js";
@@ -15,6 +16,11 @@ const providers: Record = {
bsky,
npm,
tangled,
+ signal,
+ telegram,
+ discord,
+ whatsapp,
+ matrix,
};
/**
@@ -64,4 +70,4 @@ export function getProofTextForProvider(providerId: string, did: string, handle?
return provider?.getProofText(did, handle);
}
-export { github, dns, activitypub, bsky, npm, tangled };
+export { github, dns, activitypub, bsky, npm, tangled, signal, telegram, discord, whatsapp, matrix };
diff --git a/packages/runner/src/serviceProviders/relay.ts b/packages/runner/src/serviceProviders/relay.ts
new file mode 100644
index 0000000..41c5b99
--- /dev/null
+++ b/packages/runner/src/serviceProviders/relay.ts
@@ -0,0 +1,148 @@
+import type { ServiceProvider } from "./types.js";
+
+/** Base URL for the relay endpoint — served by the main keytrace.dev app */
+const RELAY_URL = process.env.KEYTRACE_RELAY_URL ?? "https://keytrace.dev/api/relay";
+
+interface RelayPlatformConfig {
+ id: string;
+ name: string;
+ homepage: string;
+ icon: string;
+ /** Regex pattern for the username part (after "platform:") */
+ usernamePattern: string;
+ /** Description for the UI service picker */
+ description: string;
+ /** Placeholder for the claim URI input */
+ inputPlaceholder: string;
+ /** Step-by-step instructions */
+ instructions: string[];
+}
+
+function createRelayProvider(config: RelayPlatformConfig): ServiceProvider {
+ const reUri = new RegExp(`^${config.id}:(.+)$`);
+
+ return {
+ id: config.id,
+ name: config.name,
+ homepage: config.homepage,
+ reUri,
+ isAmbiguous: false,
+
+ ui: {
+ description: config.description,
+ icon: config.icon,
+ inputLabel: `${config.name} Username`,
+ inputPlaceholder: config.inputPlaceholder,
+ inputDefaultTemplate: `${config.id}:{slugHandle}`,
+ instructions: config.instructions,
+ proofTemplate: "{did}",
+ },
+
+ processURI(_uri, match) {
+ const [, username] = match;
+ return {
+ profile: {
+ display: username,
+ uri: `${config.homepage}`,
+ },
+ proof: {
+ request: {
+ uri: `${RELAY_URL}/${config.id}/${encodeURIComponent(username)}`,
+ fetcher: "http",
+ format: "json",
+ },
+ target: [{ path: ["did"], relation: "contains", format: "text" }],
+ },
+ };
+ },
+
+ getProofText(did) {
+ return did;
+ },
+
+ getProofLocation() {
+ return `Send your DID as a message to the Keytrace bot on ${config.name}`;
+ },
+
+ tests: [
+ { uri: `${config.id}:testuser`, shouldMatch: true },
+ { uri: `${config.id}:test.user.123`, shouldMatch: true },
+ { uri: `https://${config.id}.example.com/user`, shouldMatch: false },
+ ],
+ };
+}
+
+export const signal = createRelayProvider({
+ id: "signal",
+ name: "Signal",
+ homepage: "https://signal.org",
+ icon: "message-circle",
+ usernamePattern: "[a-zA-Z0-9_.]+",
+ description: "Link via Signal message",
+ inputPlaceholder: "your_signal_username",
+ instructions: [
+ "Open Signal and find the **Keytrace Verify** bot",
+ "Send your DID (shown below) as a message to the bot",
+ "Enter your Signal username below and verify",
+ ],
+});
+
+export const telegram = createRelayProvider({
+ id: "telegram",
+ name: "Telegram",
+ homepage: "https://telegram.org",
+ icon: "send",
+ usernamePattern: "[a-zA-Z0-9_]+",
+ description: "Link via Telegram message",
+ inputPlaceholder: "your_telegram_username",
+ instructions: [
+ "Open Telegram and find **@KeytraceVerifyBot**",
+ "Send your DID (shown below) as a message to the bot",
+ "Enter your Telegram username below and verify",
+ ],
+});
+
+export const discord = createRelayProvider({
+ id: "discord",
+ name: "Discord",
+ homepage: "https://discord.com",
+ icon: "hash",
+ usernamePattern: "[a-zA-Z0-9_.]+",
+ description: "Link via Discord message",
+ inputPlaceholder: "your_discord_username",
+ instructions: [
+ "Join the Keytrace Discord server and find the **#verify** channel",
+ "Send your DID (shown below) as a message in that channel",
+ "Enter your Discord username below and verify",
+ ],
+});
+
+export const whatsapp = createRelayProvider({
+ id: "whatsapp",
+ name: "WhatsApp",
+ homepage: "https://whatsapp.com",
+ icon: "phone",
+ usernamePattern: "[0-9+]+",
+ description: "Link via WhatsApp message",
+ inputPlaceholder: "+1234567890",
+ instructions: [
+ "Send a message to the Keytrace WhatsApp number",
+ "Include your DID (shown below) in the message",
+ "Enter your phone number below and verify",
+ ],
+});
+
+export const matrix = createRelayProvider({
+ id: "matrix",
+ name: "Matrix",
+ homepage: "https://matrix.org",
+ icon: "grid-3x3",
+ usernamePattern: "@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+",
+ description: "Link via Matrix message",
+ inputPlaceholder: "@user:matrix.org",
+ instructions: [
+ "Send a direct message to **@keytrace:matrix.org**",
+ "Include your DID (shown below) in the message",
+ "Enter your Matrix ID below and verify",
+ ],
+});
diff --git a/yarn.lock b/yarn.lock
index c2a7026..c60719b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1903,6 +1903,16 @@ __metadata:
languageName: unknown
linkType: soft
+"@keytrace/relay@workspace:*, @keytrace/relay@workspace:packages/relay":
+ version: 0.0.0-use.local
+ resolution: "@keytrace/relay@workspace:packages/relay"
+ dependencies:
+ "@types/node": "npm:^22.0.0"
+ typescript: "npm:^5.7.0"
+ vitest: "npm:^2.1.0"
+ languageName: unknown
+ linkType: soft
+
"@keytrace/runner@workspace:packages/runner":
version: 0.0.0-use.local
resolution: "@keytrace/runner@workspace:packages/runner"
@@ -7599,6 +7609,7 @@ __metadata:
"@atproto/oauth-client-node": "npm:^0.3.0"
"@aws-sdk/client-s3": "npm:^3.700.0"
"@keytrace/claims": "workspace:*"
+ "@keytrace/relay": "workspace:*"
"@nuxtjs/google-fonts": "npm:^3.2.0"
"@nuxtjs/tailwindcss": "npm:^6.14.0"
lucide-vue-next: "npm:^0.563.0"