mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-21 16:18:33 +02:00
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:
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''')
|
||||
.slice(0, maxLen);
|
||||
}
|
||||
|
||||
module.exports = { escapeChatText, DEFAULT_MAX };
|
||||
@@ -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 };
|
||||
@@ -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: '<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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user