diff --git a/html/js/smirkChatSign.js b/html/js/smirkChatSign.js
new file mode 100644
index 0000000..3bc525c
--- /dev/null
+++ b/html/js/smirkChatSign.js
@@ -0,0 +1,31 @@
+(function (root) {
+ 'use strict';
+ // Per-player nostr identity (Phase 2). Sign a global-chat message with the player's OWN Smirk
+ // npub (window.smirk.signNostrEvent) and emit it as `chat_signed`; the server verifies it's
+ // signed by this session's authenticated npub, then relays it to nostr under that npub.
+ //
+ // Returns a Promise: true if it signed + emitted, false if Smirk signing is
+ // unavailable or declined — in which case the caller falls back to the normal unsigned send.
+ // Feature-detected end to end, so it's a no-op (always false) until the page has a connected
+ // Smirk wallet; wiring it into a send site is therefore always safe.
+ function smirkChatSign(socket, text, tag) {
+ try {
+ var smirk = root.smirk;
+ if (!socket || !smirk || typeof smirk.signNostrEvent !== 'function') return Promise.resolve(false);
+ var tmpl = {
+ kind: 1,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['t', tag || 'wowngeon-global']],
+ content: String(text)
+ };
+ return Promise.resolve(smirk.signNostrEvent(tmpl)).then(function (signed) {
+ if (!signed || !signed.id || !signed.sig || !signed.pubkey) return false;
+ socket.emit('chat_signed', { event: signed });
+ return true;
+ }).catch(function () { return false; });
+ } catch (e) {
+ return Promise.resolve(false);
+ }
+ }
+ root.smirkChatSign = smirkChatSign;
+})(typeof window !== 'undefined' ? window : this);
diff --git a/html/tavern.html b/html/tavern.html
index 9353214..ccb64ef 100644
--- a/html/tavern.html
+++ b/html/tavern.html
@@ -5,6 +5,7 @@
Tavern - Wownerogue
+
@@ -369,7 +370,17 @@
chatForm.addEventListener('submit', function (e) {
e.preventDefault();
var text = chatInput.value;
- if (text && text.trim()) socket.emit('tavern_chat', { text: text });
+ if (text && text.trim()) {
+ // Try to post under the player's OWN Smirk npub (per-player identity); fall back to
+ // the normal unsigned send if Smirk signing isn't available/declined.
+ if (window.smirkChatSign) {
+ window.smirkChatSign(socket, text.trim(), 'wowngeon-global').then(function (signed) {
+ if (!signed) socket.emit('tavern_chat', { text: text });
+ });
+ } else {
+ socket.emit('tavern_chat', { text: text });
+ }
+ }
chatInput.value = '';
});
diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js
index 1efcffc..6d48f80 100644
--- a/src/multiplayer/entitlements.js
+++ b/src/multiplayer/entitlements.js
@@ -70,10 +70,17 @@ function normalizePackGrants(grants = [], catalog = DEFAULT_CATALOG) {
}
// Only a REAL premium tier (supporter/premium/operator) counts as a level; buying credits does not.
+// The tier is the HIGHEST of the stored premium_level and any active-subscription tier: a live
+// wowne.ro premium sub sets subscription_tier (e.g. 'premium') and thereby unlocks the cosmetic
+// packs at/below that tier — one subscription drives both chat perks and tile/customization unlocks.
+// (The subscription_tier field is populated by the user loader from a subscription check; absent =
+// unchanged legacy behavior.)
function levelForUser(user = {}) {
- const raw = String(user.premium_level || user.premiumLevel || '').trim().toLowerCase();
- if (raw && tierForLevel(raw) > 0) return raw;
- return 'free';
+ const candidates = [user.premium_level, user.premiumLevel, user.subscription_tier, user.subscriptionTier]
+ .map(v => String(v || '').trim().toLowerCase())
+ .filter(l => tierForLevel(l) > 0);
+ if (!candidates.length) return 'free';
+ return candidates.reduce((a, b) => (tierForLevel(b) > tierForLevel(a) ? b : a));
}
// A pack is unlocked for a user if ANY of: it's free, an explicit grant, the user's lifetime
diff --git a/src/network/chat/ChatProvider.js b/src/network/chat/ChatProvider.js
index 0adcdde..7a521f6 100644
--- a/src/network/chat/ChatProvider.js
+++ b/src/network/chat/ChatProvider.js
@@ -38,6 +38,16 @@ class ChatProvider {
return [];
}
+ /**
+ * Deliver a message whose nostr event was signed by the CLIENT (per-player identity, Phase 2).
+ * The base/local provider has no relay, so it just delivers the message in-game — the signed
+ * `event` is ignored. NostrChatProvider overrides this to ALSO publish the pre-signed event.
+ * @param {object} req { event, scope, username, text, ts, socketId, userId }
+ */
+ async relaySignedEvent({ event, ...msg } = {}) { // eslint-disable-line no-unused-vars
+ return this.publish({ scope: 'global', ...msg });
+ }
+
// eslint-disable-next-line no-empty-function
async shutdown() {}
}
diff --git a/src/network/chat/NostrChatProvider.js b/src/network/chat/NostrChatProvider.js
index d8d472b..6ac7297 100644
--- a/src/network/chat/NostrChatProvider.js
+++ b/src/network/chat/NostrChatProvider.js
@@ -129,6 +129,19 @@ class NostrChatProvider extends ChatProvider {
return true;
}
+ // Relay a CLIENT-signed event (per-player identity, Phase 2). Delivers the message in-game
+ // (escaped text, exactly like a normal global message) and publishes the pre-signed event to
+ // the relays WITHOUT re-signing — so it carries the player's OWN npub, not the bridge. The
+ // signed id is marked seen so the relay round-trip doesn't re-deliver it.
+ async relaySignedEvent({ event, username, text, ts, socketId, userId } = {}) {
+ await this.local.publish({ scope: 'global', username, text, ts, socketId, userId });
+ if (this._globalEnabled() && event && event.id) {
+ this._markSeen(event.id);
+ try { await this.transport.publish(event); }
+ catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] signed relay failed:', err.message); }
+ }
+ }
+
async getHistory(opts) {
return this.local.getHistory(opts);
}
diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js
index 6c05542..541f9a4 100644
--- a/src/network/chatHandler.js
+++ b/src/network/chatHandler.js
@@ -7,6 +7,8 @@ const ChatHistoryManager = require('./chatHistoryManager');
const SocketChatProvider = require('./chat/SocketChatProvider');
const { buildChatProvider } = require('./chat');
const { clientIp, stableId } = require('./rateLimitContext');
+const { verifyChatEvent } = require('../utils/verifyChatEvent');
+const { escapeChatText } = require('../utils/escapeChat');
class ChatHandler {
constructor({ io, broadcastManager, debugManager, addressManager, paymentHandlers, queueManager, gameModeManager, rateLimiter, db }) {
@@ -124,6 +126,66 @@ class ChatHandler {
}
}
+ /**
+ * Handle a CLIENT-signed global chat event (Phase 2 per-player nostr identity). The client signs
+ * with the player's OWN Smirk npub (window.smirk.signNostrEvent) and sends the finished event;
+ * the server verifies it's authentic AND signed by THIS session's authenticated npub
+ * (users.smirk_public_key from NIP-98 login), moderates, then relays it — the provider publishes
+ * the pre-signed event to nostr under the player's npub (not the bridge). Serves lobby + tavern:
+ * it delivers to global, which both render.
+ */
+ async handleSignedChatMessage(socket, payload) {
+ try {
+ const rlId = stableId(socket, this.gameModeManager?.sessionManager);
+ const rlIp = clientIp(socket);
+ const rl = await this.rateLimiter.checkLimit(rlId, 'chat:message', rlIp);
+ if (!rl.allowed) {
+ this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Please slow down before your next message.');
+ return;
+ }
+ await this.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp);
+
+ const event = payload && payload.event;
+ if (!event || typeof event !== 'object') return;
+
+ // The session's authenticated npub is the ONLY key this player may post under.
+ const db = this.gameModeManager?.db;
+ const session = this.gameModeManager?.sessionManager?.sessions?.get(socket.id);
+ const userId = session ? session.id : null;
+ if (!db || !userId) {
+ this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.');
+ return;
+ }
+ const row = (await db.query('SELECT smirk_public_key, display_name, username FROM users WHERE id = $1', [userId])).rows[0];
+ const npub = row && row.smirk_public_key;
+ if (!npub) {
+ this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.');
+ return;
+ }
+
+ const verdict = verifyChatEvent(event, {
+ expectedPubkey: npub,
+ channelTag: process.env.NOSTR_CHAT_TAG || 'wowngeon-global',
+ maxLen: 280
+ });
+ if (!verdict.ok) {
+ if (this.debugManager.CONSOLE_LOGGING) console.warn('[chat] signed event rejected:', verdict.reason);
+ this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Your signed message could not be verified.');
+ return;
+ }
+
+ const username = row.display_name || row.username || String(npub).slice(0, 8);
+ const text = escapeChatText(verdict.content, 200);
+
+ // Delivers in-game (escaped) + relays the client-signed event to nostr under the player's npub.
+ await this.chatProvider.relaySignedEvent({
+ event, scope: 'global', username, text, ts: Date.now(), socketId: socket.id, userId
+ });
+ } catch (err) {
+ console.error('Signed chat handler error:', err.message);
+ }
+ }
+
/**
* Get chat statistics
*/
diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js
index 68dd749..ccc8332 100644
--- a/src/network/socketHandlers.js
+++ b/src/network/socketHandlers.js
@@ -438,6 +438,8 @@ class SocketHandlers {
handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket),
handleStatsRequest: (socket) => this.handleStatsRequest(socket)
}));
+ // Phase 2: a client-signed global chat event (posts under the player's own Smirk npub).
+ socket.on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload));
socket.on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
socket.on('disconnect', () => this.handleDisconnect(socket));
socket.on('debug_ping', (data) => this.handleDebugPing(socket, data));
diff --git a/src/utils/verifyChatEvent.js b/src/utils/verifyChatEvent.js
new file mode 100644
index 0000000..08469c2
--- /dev/null
+++ b/src/utils/verifyChatEvent.js
@@ -0,0 +1,90 @@
+const { loadNostrTools } = require('./nostrLoader');
+
+const { getEventHash, verifyEvent } = loadNostrTools();
+
+const HEX_64 = /^[0-9a-f]{64}$/; // x-only secp256k1 pubkey / event id
+const HEX_128 = /^[0-9a-f]{128}$/; // BIP-340 schnorr signature
+
+function fail(reason) { return { ok: false, reason }; }
+
+/**
+ * Verify a client-signed global-chat event before the server relays it to nostr (Phase 2 per-player
+ * signing). Same defense posture as verifyNip98Event: rebuild a clean event from primitives,
+ * INDEPENDENTLY recompute the id, verify the schnorr signature, check freshness — and, critically,
+ * bind the author to the session's authenticated npub so a player can only ever post as THEMSELVES.
+ *
+ * @param {object} event signed nostr event { kind, created_at, pubkey, id, sig, content, tags }
+ * @param {object} opts
+ * @param {string} opts.expectedPubkey 64-hex x-only key the session is authenticated as (from NIP-98 login) — required
+ * @param {string} [opts.channelTag] 't' tag the event must carry (default 'wowngeon-global')
+ * @param {number} [opts.kind] expected kind (default 1)
+ * @param {number} [opts.maxLen] max content length (default 280)
+ * @param {number} [opts.now] unix seconds (default now)
+ * @param {number} [opts.maxSkewSec] freshness window (default 120)
+ * @returns {{ ok: boolean, reason?: string, pubkey?: string, content?: string }}
+ */
+function verifyChatEvent(event, opts = {}) {
+ const {
+ expectedPubkey,
+ channelTag = 'wowngeon-global',
+ kind = 1,
+ maxLen = 280,
+ now = Math.floor(Date.now() / 1000),
+ maxSkewSec = 120
+ } = opts;
+
+ if (!expectedPubkey || !HEX_64.test(String(expectedPubkey).toLowerCase())) return fail('no-expected-pubkey');
+
+ // Shape/type validation.
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return fail('event-not-object');
+ if (typeof event.kind !== 'number') return fail('kind-not-number');
+ if (typeof event.created_at !== 'number' || !Number.isFinite(event.created_at)) return fail('created_at-not-number');
+ if (typeof event.pubkey !== 'string' || !HEX_64.test(event.pubkey.toLowerCase())) return fail('bad-pubkey');
+ if (typeof event.id !== 'string' || !HEX_64.test(event.id.toLowerCase())) return fail('bad-id');
+ if (typeof event.sig !== 'string' || !HEX_128.test(event.sig.toLowerCase())) return fail('bad-sig');
+ if (typeof event.content !== 'string') return fail('content-not-string');
+ if (!Array.isArray(event.tags)) return fail('tags-not-array');
+ for (const t of event.tags) {
+ if (!Array.isArray(t) || t.some((v) => typeof v !== 'string')) return fail('malformed-tag');
+ }
+
+ if (event.kind !== kind) return fail('wrong-kind');
+ if (event.content.length > maxLen) return fail('too-long');
+
+ // Author binding — the ONE that stops impersonation: the event must be signed by the npub this
+ // session authenticated as (users.smirk_public_key from NIP-98 login). A valid signature by
+ // SOME other key is not enough.
+ if (event.pubkey.toLowerCase() !== String(expectedPubkey).toLowerCase()) return fail('pubkey-mismatch');
+
+ // Rebuild a clean event from primitives only (strips any stamped verified-symbol / extras).
+ const clean = {
+ kind: event.kind,
+ created_at: event.created_at,
+ pubkey: event.pubkey.toLowerCase(),
+ id: event.id.toLowerCase(),
+ sig: event.sig.toLowerCase(),
+ content: event.content,
+ tags: event.tags.map((t) => t.slice())
+ };
+
+ // Independently recompute the id — defeats content/tag tampering regardless of memoization.
+ let recomputed;
+ try { recomputed = getEventHash(clean); } catch (_) { return fail('hash-error'); }
+ if (recomputed !== clean.id) return fail('id-mismatch');
+
+ // BIP-340 schnorr signature.
+ let sigOk = false;
+ try { sigOk = verifyEvent(clean) === true; } catch (_) { return fail('sig-verify-threw'); }
+ if (!sigOk) return fail('bad-signature');
+
+ // Freshness — reject replayed-late / skewed events.
+ if (Math.abs(now - Number(clean.created_at)) > maxSkewSec) return fail('expired');
+
+ // Must target OUR channel tag, so the server can't be used to relay arbitrary events.
+ const tTags = clean.tags.filter((t) => t[0] === 't').map((t) => t[1]);
+ if (!tTags.includes(channelTag)) return fail('wrong-channel');
+
+ return { ok: true, pubkey: clean.pubkey, content: clean.content };
+}
+
+module.exports = { verifyChatEvent };
diff --git a/test/perPlayerSigning_ultra.test.js b/test/perPlayerSigning_ultra.test.js
new file mode 100644
index 0000000..5c9cb37
--- /dev/null
+++ b/test/perPlayerSigning_ultra.test.js
@@ -0,0 +1,127 @@
+/**
+ * Phase 2 — per-player nostr identity: verify client-signed chat events, relay them under the
+ * player's own npub, and let a premium subscription unlock cosmetic tiers.
+ */
+const { verifyChatEvent } = require('../src/utils/verifyChatEvent');
+const { loadNostrTools } = require('../src/utils/nostrLoader');
+const NostrChatProvider = require('../src/network/chat/NostrChatProvider');
+const ChatProvider = require('../src/network/chat/ChatProvider');
+const Entitlements = require('../src/multiplayer/entitlements');
+
+const tools = loadNostrTools();
+function signedEvent({ sk, content = 'gm', tag = 'wowngeon-global', kind = 1, created_at } = {}) {
+ return tools.finalizeEvent({
+ kind,
+ created_at: created_at || Math.floor(Date.now() / 1000),
+ tags: [['t', tag]],
+ content
+ }, sk);
+}
+
+describe('verifyChatEvent', () => {
+ const sk = tools.generateSecretKey();
+ const pub = tools.getPublicKey(sk);
+
+ test('accepts an authentic event signed by the expected npub', () => {
+ const v = verifyChatEvent(signedEvent({ sk, content: 'hello world' }), { expectedPubkey: pub });
+ expect(v.ok).toBe(true);
+ expect(v.pubkey).toBe(pub);
+ expect(v.content).toBe('hello world');
+ });
+
+ test('rejects a valid event signed by a DIFFERENT key (impersonation)', () => {
+ const otherPub = tools.getPublicKey(tools.generateSecretKey());
+ const v = verifyChatEvent(signedEvent({ sk }), { expectedPubkey: otherPub });
+ expect(v.ok).toBe(false);
+ expect(v.reason).toBe('pubkey-mismatch');
+ });
+
+ test('rejects a tampered event (content changed after signing)', () => {
+ const ev = signedEvent({ sk, content: 'original' });
+ ev.content = 'tampered';
+ expect(verifyChatEvent(ev, { expectedPubkey: pub }).reason).toBe('id-mismatch');
+ });
+
+ test('rejects the wrong channel tag', () => {
+ const v = verifyChatEvent(signedEvent({ sk, tag: 'some-other-room' }), { expectedPubkey: pub });
+ expect(v.reason).toBe('wrong-channel');
+ });
+
+ test('rejects a stale event', () => {
+ const v = verifyChatEvent(signedEvent({ sk, created_at: Math.floor(Date.now() / 1000) - 9999 }), { expectedPubkey: pub });
+ expect(v.reason).toBe('expired');
+ });
+
+ test('rejects wrong kind and over-length content', () => {
+ expect(verifyChatEvent(signedEvent({ sk, kind: 4 }), { expectedPubkey: pub }).reason).toBe('wrong-kind');
+ expect(verifyChatEvent(signedEvent({ sk, content: 'x'.repeat(400) }), { expectedPubkey: pub, maxLen: 280 }).reason).toBe('too-long');
+ });
+
+ test('requires an expected pubkey', () => {
+ expect(verifyChatEvent(signedEvent({ sk }), {}).reason).toBe('no-expected-pubkey');
+ });
+});
+
+describe('relaySignedEvent', () => {
+ function fakeLocal() {
+ return { initialize: jest.fn().mockResolvedValue(), publish: jest.fn().mockResolvedValue(), getHistory: jest.fn(), shutdown: jest.fn() };
+ }
+ function fakeTransport() {
+ return { publish: jest.fn().mockResolvedValue(), subscribe: jest.fn(function (f, cb) { this.onEvent = cb; }), close: jest.fn() };
+ }
+
+ test('NostrChatProvider delivers in-game AND relays the pre-signed event without re-signing', async () => {
+ const local = fakeLocal(), transport = fakeTransport();
+ const p = new NostrChatProvider({ local, transport, signer: { pubkey: 'aa'.repeat(32), sign: () => { throw new Error('should not sign'); } } });
+ const event = { id: 'evt-42', pubkey: 'bb'.repeat(32), sig: 'x', kind: 1, content: 'gm', tags: [] };
+ await p.relaySignedEvent({ event, username: 'alice', text: 'gm', ts: 1700000000000 });
+
+ expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({ scope: 'global', username: 'alice', text: 'gm' }));
+ expect(transport.publish).toHaveBeenCalledWith(event); // the client-signed event, verbatim
+ });
+
+ test('the relayed event echoing back from the relay is deduped (not re-delivered)', async () => {
+ const local = fakeLocal(), transport = fakeTransport();
+ const p = new NostrChatProvider({ local, transport, signer: { pubkey: 'aa'.repeat(32), sign: () => ({}) } });
+ await p.initialize();
+ const event = { id: 'evt-echo', pubkey: 'bb'.repeat(32), sig: 'x', kind: 1, content: 'gm', tags: [] };
+ await p.relaySignedEvent({ event, username: 'alice', text: 'gm' });
+ local.publish.mockClear();
+ transport.onEvent(event); // relay echoes our own published event back
+ expect(local.publish).not.toHaveBeenCalled(); // markSeen dropped it
+ });
+
+ test('the base ChatProvider relaySignedEvent just delivers locally (no relay)', async () => {
+ const base = new ChatProvider();
+ base.publish = jest.fn().mockResolvedValue();
+ await base.relaySignedEvent({ event: { id: 'x' }, username: 'a', text: 'hi' });
+ expect(base.publish).toHaveBeenCalledWith(expect.objectContaining({ scope: 'global', username: 'a', text: 'hi' }));
+ expect(base.publish.mock.calls[0][0].event).toBeUndefined(); // signed event not forwarded to a non-relay provider
+ });
+});
+
+describe('premium subscription unlocks cosmetic tiers', () => {
+ test('an active premium sub sets the tier and unlocks tier-1 packs — no purchase needed', () => {
+ const free = Entitlements.snapshotForUser({ credits: 0, total_credits_purchased: 0 });
+ expect(free.tier).toBe(0);
+ expect(free.packs['generated-skins']).toBe(false); // tier-1 pack locked for free
+
+ const premium = Entitlements.snapshotForUser({ subscription_tier: 'premium', total_credits_purchased: 0 });
+ expect(premium.level).toBe('premium');
+ expect(premium.tier).toBe(2);
+ expect(premium.packs['generated-skins']).toBe(true); // tier-1 pack unlocked by the sub
+ expect(premium.premium).toBe(true);
+ });
+
+ test('buying credits still does NOT put you on the premium ladder', () => {
+ const snap = Entitlements.snapshotForUser({ subscription_tier: 'credits', total_credits_purchased: 50 });
+ expect(snap.level).toBe('free');
+ expect(snap.tier).toBe(0);
+ });
+
+ test('the higher of stored level and subscription tier wins', () => {
+ const snap = Entitlements.snapshotForUser({ premium_level: 'supporter', subscription_tier: 'operator' });
+ expect(snap.level).toBe('operator');
+ expect(snap.tier).toBe(3);
+ });
+});