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.
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 viaChatHistoryManager+ 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 asverifyNip98Event(rebuild a clean event, independently recompute the id, verify schnorr, freshness) plusevent.pubkeymust 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 viawindow.smirk.signNostrEventwith the Nostr-scope-grant retry (sign first; onNOT_AUTHORIZED,getNostrPublicKey()once and retry — same pattern asSmirkAuth), 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 ontavern.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