mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-22 00:28:34 +02:00
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.
132 lines
7.2 KiB
Markdown
132 lines
7.2 KiB
Markdown
# 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](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
|
|
```
|