mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 15:18:33 +02:00
feat(chat): per-player nostr identity (Phase 2) + premium subscription unlocks cosmetic tiers
Phase 2 — a signed-in player can post global chat under their OWN Smirk npub instead of the shared bridge account: - src/utils/verifyChatEvent.js: verify a client-signed chat event with the same defenses as verifyNip98Event (rebuild clean event, recompute id, verify schnorr, freshness) PLUS the impersonation guard — the event's pubkey MUST equal the session's authenticated npub (users.smirk_public_key from NIP-98 login). - ChatProvider.relaySignedEvent (base: local delivery only) + NostrChatProvider override (local delivery + publish the PRE-SIGNED event to the relays, no re-signing, echo-deduped) so it carries the player's npub. - chatHandler.handleSignedChatMessage: rate-limit + resolve the session npub + verify + escape + relay. socketHandlers wires the event. Serves lobby + tavern (both render the global broadcast). - html/js/smirkChatSign.js + tavern send wire: feature-detected client helper — signs via window.smirk.signNostrEvent, falls back to the unsigned send when Smirk isn't connected, so it's inert (and safe) until a page has a wallet. Premium -> cosmetics (the P1 catalog already had the hook): - entitlements.levelForUser now takes the HIGHER of premium_level and an active-subscription tier, so a wowne.ro premium sub sets the entitlement tier and unlocks tile/customization packs at/below it — one sub, chat perks + cosmetics. Buying credits still never joins the premium ladder. Behavior-preserving: no subscription_tier => unchanged; chat_signed only fires when a client opts in. The client signing path needs browser verification against a connected Smirk wallet (like the NIP-98 login). Tests: +13 (verify incl. impersonation/tamper/channel/freshness, relay+echo-dedupe, sub-tier unlock). Suite 56 suites / 407 tests green.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user