feat(chat): NostrChatProvider — global cross-server chat over nostr (Pillar 5)

Layers global chat on the existing ChatProvider seam without changing callers.
NostrChatProvider DECORATES the local SocketChatProvider: every message is still
delivered in-game + persisted exactly as before, and a global message is also
signed and fanned out to nostr relays (relay.smirk.cash). Messages from the relay
are delivered locally-only (never re-published) so there are no echo loops.

North-star fit: relay.smirk.cash's default inbox-outbox policy only lets
REGISTERED Smirk npubs publish — so putting a message in global chat is a concrete
reason to sign in with Smirk. Signer is injectable: a server bridge key today
(createBridgeSigner, must be a registered npub to publish; omit -> receive-only),
each player's own Smirk npub later (chat identity == wallet identity) by swapping
the signer, no provider change.

- src/utils/nostrLoader.js: shared nostr-tools loader (prod require / Jest bundle
  shim), factored from the nip98 pattern.
- src/utils/escapeChat.js: entity-escape + length cap, applied to untrusted remote
  messages (they have no trusted caller, unlike local ones).
- src/network/chat/nostr/{NostrTransport,bridgeSigner}.js: SimplePool transport
  (degrades to noop if nostr-tools/ws unavailable — enabling can't crash the
  server) + server key signer.
- src/network/chat/NostrChatProvider.js: decorator; echo dedupe (own pubkey +
  seen-id FIFO), remote-delivery rate limit, XSS escape on remote content/username.
- src/network/chat/index.js buildChatProvider: opt-in via NOSTR_CHAT_ENABLED /
  NOSTR_CHAT_SCOPE / NOSTR_RELAYS / NOSTR_CHAT_TAG / NOSTR_BRIDGE_SK. chatHandler
  wraps its local provider through it and calls initialize().

Behavior-preserving: NOSTR_CHAT_ENABLED unset (today) => the plain local provider
is returned, chat is byte-for-byte unchanged. Server stays moderation authority
(bans/rate-limit/history in Postgres via the local provider). Tests: +10 (decorate/
fan-out/room-skip, remote deliver+escape+dedupe+rate-limit, factory, real schnorr
round-trip). Suite 54 suites / 382 tests green. Not yet live against relay.smirk.cash.
This commit is contained in:
jwinterm
2026-07-10 18:51:13 -04:00
parent f7c9fb7366
commit 12b887f8f1
8 changed files with 497 additions and 5 deletions
+142
View File
@@ -0,0 +1,142 @@
const ChatProvider = require('./ChatProvider');
const { escapeChatText } = require('../../utils/escapeChat');
/**
* NostrChatProvider — global cross-server chat over nostr, layered on the existing ChatProvider seam.
*
* It DECORATES a local provider (SocketChatProvider): every message is still delivered in-game +
* persisted exactly as before, and on top of that a global message is fanned out to nostr relays
* (relay.smirk.cash) so other game servers/modes see it. Messages arriving from the relay are
* delivered locally-only (never re-published), so there are no echo loops.
*
* publish(local msg) → local.publish (in-game + history) + sign & relay.publish (global fan-out)
* relay event (remote)→ local.publish only (in-game + history), text/username escaped
*
* Topology: client → server → relay. The server stays the moderation authority (bans, rate-limit,
* history in Postgres via the local provider); the relay is transport + fan-out. Signing with a
* registered Smirk npub (the bridge key, or later each player's own) is what the relay's
* inbox-outbox policy requires to publish — so global chat is a concrete reason to sign in with Smirk.
*
* Behavior-preserving: with no transport/signer (nostr disabled) this is exactly the local provider.
*/
class NostrChatProvider extends ChatProvider {
constructor({
local,
transport = null,
signer = null,
scope = 'global',
channelTag = 'wowngeon-global',
kind = 1,
serverOrigin = null,
debugManager = null,
maxRemotePerMin = 120,
now = () => Date.now()
} = {}) {
super();
if (!local) throw new Error('NostrChatProvider requires a local provider');
this.local = local;
this.transport = transport;
this.signer = signer;
this.scope = scope;
this.channelTag = channelTag;
this.kind = kind;
this.serverOrigin = serverOrigin;
this.debugManager = debugManager;
this.maxRemotePerMin = maxRemotePerMin;
this.now = now;
this._seen = new Set(); // event ids we've delivered/published, for echo dedupe
this._seenOrder = []; // bounded FIFO of the same ids
this._remoteTimes = []; // recent remote-delivery timestamps (rate limit)
}
_globalEnabled() {
return this.scope === 'global' && !!this.transport;
}
async initialize() {
await this.local.initialize();
if (!this._globalEnabled()) return;
const since = Math.floor(this.now() / 1000);
// Only future messages — no history flood on connect (local history covers the past).
this.transport.subscribe([{ kinds: [this.kind], '#t': [this.channelTag], since }], (ev) => this._onRemote(ev));
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`[nostr] chat subscribed: tag=${this.channelTag} kind=${this.kind} publish=${this.signer ? 'on' : 'read-only'}`);
}
}
async publish(msg = {}) {
// 1) Always deliver + persist locally, exactly as before.
await this.local.publish(msg);
// 2) Fan out global messages to nostr (skip room-scoped and skip messages we received
// from the relay, which arrive here only via local delivery — never through publish).
if (msg.remote || msg.scope !== 'global' || !this._globalEnabled() || !this.signer) return;
try {
const template = {
kind: this.kind,
created_at: Math.floor((msg.ts || this.now()) / 1000),
tags: [['t', this.channelTag], ['n', String(msg.username || '').slice(0, 32)]],
content: String(msg.text == null ? '' : msg.text)
};
if (this.serverOrigin) template.tags.push(['origin', String(this.serverOrigin)]);
const signed = this.signer.sign(template);
if (signed && signed.id) this._markSeen(signed.id); // our own echo will be ignored
await this.transport.publish(signed);
} catch (err) {
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] fan-out failed:', err.message);
}
}
// A message arriving from the relay: deliver locally only (no re-publish → no loop).
_onRemote(event) {
if (!event || typeof event !== 'object') return;
if (typeof event.content !== 'string' || !Array.isArray(event.tags)) return;
// Ours (echo) or already seen → drop.
if (this.signer && event.pubkey === this.signer.pubkey) return;
if (event.id && !this._markSeen(event.id)) return;
// Rate-limit remote delivery so a hostile relay can't flood in-game clients.
if (!this._allowRemote()) return;
const nTag = event.tags.find(t => Array.isArray(t) && t[0] === 'n');
const rawName = (nTag && nTag[1]) || (typeof event.pubkey === 'string' ? event.pubkey.slice(0, 8) : 'nostr');
const username = escapeChatText(rawName, 32) || 'nostr';
const text = escapeChatText(event.content, 200);
const ts = Number.isFinite(event.created_at) ? event.created_at * 1000 : this.now();
// remote:true prevents re-fan-out; local.publish delivers to in-game clients + persists.
this.local.publish({ scope: 'global', username, text, ts, userId: null, remote: true })
.catch(err => console.error('[nostr] local deliver of remote failed:', err.message));
}
_allowRemote() {
const cutoff = this.now() - 60000;
this._remoteTimes = this._remoteTimes.filter(t => t >= cutoff);
if (this._remoteTimes.length >= this.maxRemotePerMin) return false;
this._remoteTimes.push(this.now());
return true;
}
// Returns true if the id is new (and records it); false if already seen. Bounded FIFO.
_markSeen(id) {
if (this._seen.has(id)) return false;
this._seen.add(id);
this._seenOrder.push(id);
if (this._seenOrder.length > 1000) {
const old = this._seenOrder.shift();
this._seen.delete(old);
}
return true;
}
async getHistory(opts) {
return this.local.getHistory(opts);
}
async shutdown() {
try { if (this.transport) this.transport.close(); } catch (_) {}
await this.local.shutdown();
}
}
module.exports = NostrChatProvider;
+46
View File
@@ -0,0 +1,46 @@
const NostrChatProvider = require('./NostrChatProvider');
const NostrTransport = require('./nostr/NostrTransport');
const { createBridgeSigner } = require('./nostr/bridgeSigner');
function parseBool(v) {
if (v == null) return false;
return ['1', 'true', 'yes', 'on'].includes(String(v).trim().toLowerCase());
}
/**
* Build the chat provider for the given local (in-game) provider. Single opt-in seam for Pillar 5.
*
* Behavior-preserving: with NOSTR_CHAT_ENABLED unset (today) the plain local provider is returned
* unchanged. Enable to layer global cross-server chat over nostr:
* NOSTR_CHAT_ENABLED=true
* NOSTR_CHAT_SCOPE=global|local (local == plain local chat; global fans out over relays)
* NOSTR_RELAYS=wss://relay.smirk.cash[,...]
* NOSTR_CHAT_TAG=wowngeon-global (the shared channel topic)
* NOSTR_CHAT_KIND=1
* NOSTR_BRIDGE_SK=<nsec|hex> (server bridge key; must be a registered Smirk npub to publish.
* Omit → receive-only global chat.)
*/
function buildChatProvider({ local, env = process.env, debugManager = null } = {}) {
if (!local) throw new Error('buildChatProvider requires a local provider');
if (!parseBool(env.NOSTR_CHAT_ENABLED)) return local;
const scope = (env.NOSTR_CHAT_SCOPE || 'global').toLowerCase();
if (scope !== 'global') return local; // local-only is exactly the plain provider
const relays = (env.NOSTR_RELAYS || 'wss://relay.smirk.cash').split(',').map(s => s.trim()).filter(Boolean);
const transport = new NostrTransport({ relays, debugManager });
const signer = createBridgeSigner(env.NOSTR_BRIDGE_SK);
return new NostrChatProvider({
local,
transport,
signer,
scope: 'global',
channelTag: env.NOSTR_CHAT_TAG || 'wowngeon-global',
kind: parseInt(env.NOSTR_CHAT_KIND || '1', 10) || 1,
serverOrigin: env.NOSTR_SERVER_ORIGIN || env.SERVER_ID || null,
debugManager
});
}
module.exports = { buildChatProvider, NostrChatProvider, NostrTransport };
+65
View File
@@ -0,0 +1,65 @@
const { loadNostrTools } = require('../../../utils/nostrLoader');
/**
* Thin transport over nostr-tools SimplePool: connect to relays, publish signed events, subscribe
* to filters. Isolated behind this class so NostrChatProvider can be unit-tested with a fake
* transport (no network). Degrades to a warn-and-noop if nostr-tools/ws can't load, so enabling
* chat can never crash the server.
*
* Reading (REQ/subscribe) is open on relay.smirk.cash; only publishing is policy-gated.
*/
class NostrTransport {
constructor({ relays = ['wss://relay.smirk.cash'], debugManager = null } = {}) {
this.relays = Array.isArray(relays) ? relays.filter(Boolean) : [];
this.debugManager = debugManager;
this.pool = null;
this._sub = null;
this._ok = false;
}
connect() {
if (this.pool || !this.relays.length) return this._ok;
try {
const tools = loadNostrTools();
if (typeof tools.useWebSocketImplementation === 'function') {
try { tools.useWebSocketImplementation(require('ws')); } catch (_) { /* browser/global WS */ }
}
this.pool = new tools.SimplePool();
this._ok = true;
} catch (err) {
console.error('[nostr] transport disabled (nostr-tools/ws unavailable):', err.message);
this._ok = false;
}
return this._ok;
}
async publish(signedEvent) {
if (!this.connect()) return;
try {
await Promise.any(this.pool.publish(this.relays, signedEvent));
} catch (err) {
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] publish failed:', err?.message || err);
}
}
subscribe(filters, onEvent) {
if (!this.connect()) return;
try {
this._sub = this.pool.subscribeMany(this.relays, filters, {
onevent: (ev) => { try { onEvent(ev); } catch (e) { console.error('[nostr] onEvent error:', e.message); } }
});
} catch (err) {
console.error('[nostr] subscribe failed:', err.message);
}
}
close() {
try { if (this._sub && typeof this._sub.close === 'function') this._sub.close(); } catch (_) {}
try { if (this.pool && typeof this.pool.close === 'function') this.pool.close(this.relays); } catch (_) {}
this._sub = null;
this.pool = null;
this._ok = false;
}
}
module.exports = NostrTransport;
+61
View File
@@ -0,0 +1,61 @@
const { loadNostrTools } = require('../../../utils/nostrLoader');
/**
* A signer produces signed nostr events for outbound chat. Contract:
* { pubkey: <64-hex x-only>, sign(template) -> signedEvent } (template: {kind, created_at, tags, content})
*
* createBridgeSigner builds a SERVER bridge signer from a secret key (hex or nsec). This is the
* drop-in model: the game server signs cross-server chat with one key. For global chat to be
* ACCEPTED by relay.smirk.cash (default `inbox-outbox` policy → only registered npubs may publish),
* this bridge pubkey must be registered as a Smirk npub (an ops step, like the BTCPay API key).
* A future upgrade signs per-message with each player's OWN Smirk npub (window.smirk.signNostrEvent)
* so chat identity == wallet identity cryptographically — the provider stays signer-agnostic, so
* that path only swaps the signer.
*
* Returns null when no key is configured → outbound nostr disabled (the provider still receives).
*/
function createBridgeSigner(secret) {
if (!secret) return null;
let tools, sk, pubkey;
try {
tools = loadNostrTools();
sk = normalizeSecret(secret, tools);
pubkey = tools.getPublicKey(sk);
} catch (err) {
console.error('[nostr] bridge signer disabled — bad NOSTR_BRIDGE_SK:', err.message);
return null;
}
return {
pubkey,
sign(template) {
// finalizeEvent fills pubkey/id/sig and stamps created_at if absent.
return tools.finalizeEvent(
{
kind: template.kind,
created_at: template.created_at || Math.floor(Date.now() / 1000),
tags: template.tags || [],
content: template.content || ''
},
sk
);
}
};
}
// Accept an nsec bech32 or a 64-hex string; return the Uint8Array secret nostr-tools expects.
function normalizeSecret(secret, tools) {
const s = String(secret).trim();
if (s.startsWith('nsec1')) {
const dec = tools.nip19.decode(s);
if (dec.type !== 'nsec') throw new Error('not an nsec');
return dec.data; // Uint8Array
}
if (/^[0-9a-fA-F]{64}$/.test(s)) {
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) bytes[i] = parseInt(s.substr(i * 2, 2), 16);
return bytes;
}
throw new Error('expected nsec or 64-hex secret key');
}
module.exports = { createBridgeSigner };
+13 -5
View File
@@ -5,6 +5,7 @@
const ChatHistoryManager = require('./chatHistoryManager');
const SocketChatProvider = require('./chat/SocketChatProvider');
const { buildChatProvider } = require('./chat');
const { clientIp, stableId } = require('./rateLimitContext');
class ChatHandler {
@@ -25,16 +26,18 @@ class ChatHandler {
maxHistoryMessages: 50
});
// Chat delivery + history go through a ChatProvider seam so the backend is swappable
// (e.g. a Nostr channel later). The default provider shares the ChatHistoryManager
// instance above, so ban checks / stats that read this.chatHistory keep working and
// lifecycle (initialize/shutdown) stays owned here.
this.chatProvider = new SocketChatProvider({
// Chat delivery + history go through a ChatProvider seam so the backend is swappable.
// The local provider shares the ChatHistoryManager instance above, so ban checks / stats
// that read this.chatHistory keep working and lifecycle stays owned here. buildChatProvider
// optionally wraps it with a NostrChatProvider for global cross-server chat (Pillar 5) when
// NOSTR_CHAT_ENABLED is set; otherwise it returns this local provider unchanged.
const localChatProvider = new SocketChatProvider({
io: this.io,
broadcastManager: this.broadcastManager,
debugManager: this.debugManager,
historyManager: this.chatHistory
});
this.chatProvider = buildChatProvider({ local: localChatProvider, debugManager: this.debugManager });
// Memory leak prevention - cleanup old timestamps
this._chatLastSent = new Map();
@@ -48,6 +51,11 @@ class ChatHandler {
*/
async initialize() {
await this.chatHistory.initialize();
// Lets a NostrChatProvider connect + subscribe to the relay. No-op for the plain local
// provider (history is already initialized above and injected, not owned by the provider).
if (this.chatProvider && typeof this.chatProvider.initialize === 'function') {
await this.chatProvider.initialize();
}
}
/**
+19
View File
@@ -0,0 +1,19 @@
/**
* HTML-entity escape + length cap for chat text. Mirrors the inline escaping ChatHandler applies
* to local messages, so untrusted remote (nostr) messages are sanitized identically before they
* are delivered to browsers. Escaping is the caller's job per the ChatProvider contract — remote
* events have no trusted caller, so the NostrChatProvider applies this.
*/
const DEFAULT_MAX = 200;
function escapeChatText(input, maxLen = DEFAULT_MAX) {
return String(input == null ? '' : input)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.slice(0, maxLen);
}
module.exports = { escapeChatText, DEFAULT_MAX };
+23
View File
@@ -0,0 +1,23 @@
/**
* Load nostr-tools in a way that works in BOTH runtimes (see the same shim in utils/nip98.js):
* - Production (plain Node >=22): a normal require() succeeds — Node natively require()s
* nostr-tools' ESM-only transitive deps (@noble/*).
* - A CJS-only test runtime (Jest without --experimental-vm-modules) cannot parse those ESM
* deps, so we fall back to nostr-tools' pre-bundled self-contained IIFE build.
* Same public API either way. Shared so chat + auth load nostr identically.
*/
function loadNostrTools() {
try {
return require('nostr-tools');
} catch (_e) {
const fs = require('fs');
const path = require('path');
const cjsIndex = require.resolve('nostr-tools');
const bundlePath = path.join(path.dirname(cjsIndex), '..', 'nostr.bundle.js');
const src = fs.readFileSync(bundlePath, 'utf8');
// eslint-disable-next-line no-new-func
return new Function(`${src}\nreturn NostrTools;`)();
}
}
module.exports = { loadNostrTools };
+128
View File
@@ -0,0 +1,128 @@
/**
* Pillar 5 — global cross-server chat over nostr, layered on the ChatProvider seam.
*/
const NostrChatProvider = require('../src/network/chat/NostrChatProvider');
const { buildChatProvider } = require('../src/network/chat');
const { createBridgeSigner } = require('../src/network/chat/nostr/bridgeSigner');
const { loadNostrTools } = require('../src/utils/nostrLoader');
function fakeLocal() {
return {
initialize: jest.fn().mockResolvedValue(),
publish: jest.fn().mockResolvedValue(),
getHistory: jest.fn().mockResolvedValue(['history']),
shutdown: jest.fn().mockResolvedValue()
};
}
function fakeTransport() {
return {
publish: jest.fn().mockResolvedValue(),
subscribe: jest.fn(function (filters, onEvent) { this.filters = filters; this.onEvent = onEvent; }),
close: jest.fn()
};
}
let _n = 0;
function fakeSigner(pubkey = 'ab'.repeat(32)) {
return { pubkey, sign: (tpl) => ({ ...tpl, id: `evt-${++_n}`, pubkey, sig: 'sig' }) };
}
describe('NostrChatProvider decorates the local provider', () => {
test('publish always delivers locally; getHistory/shutdown delegate', async () => {
const local = fakeLocal();
const p = new NostrChatProvider({ local }); // no transport/signer
await p.publish({ scope: 'global', username: 'bob', text: 'hi' });
expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({ text: 'hi' }));
expect(await p.getHistory({ scope: 'global' })).toEqual(['history']);
await p.shutdown();
expect(local.shutdown).toHaveBeenCalled();
});
test('global message is signed and fanned out to the relay', async () => {
const local = fakeLocal(), transport = fakeTransport(), signer = fakeSigner();
const p = new NostrChatProvider({ local, transport, signer, channelTag: 'wowngeon-global', kind: 1 });
await p.publish({ scope: 'global', username: 'bob', text: 'gm', ts: 1_700_000_000_000 });
expect(local.publish).toHaveBeenCalled();
expect(transport.publish).toHaveBeenCalledTimes(1);
const ev = transport.publish.mock.calls[0][0];
expect(ev.kind).toBe(1);
expect(ev.content).toBe('gm');
expect(ev.tags).toEqual(expect.arrayContaining([['t', 'wowngeon-global'], ['n', 'bob']]));
expect(ev.created_at).toBe(1_700_000_000); // ms -> s
});
test('room-scoped and remote messages are NOT fanned out', async () => {
const local = fakeLocal(), transport = fakeTransport(), signer = fakeSigner();
const p = new NostrChatProvider({ local, transport, signer });
await p.publish({ scope: 'tavern:1', username: 'x', text: 'local only' });
await p.publish({ scope: 'global', username: 'x', text: 'echo', remote: true });
expect(local.publish).toHaveBeenCalledTimes(2);
expect(transport.publish).not.toHaveBeenCalled();
});
});
describe('NostrChatProvider receives remote messages', () => {
function connected() {
const local = fakeLocal(), transport = fakeTransport(), signer = fakeSigner();
const p = new NostrChatProvider({ local, transport, signer, now: () => 1_700_000_000_000 });
return { local, transport, signer, p };
}
test('a remote event is delivered locally-only, escaped, never re-published', async () => {
const { local, transport, p } = connected();
await p.initialize();
expect(transport.subscribe).toHaveBeenCalled();
p.onEvent = transport.onEvent; // convenience
transport.onEvent({ id: 'r1', pubkey: 'cc'.repeat(32), created_at: 1_700_000_000, content: '<b>hi</b>', tags: [['t', 'wowngeon-global'], ['n', 'alice']] });
expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({
scope: 'global', username: 'alice', text: '&lt;b&gt;hi&lt;/b&gt;', remote: true
}));
expect(transport.publish).not.toHaveBeenCalled(); // no echo loop
});
test('our own echoes and duplicate ids are dropped', async () => {
const { local, transport, signer, p } = connected();
await p.initialize();
transport.onEvent({ id: 'mine', pubkey: signer.pubkey, content: 'x', tags: [] }); // our pubkey -> drop
transport.onEvent({ id: 'dup', pubkey: 'cc'.repeat(32), content: 'first', tags: [] });
transport.onEvent({ id: 'dup', pubkey: 'cc'.repeat(32), content: 'again', tags: [] }); // same id -> drop
expect(local.publish).toHaveBeenCalledTimes(1);
});
test('remote delivery is rate-limited', async () => {
const local = fakeLocal(), transport = fakeTransport();
const p = new NostrChatProvider({ local, transport, signer: fakeSigner(), maxRemotePerMin: 2, now: () => 1000 });
await p.initialize();
for (let i = 0; i < 5; i++) transport.onEvent({ id: `x${i}`, pubkey: 'dd'.repeat(32), content: 'spam', tags: [] });
expect(local.publish).toHaveBeenCalledTimes(2); // capped
});
});
describe('buildChatProvider (opt-in seam)', () => {
const local = fakeLocal();
test('disabled -> returns the local provider unchanged (behavior-preserving)', () => {
expect(buildChatProvider({ local, env: {} })).toBe(local);
});
test('scope=local -> returns the plain local provider', () => {
expect(buildChatProvider({ local, env: { NOSTR_CHAT_ENABLED: 'true', NOSTR_CHAT_SCOPE: 'local' } })).toBe(local);
});
test('scope=global -> wraps in a NostrChatProvider', () => {
const p = buildChatProvider({ local, env: { NOSTR_CHAT_ENABLED: 'true', NOSTR_RELAYS: 'wss://relay.smirk.cash' } });
expect(p).toBeInstanceOf(NostrChatProvider);
});
});
describe('bridge signer (real nostr-tools round-trip)', () => {
test('null key -> no signer; a real key signs a verifiable event', () => {
expect(createBridgeSigner(null)).toBeNull();
const tools = loadNostrTools();
const sk = tools.generateSecretKey();
const hex = Buffer.from(sk).toString('hex');
const signer = createBridgeSigner(hex);
expect(signer.pubkey).toBe(tools.getPublicKey(sk));
const ev = signer.sign({ kind: 1, tags: [['t', 'wowngeon-global']], content: 'hello' });
expect(tools.verifyEvent(ev)).toBe(true);
expect(ev.content).toBe('hello');
});
});