Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/keytrace.dev/components/ui/ChatMessageRow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<template>
<div
class="flex items-start gap-3 px-3 py-1.5 rounded-md transition-colors"
:class="message.saved ? 'bg-emerald-500/5 border border-emerald-500/20' : 'hover:bg-zinc-800/30'"
>
<!-- Timestamp -->
<span class="text-[11px] text-zinc-600 font-mono shrink-0 pt-0.5">
{{ formatTime(message.timestamp) }}
</span>

<!-- Platform badge -->
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0"
:class="platformBadgeClass"
>
<component :is="platformIcon" class="w-3 h-3" />
{{ message.platform }}
</span>

<!-- Username -->
<span class="text-sm font-medium text-zinc-300 shrink-0">
{{ message.username }}
</span>

<!-- Message text -->
<span class="text-sm text-zinc-400 break-all min-w-0 flex-1">
{{ message.text }}
</span>

<!-- Saved indicator -->
<span
v-if="message.saved"
class="ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-emerald-500/15 text-emerald-400 border border-emerald-500/20 shrink-0"
>
<CheckCircleIcon class="w-3 h-3" />
saved
</span>
</div>
</template>

<script setup lang="ts">
import { computed } from "vue";
import {
CheckCircle as CheckCircleIcon,
MessageSquare,
Hash,
AtSign,
Globe,
Smartphone,
} from "lucide-vue-next";

interface ChatMessage {
id: string;
text: string;
username: string;
platform: string;
timestamp: number;
did?: string;
saved?: boolean;
}

const props = defineProps<{
message: ChatMessage;
}>();

const platformIcons: Record<string, any> = {
telegram: Smartphone,
signal: Smartphone,
whatsapp: Smartphone,
discord: Hash,
matrix: Globe,
slack: Hash,
mastodon: AtSign,
irc: Hash,
};

const platformIcon = computed(() => platformIcons[props.message.platform] ?? MessageSquare);

const platformColors: Record<string, string> = {
telegram: "bg-blue-500/15 text-blue-400 border border-blue-500/20",
signal: "bg-blue-500/15 text-blue-300 border border-blue-500/20",
whatsapp: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
discord: "bg-indigo-500/15 text-indigo-400 border border-indigo-500/20",
matrix: "bg-teal-500/15 text-teal-400 border border-teal-500/20",
slack: "bg-purple-500/15 text-purple-400 border border-purple-500/20",
mastodon: "bg-violet-500/15 text-violet-400 border border-violet-500/20",
irc: "bg-zinc-500/15 text-zinc-400 border border-zinc-500/20",
};

const platformBadgeClass = computed(
() => platformColors[props.message.platform] ?? "bg-zinc-500/15 text-zinc-400 border border-zinc-500/20",
);

function formatTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
</script>
6 changes: 5 additions & 1 deletion apps/keytrace.dev/components/ui/NavBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

<!-- Mobile: icon buttons. Desktop: full nav -->
<div class="flex items-center gap-2">
<NuxtLink to="/chat" class="sm:px-3 sm:py-1.5 p-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors">
<RadioIcon class="w-5 h-5 sm:hidden" />
<span class="hidden sm:inline">Relay</span>
</NuxtLink>
<NuxtLink v-if="showAddClaim" to="/add" class="sm:px-3 sm:py-1.5 p-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors">
<PlusIcon class="w-5 h-5 sm:hidden" />
<span class="hidden sm:inline">Add claim</span>
Expand All @@ -39,7 +43,7 @@
</template>

<script setup lang="ts">
import { Plus as PlusIcon, User as UserIcon, TriangleAlert as TriangleAlertIcon } from "lucide-vue-next";
import { Plus as PlusIcon, Radio as RadioIcon, User as UserIcon, TriangleAlert as TriangleAlertIcon } from "lucide-vue-next";

defineProps<{
avatarUrl?: string;
Expand Down
1 change: 1 addition & 0 deletions apps/keytrace.dev/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,6 @@ export default defineNuxtConfig({
s3Endpoint: "",
keytraceDid: "",
keytracePassword: "",
relayIngestToken: "",
},
});
1 change: 1 addition & 0 deletions apps/keytrace.dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@atproto/oauth-client-node": "^0.3.0",
"@aws-sdk/client-s3": "^3.700.0",
"@keytrace/claims": "workspace:*",
"@keytrace/relay": "workspace:*",
"@nuxtjs/google-fonts": "^3.2.0",
"@nuxtjs/tailwindcss": "^6.14.0",
"lucide-vue-next": "^0.563.0",
Expand Down
106 changes: 106 additions & 0 deletions apps/keytrace.dev/pages/chat.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<template>
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Page header -->
<div class="mb-6">
<h1 class="text-2xl font-semibold text-zinc-100 tracking-tight">Public Relay</h1>
<p class="text-sm text-zinc-500 mt-1">
Live messages from chat platforms bridged through Matterbridge. Messages containing a DID are saved for identity verification.
</p>
</div>

<!-- Connection status -->
<div class="mb-4 flex items-center gap-2 text-xs text-zinc-500">
<span class="w-2 h-2 rounded-full" :class="connected ? 'bg-emerald-500 animate-pulse' : 'bg-red-500'" />
{{ connected ? `Connected` : "Reconnecting..." }}
<span v-if="connected && listenerInfo" class="text-zinc-600">{{ listenerInfo }}</span>
</div>

<!-- Message feed -->
<div
ref="feedRef"
class="space-y-0.5 max-h-[70vh] overflow-y-auto rounded-lg border border-zinc-800/50 bg-kt-surface p-2"
>
<ChatMessageRow v-for="msg in messages" :key="msg.id" :message="msg" />

<!-- Empty state -->
<div v-if="messages.length === 0" class="text-center py-16 text-zinc-600 text-sm">Waiting for messages...</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from "vue";

useSeoMeta({
title: "Public Relay - Keytrace",
description: "Live chat relay showing identity verification messages from 20+ platforms.",
});

interface ChatMessage {
id: string;
text: string;
username: string;
platform: string;
timestamp: number;
did?: string;
saved?: boolean;
}

const messages = ref<ChatMessage[]>([]);
const connected = ref(false);
const feedRef = ref<HTMLElement | null>(null);
const listenerInfo = ref("");
let eventSource: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;

function isNearBottom(): boolean {
if (!feedRef.value) return true;
const { scrollTop, scrollHeight, clientHeight } = feedRef.value;
return scrollHeight - scrollTop - clientHeight < 100;
}

function scrollToBottom() {
nextTick(() => {
if (feedRef.value && isNearBottom()) {
feedRef.value.scrollTop = feedRef.value.scrollHeight;
}
});
}

function connect() {
if (eventSource) {
eventSource.close();
}

eventSource = new EventSource("/api/chat/stream");

eventSource.addEventListener("message", (e) => {
const msg: ChatMessage = JSON.parse(e.data);
messages.value.push(msg);
// Cap at 500 messages client-side
if (messages.value.length > 500) {
messages.value = messages.value.slice(-500);
}
scrollToBottom();
});

eventSource.onopen = () => {
connected.value = true;
};

eventSource.onerror = () => {
connected.value = false;
eventSource?.close();
eventSource = null;
reconnectTimer = setTimeout(connect, 3000);
};
}

onMounted(() => connect());

onUnmounted(() => {
eventSource?.close();
eventSource = null;
if (reconnectTimer) clearTimeout(reconnectTimer);
});
</script>
63 changes: 63 additions & 0 deletions apps/keytrace.dev/server/api/chat/messages.post.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
23 changes: 23 additions & 0 deletions apps/keytrace.dev/server/api/chat/stream.get.ts
Original file line number Diff line number Diff line change
@@ -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();
});
39 changes: 39 additions & 0 deletions apps/keytrace.dev/server/api/relay/[...path].get.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});
Loading