From ea07e43878ef9bce18085b5b8bfaae600274e2bf Mon Sep 17 00:00:00 2001 From: orta Date: Fri, 13 Feb 2026 07:25:33 +0000 Subject: [PATCH 1/2] Adds infra to do a public chat relay --- .../components/ui/ChatMessageRow.vue | 102 +++++++++++ apps/keytrace.dev/components/ui/NavBar.vue | 6 +- apps/keytrace.dev/nuxt.config.ts | 1 + apps/keytrace.dev/package.json | 1 + apps/keytrace.dev/pages/chat.vue | 106 ++++++++++++ .../server/api/chat/messages.post.ts | 62 +++++++ .../server/api/chat/stream.get.ts | 23 +++ .../server/api/relay/[...path].get.ts | 37 ++++ .../server/api/relay/ingest.post.ts | 46 +++++ .../server/utils/chat-broadcast.ts | 37 ++++ apps/keytrace.dev/server/utils/relay.ts | 13 ++ apps/keytrace.dev/server/utils/storage.ts | 23 +++ packages/relay/.env.example | 6 + packages/relay/Dockerfile | 8 + packages/relay/README.md | 135 +++++++++++++++ .../relay/config/matterbridge.toml.example | 86 ++++++++++ packages/relay/docker-compose.yml | 34 ++++ packages/relay/package.json | 40 +++++ packages/relay/src/index.ts | 3 + packages/relay/src/ingester.ts | 161 ++++++++++++++++++ packages/relay/src/store.ts | 115 +++++++++++++ packages/relay/src/types.ts | 13 ++ packages/relay/tests/store.test.ts | 121 +++++++++++++ packages/relay/tsconfig.json | 9 + packages/relay/vitest.config.ts | 9 + packages/runner/src/serviceProviders/index.ts | 8 +- packages/runner/src/serviceProviders/relay.ts | 148 ++++++++++++++++ yarn.lock | 11 ++ 28 files changed, 1362 insertions(+), 2 deletions(-) create mode 100644 apps/keytrace.dev/components/ui/ChatMessageRow.vue create mode 100644 apps/keytrace.dev/pages/chat.vue create mode 100644 apps/keytrace.dev/server/api/chat/messages.post.ts create mode 100644 apps/keytrace.dev/server/api/chat/stream.get.ts create mode 100644 apps/keytrace.dev/server/api/relay/[...path].get.ts create mode 100644 apps/keytrace.dev/server/api/relay/ingest.post.ts create mode 100644 apps/keytrace.dev/server/utils/chat-broadcast.ts create mode 100644 apps/keytrace.dev/server/utils/relay.ts create mode 100644 packages/relay/.env.example create mode 100644 packages/relay/Dockerfile create mode 100644 packages/relay/README.md create mode 100644 packages/relay/config/matterbridge.toml.example create mode 100644 packages/relay/docker-compose.yml create mode 100644 packages/relay/package.json create mode 100644 packages/relay/src/index.ts create mode 100644 packages/relay/src/ingester.ts create mode 100644 packages/relay/src/store.ts create mode 100644 packages/relay/src/types.ts create mode 100644 packages/relay/tests/store.test.ts create mode 100644 packages/relay/tsconfig.json create mode 100644 packages/relay/vitest.config.ts create mode 100644 packages/runner/src/serviceProviders/relay.ts 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 @@ + + + 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 @@
+ + + + @@ -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..6c9a191 --- /dev/null +++ b/apps/keytrace.dev/server/api/chat/messages.post.ts @@ -0,0 +1,62 @@ +/** + * 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, 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, 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); + saved = !!result; + console.log(`[chat] DID saved: ${platform}/${username} → ${did}`); + } + + const msg: ChatMessage = { + id: crypto.randomUUID(), + text, + username, + 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..fb15ffb --- /dev/null +++ b/apps/keytrace.dev/server/api/relay/[...path].get.ts @@ -0,0 +1,37 @@ +/** + * GET /api/relay/:platform/:username + * + * Public endpoint for the runner to fetch verified DID messages from the relay store. + * Returns the latest DID verification for a given platform/username pair. + */ +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, username] = parts; + + if (!platform || !username) { + throw createError({ statusCode: 400, statusMessage: "Missing platform or username" }); + } + + const store = getRelayStore(); + const msg = await store.get(platform, decodeURIComponent(username)); + + if (!msg) { + throw createError({ statusCode: 404, statusMessage: "No verification found" }); + } + + return { + did: msg.did, + username: msg.username, + 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..dec09a5 --- /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 } + */ + +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 } = 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); + + console.log(`[relay] Ingested: ${platform}/${username} → ${did}`); + + return { ok: true, platform, username, 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..cd91447 --- /dev/null +++ b/apps/keytrace.dev/server/utils/chat-broadcast.ts @@ -0,0 +1,37 @@ +export interface ChatMessage { + id: string; + text: string; + username: 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..21373c6 --- /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; 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, 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, 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..3fe544f --- /dev/null +++ b/packages/relay/src/store.ts @@ -0,0 +1,115 @@ +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, username: string): Promise; + put(platform: string, username: string, text: string): Promise; + delete(platform: string, username: string): Promise; +} + +/** + * In-memory store for verified DID messages. + * Keyed by "platform:username" — latest message per user per platform wins. + * Good for development and testing. + */ +export class MemoryStore implements RelayStore { + private messages = new Map(); + + private key(platform: string, username: string): string { + return `${platform}:${username.toLowerCase()}`; + } + + async put(platform: string, username: string, text: string): Promise { + const did = extractDid(text); + if (!did) return undefined; + + const msg: VerifiedMessage = { + platform, + username, + did, + timestamp: Date.now(), + raw: text, + }; + this.messages.set(this.key(platform, username), msg); + return msg; + } + + async get(platform: string, username: string): Promise { + return this.messages.get(this.key(platform, username)); + } + + async delete(platform: string, username: string): Promise { + return this.messages.delete(this.key(platform, username)); + } + + /** 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. + * Each message is stored as a separate JSON object at relay/{platform}/{username}.json + */ +export class JsonStore implements RelayStore { + constructor( + private load: (key: string) => Promise, + private save: (key: string, data: T) => Promise, + private del: (key: string) => Promise, + ) {} + + private key(platform: string, username: string): string { + return `relay/${platform}/${username.toLowerCase()}.json`; + } + + async put(platform: string, username: string, text: string): Promise { + const did = extractDid(text); + if (!did) return undefined; + + const msg: VerifiedMessage = { + platform, + username, + did, + timestamp: Date.now(), + raw: text, + }; + await this.save(this.key(platform, username), msg); + return msg; + } + + async get(platform: string, username: string): Promise { + const msg = await this.load(this.key(platform, username)); + return msg ?? undefined; + } + + async delete(platform: string, username: string): Promise { + try { + await this.del(this.key(platform, username)); + 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..fb70824 --- /dev/null +++ b/packages/relay/src/types.ts @@ -0,0 +1,13 @@ +/** 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 */ + username: 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..29b2216 --- /dev/null +++ b/packages/relay/tests/store.test.ts @@ -0,0 +1,121 @@ +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 messages without a DID", 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("tracks size", async () => { + expect(store.size).toBe(0); + await store.put("telegram", "alice", "did:plc:a"); + expect(store.size).toBe(1); + await store.put("signal", "bob", "did:plc:b"); + expect(store.size).toBe(2); + }); + + 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(); + expect(store.size).toBe(0); + }); + + 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 message text", 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"); + expect(msg!.raw).toBe("hey verify me: did:plc:abc123 thanks"); + }); +}); 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" From 66dc51cab63acb27cf0c54fce9e59bae6f094d0b Mon Sep 17 00:00:00 2001 From: orta Date: Fri, 13 Feb 2026 08:09:50 +0000 Subject: [PATCH 2/2] Track a username --- .../server/api/chat/messages.post.ts | 9 +-- .../server/api/relay/[...path].get.ts | 14 ++-- .../server/api/relay/ingest.post.ts | 10 +-- .../server/utils/chat-broadcast.ts | 2 + packages/relay/src/ingester.ts | 6 +- packages/relay/src/store.ts | 72 +++++++++++-------- packages/relay/src/types.ts | 4 +- packages/relay/tests/store.test.ts | 48 +++++++++---- 8 files changed, 104 insertions(+), 61 deletions(-) diff --git a/apps/keytrace.dev/server/api/chat/messages.post.ts b/apps/keytrace.dev/server/api/chat/messages.post.ts index 6c9a191..711d7ca 100644 --- a/apps/keytrace.dev/server/api/chat/messages.post.ts +++ b/apps/keytrace.dev/server/api/chat/messages.post.ts @@ -5,7 +5,7 @@ * 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, account: string, gateway?: string } + * Body: { text: string, username: string, userid?: string, account: string, gateway?: string } */ import { extractDid, extractPlatform } from "@keytrace/relay"; @@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event); - const { text, username, account, gateway } = body ?? {}; + const { text, username, userid, account, gateway } = body ?? {}; if (!text || typeof text !== "string") { throw createError({ statusCode: 400, statusMessage: "Missing text" }); @@ -40,15 +40,16 @@ export default defineEventHandler(async (event) => { let saved = false; if (did) { const store = getRelayStore(); - const result = await store.put(platform, username, did); + const result = await store.put(platform, username, did, userid); saved = !!result; - console.log(`[chat] DID saved: ${platform}/${username} → ${did}`); + 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(), diff --git a/apps/keytrace.dev/server/api/relay/[...path].get.ts b/apps/keytrace.dev/server/api/relay/[...path].get.ts index fb15ffb..9e9ac9c 100644 --- a/apps/keytrace.dev/server/api/relay/[...path].get.ts +++ b/apps/keytrace.dev/server/api/relay/[...path].get.ts @@ -1,8 +1,9 @@ /** - * GET /api/relay/:platform/:username + * GET /api/relay/:platform/:identifier * * Public endpoint for the runner to fetch verified DID messages from the relay store. - * Returns the latest DID verification for a given platform/username pair. + * 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"); @@ -15,14 +16,14 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: "Expected /api/relay/:platform/:username" }); } - const [platform, username] = parts; + const [platform, identifier] = parts; - if (!platform || !username) { - throw createError({ statusCode: 400, statusMessage: "Missing platform or username" }); + if (!platform || !identifier) { + throw createError({ statusCode: 400, statusMessage: "Missing platform or identifier" }); } const store = getRelayStore(); - const msg = await store.get(platform, decodeURIComponent(username)); + const msg = await store.get(platform, decodeURIComponent(identifier)); if (!msg) { throw createError({ statusCode: 404, statusMessage: "No verification found" }); @@ -31,6 +32,7 @@ export default defineEventHandler(async (event) => { 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 index dec09a5..7496782 100644 --- a/apps/keytrace.dev/server/api/relay/ingest.post.ts +++ b/apps/keytrace.dev/server/api/relay/ingest.post.ts @@ -4,7 +4,7 @@ * Receives DID verification messages from the Matterbridge ingester. * Protected by a bearer token (NUXT_RELAY_INGEST_TOKEN). * - * Body: { platform: string, username: string, did: string } + * Body: { platform: string, username: string, did: string, userid?: string } */ const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._:%-]+$/; @@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event); - const { platform, username, did } = body ?? {}; + const { platform, username, did, userid } = body ?? {}; if (!platform || typeof platform !== "string" || !PLATFORM_PATTERN.test(platform)) { throw createError({ statusCode: 400, statusMessage: "Invalid platform" }); @@ -38,9 +38,9 @@ export default defineEventHandler(async (event) => { } const store = getRelayStore(); - await store.put(platform, username, did); + await store.put(platform, username, did, userid); - console.log(`[relay] Ingested: ${platform}/${username} → ${did}`); + console.log(`[relay] Ingested: ${platform}/${userid ?? username} → ${did}`); - return { ok: true, platform, 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 index cd91447..b08f648 100644 --- a/apps/keytrace.dev/server/utils/chat-broadcast.ts +++ b/apps/keytrace.dev/server/utils/chat-broadcast.ts @@ -2,6 +2,8 @@ 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; diff --git a/packages/relay/src/ingester.ts b/packages/relay/src/ingester.ts index 21373c6..90149ec 100644 --- a/packages/relay/src/ingester.ts +++ b/packages/relay/src/ingester.ts @@ -34,7 +34,7 @@ function getConfig(): IngesterConfig { /** Forward a message to the keytrace.dev chat endpoint (broadcasts to SSE + saves DIDs) */ async function postToChat( config: IngesterConfig, - msg: { text: string; username: string; account: string; gateway?: string }, + msg: { text: string; username: string; userid?: string; account: string; gateway?: string }, ): Promise<{ ok: boolean; saved?: boolean }> { try { const res = await fetch(config.chatUrl, { @@ -117,11 +117,11 @@ async function connectStream(config: IngesterConfig): Promise { // Skip system events (like api_connected) if (msg.event) continue; - const { text, username, account, gateway } = msg; + 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, account, gateway }); + 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) { diff --git a/packages/relay/src/store.ts b/packages/relay/src/store.ts index 3fe544f..adb8ad1 100644 --- a/packages/relay/src/store.ts +++ b/packages/relay/src/store.ts @@ -5,44 +5,50 @@ const DID_PATTERN = /did:(plc|web):[a-zA-Z0-9._:%-]+/; /** Interface for relay message storage backends */ export interface RelayStore { - get(platform: string, username: string): Promise; - put(platform: string, username: string, text: string): Promise; - delete(platform: string, username: string): Promise; + 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. - * Keyed by "platform:username" — latest message per user per platform wins. - * Good for development and testing. + * 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, username: string): string { - return `${platform}:${username.toLowerCase()}`; + private key(platform: string, identifier: string): string { + return `${platform}:${identifier.toLowerCase()}`; } - async put(platform: string, username: string, text: string): Promise { - const did = extractDid(text); - if (!did) return undefined; + async put(platform: string, username: string, did: string, userid?: string): Promise { + const validDid = extractDid(did); + if (!validDid) return undefined; const msg: VerifiedMessage = { platform, username, - did, + userid, + did: validDid, timestamp: Date.now(), - raw: text, }; - this.messages.set(this.key(platform, username), msg); + // 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, username: string): Promise { - return this.messages.get(this.key(platform, username)); + async get(platform: string, identifier: string): Promise { + return this.messages.get(this.key(platform, identifier)); } - async delete(platform: string, username: string): Promise { - return this.messages.delete(this.key(platform, username)); + async delete(platform: string, identifier: string): Promise { + return this.messages.delete(this.key(platform, identifier)); } /** Number of stored messages */ @@ -59,7 +65,8 @@ export class MemoryStore implements RelayStore { /** * JSON-file/S3 store for verified DID messages. * Uses a loadJson/saveJson abstraction so it works with both local files and S3. - * Each message is stored as a separate JSON object at relay/{platform}/{username}.json + * 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( @@ -68,33 +75,38 @@ export class JsonStore implements RelayStore { private del: (key: string) => Promise, ) {} - private key(platform: string, username: string): string { - return `relay/${platform}/${username.toLowerCase()}.json`; + private path(platform: string, identifier: string): string { + return `relay/${platform}/${identifier.toLowerCase()}.json`; } - async put(platform: string, username: string, text: string): Promise { - const did = extractDid(text); - if (!did) return undefined; + async put(platform: string, username: string, did: string, userid?: string): Promise { + const validDid = extractDid(did); + if (!validDid) return undefined; const msg: VerifiedMessage = { platform, username, - did, + userid, + did: validDid, timestamp: Date.now(), - raw: text, }; - await this.save(this.key(platform, username), msg); + 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, username: string): Promise { - const msg = await this.load(this.key(platform, username)); + async get(platform: string, identifier: string): Promise { + const msg = await this.load(this.path(platform, identifier)); return msg ?? undefined; } - async delete(platform: string, username: string): Promise { + async delete(platform: string, identifier: string): Promise { try { - await this.del(this.key(platform, username)); + await this.del(this.path(platform, identifier)); return true; } catch { return false; diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts index fb70824..05c84a7 100644 --- a/packages/relay/src/types.ts +++ b/packages/relay/src/types.ts @@ -2,8 +2,10 @@ export interface VerifiedMessage { /** Platform identifier (e.g., "telegram", "signal", "discord") */ platform: string; - /** Sender's username on that platform */ + /** 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 */ diff --git a/packages/relay/tests/store.test.ts b/packages/relay/tests/store.test.ts index 29b2216..f9df75f 100644 --- a/packages/relay/tests/store.test.ts +++ b/packages/relay/tests/store.test.ts @@ -65,7 +65,7 @@ describe("MemoryStore", () => { expect(await store.get("telegram", "nobody")).toBeUndefined(); }); - it("ignores messages without a DID", async () => { + 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(); @@ -90,19 +90,10 @@ describe("MemoryStore", () => { expect(await store.get("telegram", "ALICE")).toBeDefined(); }); - it("tracks size", async () => { - expect(store.size).toBe(0); - await store.put("telegram", "alice", "did:plc:a"); - expect(store.size).toBe(1); - await store.put("signal", "bob", "did:plc:b"); - expect(store.size).toBe(2); - }); - 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(); - expect(store.size).toBe(0); }); it("clears all messages", async () => { @@ -112,10 +103,43 @@ describe("MemoryStore", () => { expect(store.size).toBe(0); }); - it("extracts DID from longer message text", async () => { + 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"); - expect(msg!.raw).toBe("hey verify me: did:plc:abc123 thanks"); + }); + + 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"); + }); }); });