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
+10 -3
View File
@@ -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