diff --git a/src/network/chat/NostrChatProvider.js b/src/network/chat/NostrChatProvider.js new file mode 100644 index 0000000..d8d472b --- /dev/null +++ b/src/network/chat/NostrChatProvider.js @@ -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; diff --git a/src/network/chat/index.js b/src/network/chat/index.js new file mode 100644 index 0000000..b184291 --- /dev/null +++ b/src/network/chat/index.js @@ -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= (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 }; diff --git a/src/network/chat/nostr/NostrTransport.js b/src/network/chat/nostr/NostrTransport.js new file mode 100644 index 0000000..ff03b94 --- /dev/null +++ b/src/network/chat/nostr/NostrTransport.js @@ -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; diff --git a/src/network/chat/nostr/bridgeSigner.js b/src/network/chat/nostr/bridgeSigner.js new file mode 100644 index 0000000..d65f314 --- /dev/null +++ b/src/network/chat/nostr/bridgeSigner.js @@ -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 }; diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js index 666a0d8..6c05542 100644 --- a/src/network/chatHandler.js +++ b/src/network/chatHandler.js @@ -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(); + } } /** diff --git a/src/utils/escapeChat.js b/src/utils/escapeChat.js new file mode 100644 index 0000000..9ce3839 --- /dev/null +++ b/src/utils/escapeChat.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .slice(0, maxLen); +} + +module.exports = { escapeChatText, DEFAULT_MAX }; diff --git a/src/utils/nostrLoader.js b/src/utils/nostrLoader.js new file mode 100644 index 0000000..2dcbc89 --- /dev/null +++ b/src/utils/nostrLoader.js @@ -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 }; diff --git a/test/nostrChatProvider_ultra.test.js b/test/nostrChatProvider_ultra.test.js new file mode 100644 index 0000000..7ab5315 --- /dev/null +++ b/test/nostrChatProvider_ultra.test.js @@ -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: 'hi', tags: [['t', 'wowngeon-global'], ['n', 'alice']] }); + + expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({ + scope: 'global', username: 'alice', text: '<b>hi</b>', 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'); + }); +});