Move chat to private authenticated API

This commit is contained in:
Dorian
2026-05-06 21:04:50 +01:00
parent cb21c693d0
commit 0ff7bc46fc
7 changed files with 202 additions and 174 deletions

View File

@@ -0,0 +1,44 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth/middleware.js";
import { addMessage, addReaction, listChat } from "./store.js";
export const chatRouter = Router();
const MessageBody = z.object({
content: z.string().trim().min(1).max(2000),
replyToId: z.string().max(128).optional(),
replyToPubkey: z.string().max(128).optional(),
});
const ReactionBody = z.object({
messageId: z.string().min(1).max(128),
content: z.string().trim().min(1).max(32),
});
chatRouter.use(requireAuth);
chatRouter.get("/", (_req, res) => {
res.json(listChat());
});
chatRouter.post("/messages", (req, res) => {
const body = MessageBody.parse(req.body);
const message = addMessage({
pubkey: req.session!.pubkey,
content: body.content,
...(body.replyToId ? { replyToId: body.replyToId } : {}),
...(body.replyToPubkey ? { replyToPubkey: body.replyToPubkey } : {}),
});
res.status(201).json(message);
});
chatRouter.post("/reactions", (req, res) => {
const body = ReactionBody.parse(req.body);
const reaction = addReaction({
pubkey: req.session!.pubkey,
messageId: body.messageId,
content: body.content,
});
res.status(201).json(reaction);
});

View File

@@ -0,0 +1,62 @@
export type StoredChatMessage = {
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
};
export type StoredChatReaction = {
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
};
const MAX_MESSAGES = 500;
const MAX_REACTIONS = 2000;
const messages: StoredChatMessage[] = [];
const reactions: StoredChatReaction[] = [];
export function listChat(): { messages: StoredChatMessage[]; reactions: StoredChatReaction[] } {
return { messages: [...messages], reactions: [...reactions] };
}
export function addMessage(input: {
pubkey: string;
content: string;
replyToId?: string;
replyToPubkey?: string;
}): StoredChatMessage {
const message: StoredChatMessage = {
id: crypto.randomUUID(),
pubkey: input.pubkey,
content: input.content,
createdAt: Math.floor(Date.now() / 1000),
replyToId: input.replyToId ?? "",
replyToPubkey: input.replyToPubkey ?? "",
};
messages.push(message);
if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES);
return message;
}
export function addReaction(input: {
pubkey: string;
messageId: string;
content: string;
}): StoredChatReaction {
const reaction: StoredChatReaction = {
id: crypto.randomUUID(),
pubkey: input.pubkey,
messageId: input.messageId,
content: input.content,
createdAt: Math.floor(Date.now() / 1000),
};
reactions.push(reaction);
if (reactions.length > MAX_REACTIONS) reactions.splice(0, reactions.length - MAX_REACTIONS);
return reaction;
}

View File

@@ -9,6 +9,7 @@ import fs from "node:fs";
import { config } from "./config.js";
import { logger } from "./logger.js";
import { authRouter } from "./auth/routes.js";
import { chatRouter } from "./chat/routes.js";
import { datumRouter } from "./datum/routes.js";
import { errorHandler } from "./errors.js";
@@ -61,6 +62,7 @@ export function buildApp() {
res.json({ ok: true });
});
app.use("/api/auth", authRouter);
app.use("/api/chat", chatRouter);
app.use("/api/datum", datumRouter);
const staticDir = config.staticDir

View File

@@ -204,7 +204,7 @@ async function scrollBottom(): Promise<void> {
<aside :class="['chat-drawer', { open }]">
<header>
<div>
<div class="label">encrypted nostr chat</div>
<div class="label">private nostr-login chat</div>
<h2 class="glow-cyan">mining desk heckle box</h2>
</div>
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>

View File

@@ -1,4 +1,4 @@
import type { ApiError, DatumSnapshot, LoginResponse } from "../types";
import type { ApiError, ChatSnapshot, DatumSnapshot, LoginResponse } from "../types";
import { buildNip98AuthHeader } from "./nip98";
const TOKEN_KEY = "gashboard.jwt";
@@ -61,6 +61,25 @@ async function authedGet<T>(path: string): Promise<T> {
return (await res.json()) as T;
}
async function authedPost<T>(path: string, body: unknown): Promise<T> {
const stored = loadToken();
if (!stored) throw new Error("Not logged in");
const res = await fetch(path, {
method: "POST",
headers: {
authorization: `Bearer ${stored.token}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status === 401) {
clearToken();
throw new Error("Session expired");
}
if (!res.ok) throw await asError(res);
return (await res.json()) as T;
}
export async function fetchSnapshot(): Promise<DatumSnapshot> {
return authedGet<DatumSnapshot>("/api/datum/stats");
}
@@ -69,3 +88,22 @@ export async function fetchChatPubkeys(): Promise<string[]> {
const res = await authedGet<{ pubkeys: string[] }>("/api/auth/chat-config");
return res.pubkeys;
}
export function fetchChat(): Promise<ChatSnapshot> {
return authedGet<ChatSnapshot>("/api/chat");
}
export function postChatMessage(body: {
content: string;
replyToId?: string;
replyToPubkey?: string;
}): Promise<ChatSnapshot["messages"][number]> {
return authedPost<ChatSnapshot["messages"][number]>("/api/chat/messages", body);
}
export function postChatReaction(body: {
messageId: string;
content: string;
}): Promise<ChatSnapshot["reactions"][number]> {
return authedPost<ChatSnapshot["reactions"][number]>("/api/chat/reactions", body);
}

View File

@@ -1,12 +1,11 @@
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
import type { Event as NostrEvent } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { SimplePool } from "nostr-tools/pool";
import { fetchChatPubkeys } from "./api";
import { getActiveSigner, hasActiveSigner } from "./signer";
import { fetchChat, postChatMessage, postChatReaction } from "./api";
import { hasActiveSigner } from "./signer";
const RELAYS = ["wss://relay.primal.net"];
const PRIVATE_CHAT_KIND = 4;
const CHAT_ROOM = "gashboard-chat";
const POLL_MS = 2500;
const pool = new SimplePool();
export type ChatMessage = {
@@ -32,28 +31,7 @@ export type ChatProfile = {
picture: string;
};
type PrivatePayload =
| {
room: typeof CHAT_ROOM;
v: 1;
type: "message";
id: string;
content: string;
createdAt: number;
replyToId?: string;
replyToPubkey?: string;
}
| {
room: typeof CHAT_ROOM;
v: 1;
type: "reaction";
id: string;
messageId: string;
content: string;
createdAt: number;
};
let cachedRecipients: string[] | null = null;
let reactionCache: ChatReaction[] = [];
export function canSendChat(): boolean {
return hasActiveSigner();
@@ -70,82 +48,65 @@ export function pubkeyFromNpub(npub: string | null): string | null {
}
export async function loadChat(): Promise<ChatMessage[]> {
const { messages } = await loadPrivateChat();
return dedupe(messages).sort((a, b) => a.createdAt - b.createdAt);
const snapshot = await fetchChat();
reactionCache = snapshot.reactions;
return snapshot.messages.sort((a, b) => a.createdAt - b.createdAt);
}
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
const wanted = new Set(messageIds);
const { reactions } = await loadPrivateChat();
return dedupeReactions(reactions.filter((r) => wanted.has(r.messageId)));
if (!reactionCache.length) reactionCache = (await fetchChat()).reactions;
return reactionCache.filter((r) => wanted.has(r.messageId));
}
export function subscribeChat(
onMessage: (message: ChatMessage) => void,
onReaction?: (reaction: ChatReaction) => void,
): { close: () => void } {
const signer = getActiveSigner();
const seenMessages = new Set<string>();
const seenReactions = new Set<string>();
let closed = false;
let sub: { close: () => void } | null = null;
if (!signer) return { close: () => {} };
void signer.getPublicKey().then((ownPubkey) => {
async function poll(): Promise<void> {
if (closed) return;
const since = Math.floor(Date.now() / 1000) - 60;
sub = pool.subscribeMany(RELAYS, [{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], since }], {
onevent(event) {
void decryptEvent(event).then((payload) => {
if (!payload || closed) return;
if (payload.type === "reaction") onReaction?.(asReaction(payload, event.pubkey));
else onMessage(asMessage(payload, event.pubkey));
});
},
});
});
const snapshot = await fetchChat();
for (const message of snapshot.messages) {
if (seenMessages.has(message.id)) continue;
seenMessages.add(message.id);
onMessage(message);
}
for (const reaction of snapshot.reactions) {
if (seenReactions.has(reaction.id)) continue;
seenReactions.add(reaction.id);
onReaction?.(reaction);
}
}
const timer = window.setInterval(() => {
void poll().catch(() => {});
}, POLL_MS);
void poll().catch(() => {});
return {
close() {
closed = true;
sub?.close();
window.clearInterval(timer);
},
};
}
export async function sendChat(
export function sendChat(
content: string,
replyTo?: { id: string; pubkey: string },
): Promise<ChatMessage> {
const signer = getActiveSigner();
if (!signer) throw new Error("Private chat needs the active signer. Reconnect your signer to send.");
const pubkey = await signer.getPublicKey();
const payload: PrivatePayload = {
room: CHAT_ROOM,
v: 1,
type: "message",
id: crypto.randomUUID(),
return postChatMessage({
content,
createdAt: Math.floor(Date.now() / 1000),
...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}),
};
await publishPrivatePayload(payload);
return asMessage(payload, pubkey);
});
}
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
const signer = getActiveSigner();
if (!signer) throw new Error("Private reactions need the active signer. Reconnect your signer to react.");
const pubkey = await signer.getPublicKey();
const payload: PrivatePayload = {
room: CHAT_ROOM,
v: 1,
type: "reaction",
id: crypto.randomUUID(),
messageId: message.id,
content,
createdAt: Math.floor(Date.now() / 1000),
};
await publishPrivatePayload(payload);
return asReaction(payload, pubkey);
export function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
return postChatReaction({ messageId: message.id, content });
}
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
@@ -176,100 +137,3 @@ export async function loadProfiles(pubkeys: string[]): Promise<Record<string, Ch
export function shortPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
}
async function loadPrivateChat(): Promise<{ messages: ChatMessage[]; reactions: ChatReaction[] }> {
const signer = getActiveSigner();
if (!signer) return { messages: [], reactions: [] };
const ownPubkey = await signer.getPublicKey();
const events = await pool.querySync(
RELAYS,
{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], limit: 500 },
{ maxWait: 3500 },
);
const payloads = await Promise.all(events.map((event) => decryptEvent(event)));
const messages: ChatMessage[] = [];
const reactions: ChatReaction[] = [];
for (let i = 0; i < events.length; i += 1) {
const payload = payloads[i];
const event = events[i];
if (!payload || !event) continue;
if (payload.type === "reaction") reactions.push(asReaction(payload, event.pubkey));
else messages.push(asMessage(payload, event.pubkey));
}
return { messages, reactions };
}
async function publishPrivatePayload(payload: PrivatePayload): Promise<void> {
const signer = getActiveSigner();
if (!signer) throw new Error("Private chat needs the active signer.");
const recipients = await getRecipients();
const plaintext = JSON.stringify(payload);
const signedEvents = await Promise.all(
recipients.map(async (recipient) => {
const encrypted = await signer.encrypt(recipient, plaintext);
const template: EventTemplate = {
kind: PRIVATE_CHAT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
["p", recipient],
["client", "gashboard"],
],
content: encrypted,
};
return signer.signEvent(template);
}),
);
const results = await Promise.allSettled(signedEvents.flatMap((event) => pool.publish(RELAYS, event)));
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("Could not publish private chat event to relay");
}
}
async function getRecipients(): Promise<string[]> {
const signer = getActiveSigner();
const own = await signer?.getPublicKey();
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
}
async function decryptEvent(event: NostrEvent): Promise<PrivatePayload | null> {
const signer = getActiveSigner();
if (!signer) return null;
try {
const plaintext = await signer.decrypt(event.pubkey, event.content);
const payload = JSON.parse(plaintext) as PrivatePayload;
if (payload.room !== CHAT_ROOM || payload.v !== 1) return null;
return payload;
} catch {
return null;
}
}
function asMessage(payload: Extract<PrivatePayload, { type: "message" }>, pubkey: string): ChatMessage {
return {
id: payload.id,
pubkey,
content: payload.content,
createdAt: payload.createdAt,
replyToId: payload.replyToId ?? "",
replyToPubkey: payload.replyToPubkey ?? "",
};
}
function asReaction(payload: Extract<PrivatePayload, { type: "reaction" }>, pubkey: string): ChatReaction {
return {
id: payload.id,
pubkey,
messageId: payload.messageId,
content: payload.content || "+",
createdAt: payload.createdAt,
};
}
function dedupe(messages: ChatMessage[]): ChatMessage[] {
return [...new Map(messages.map((m) => [m.id, m])).values()];
}
function dedupeReactions(reactions: ChatReaction[]): ChatReaction[] {
return [...new Map(reactions.map((r) => [r.id, r])).values()];
}

View File

@@ -86,6 +86,24 @@ export type LoginResponse = {
expiresAt: number;
};
export type ChatSnapshot = {
messages: Array<{
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
}>;
reactions: Array<{
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
}>;
};
export type ApiError = { error: { code: string; message: string } };
declare global {