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:
jwinterm
2026-07-10 22:20:10 -04:00
parent ebf14560dd
commit 4b78fec853
9 changed files with 357 additions and 4 deletions
+31
View File
@@ -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<boolean>: 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);
+12 -1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tavern - Wownerogue</title>
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<script src="js/smirkChatSign.js"></script>
<!-- PixiJS (+ @pixi/unsafe-eval for the CSP) is lazy-loaded by renderModes.js only when the
Fancy mode is first selected, so ASCII/Tiled users never download the WebGL library. -->
<script src="js/render/sceneModel.js"></script>
@@ -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 = '';
});