Files
wownerogue/docs/CHAT_AND_NOSTR.md
T
jwinterm 4b92e70d4c feat: decaveat Phase 2 — subscription->tier data source + robust client signing
Caveat 2 (premium sub had no data source) -> RESOLVED:
- src/services/subscriptionService.js: resolves a premium tier by npub. Two
  sources, both optional (absent => null => unchanged): PREMIUM_NPUBS operator
  allowlist (npub1/hex[:tier]) works TODAY with zero backend dependency, and an
  optional SMIRK_PREMIUM_STATUS_URL HTTP endpoint for full automation. Cached.
- identityService.entitlementsForUser populates user.subscription_tier from it
  (by smirk_public_key) before the snapshot, so an active sub unlocks cosmetics.
  (The Smirk backend's /premium/status is self-only, so a by-npub service lookup
  is the one remaining backend addition for full automation — documented.)

Caveat 1 (client signing was bare scaffold) -> HARDENED:
- html/js/smirkChatSign.js now does the Nostr-scope-grant retry (sign first; on
  NOT_AUTHORIZED, getNostrPublicKey() once + retry), same as SmirkAuth — so a
  returning user signs with one approval instead of silently falling back. Still
  feature-detected + graceful. A real Smirk wallet click is the last confirm.

Docs updated (MONETIZATION.md, CHAT_AND_NOSTR.md). Tests: +6 (allowlist hex/npub1,
HTTP active/inactive+cache, identityService unlock, non-premium unchanged).
Suite 57 suites / 413 tests green.
2026-07-10 22:33:13 -04:00

7.2 KiB

Chat & Nostr (P5)

Chat has two layers: local delivery (Socket.IO + Postgres history) and an optional nostr fan-out that carries the same conversation across servers and into the Smirk ecosystem. Both sit behind one seam so callers never change.

The seam

src/network/chat/ChatProvider.js — the abstraction every chat caller uses:

publish(msg)                 deliver + (optionally) persist a message
getHistory({scope, limit})   recent messages, oldest → newest
relaySignedEvent(req)        deliver a CLIENT-signed nostr event (per-player identity)
initialize() / shutdown()

Two implementations:

  • SocketChatProvider — the default. Global scope → persist via ChatHistoryManager + broadcast (chat_broadcast); room scope (tavern) → deliver to that Socket.IO room only.
  • NostrChatProvider — a decorator around a local provider. Everything still delivers in-game + persists; on top of that, global messages fan out to nostr relays, and messages arriving from a relay are delivered locally-only (never re-published → no echo loops).

buildChatProvider({local, env}) (src/network/chat/index.js) picks: with NOSTR_CHAT_ENABLED unset it returns the plain local provider (behavior-preserving); enabled + scope=global wraps it in a NostrChatProvider.

Unified global chat

The tavern shares the lobby's global chat — TavernManager takes the lobby's chatProvider and publishes to the global scope, and sends the global backlog (chat_history) on join. So lobby and tavern are one conversation with persistent history, and when nostr is on, tavern chat rides the relay too. Topology is always client → server → relay: the server stays the moderation authority (bans, rate-limit, history in Postgres); the relay is transport + fan-out.

Signer models

NostrChatProvider takes an injectable signer ({ pubkey, sign(template) }). That one object is the whole difference between the two identity models:

Phase 1 — bridge (shipped, live)

The server signs every global message with one allowlisted bridge npub; the player's name rides in an ['n', name] tag. Works for anyone (even anon), but on nostr it reads as one account talking for everyone. createBridgeSigner(NOSTR_BRIDGE_SK) builds it; the npub must be write-allowlisted on the relay (see Relay policy).

Phase 2 — per-player signing (built; one real-wallet click to confirm)

The client signs with the player's own Smirk npub and sends the finished event; the server verifies + relays it. On nostr it's genuinely them.

client                         server (chatHandler)                  relay
  │ build kind-1 event           │                                     │
  │ window.smirk.signNostrEvent  │                                     │
  │ ── chat_signed {event} ────► │ rate-limit                          │
  │                              │ verifyChatEvent(event, npub)        │
  │                              │   sig ok? id ok? fresh? tag ok?     │
  │                              │   pubkey === session npub? ◄── the  │
  │                              │        impersonation guard           │
  │                              │ escape + relaySignedEvent ──────────►│ (published under the player's npub)
  │ ◄──────── chat_broadcast (in-game, all clients) ──────────────────│
  • src/utils/verifyChatEvent.js — the security core. Same defenses as verifyNip98Event (rebuild a clean event, independently recompute the id, verify schnorr, freshness) plus event.pubkey must equal the session's authenticated npub (users.smirk_public_key, set at NIP-98 login). A valid signature by some other key is rejected (pubkey-mismatch).
  • chatHandler.handleSignedChatMessage — rate-limit → resolve session npub → verify → escape → relay.
  • NostrChatProvider.relaySignedEvent — local delivery + transport.publish(signedEvent) (no re-signing) + echo-dedupe on the event id.
  • html/js/smirkChatSign.js — feature-detected client helper. Signs via window.smirk.signNostrEvent with the Nostr-scope-grant retry (sign first; on NOT_AUTHORIZED, getNostrPublicKey() once and retry — same pattern as SmirkAuth), so a returning user gets a single approval. Falls back to the unsigned send when Smirk isn't connected, so wiring it into a send site is always safe. Loaded on tavern.html; the tavern send tries it first. (A real Smirk wallet click is the only step left to confirm end to end.)

The tier model (two relays)

Everyone signed-in self-signs as themselves; the tier is only which relay accepts them, and what perks come with it (perks + cosmetics, never identity):

Player Signs Lands on
Anon nobody local only
Free "pleb" own npub relay.smirk.cash — open signup + PoW
Premium own npub + ✦ badge relay.wowne.ro — premium-post

The game publishes each signed event to all NOSTR_RELAYS and lets each relay's policy accept or reject — no per-player routing or allowlist in game code. Reading is the mirror (subscribe to all, dedupe by event id). Premium membership also sets the player's entitlement tier, which unlocks cosmetics (see MONETIZATION.md) — one subscription, two payoffs.

Config (env)

Var Meaning
NOSTR_CHAT_ENABLED master switch (unset ⇒ plain local chat)
NOSTR_CHAT_SCOPE global (fan out) or local (plain provider)
NOSTR_RELAYS CSV of relay URLs; transport publishes/subscribes to all
NOSTR_CHAT_TAG shared channel topic (default wowngeon-global)
NOSTR_BRIDGE_SK bridge secret key (nsec/hex); omit ⇒ receive-only

Relay policy (smirk-backend-core)

relay.smirk.cash is a nostr-rs-relay gated by the Smirk backend's admission oracle. Write policy premium-post = only premium (or write-allowlisted) npubs may publish; RELAY_WRITE_ALLOWLIST_NPUBS (comma-separated npub1…/hex) exempts specific keys — that's how the bridge npub is registered. PoW (RELAY_INBOUND_POW_BITS) only gates non-registered authors, so allowlisted/premium npubs post freely. Reading is open.

Loader shim

nostr-tools pulls ESM-only deps. src/utils/nostrLoader.js loadNostrTools() require()s it in prod Node 22 and falls back to the pre-bundled IIFE under CJS-only Jest. Chat + NIP-98 both use it.

Files

src/network/chat/ChatProvider.js          seam (+ relaySignedEvent default)
src/network/chat/SocketChatProvider.js     local delivery + history
src/network/chat/NostrChatProvider.js      decorator: local + nostr fan-out + signed relay
src/network/chat/index.js                  buildChatProvider (env → provider)
src/network/chat/nostr/NostrTransport.js   SimplePool wrapper (multi-relay, degrades to noop)
src/network/chat/nostr/bridgeSigner.js     server bridge signer
src/utils/verifyChatEvent.js               verify a client-signed event (impersonation guard)
src/utils/nostrLoader.js                   nostr-tools loader shim
html/js/smirkChatSign.js                   client per-player signing helper