From 4b92e70d4c74fd3dcd71993c3828d72dd8101cbb Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 22:33:13 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20decaveat=20Phase=202=20=E2=80=94=20subs?= =?UTF-8?q?cription->tier=20data=20source=20+=20robust=20client=20signing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/CHAT_AND_NOSTR.md | 10 ++-- docs/MONETIZATION.md | 31 +++++++---- html/js/smirkChatSign.js | 17 +++++- src/network/identityService.js | 14 ++++- src/services/subscriptionService.js | 82 +++++++++++++++++++++++++++++ test/subscriptionTier_ultra.test.js | 69 ++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 src/services/subscriptionService.js create mode 100644 test/subscriptionTier_ultra.test.js diff --git a/docs/CHAT_AND_NOSTR.md b/docs/CHAT_AND_NOSTR.md index 485b781..a123dfa 100644 --- a/docs/CHAT_AND_NOSTR.md +++ b/docs/CHAT_AND_NOSTR.md @@ -46,7 +46,7 @@ The server signs every global message with one allowlisted bridge npub; the play 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, needs browser verification) +### 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*. @@ -70,8 +70,12 @@ client server (chatHandler) relay - **`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`, - falls back to the unsigned send when Smirk isn't connected, so wiring it into a send site is always safe. +- **`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) diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md index 9038e99..e1fda88 100644 --- a/docs/MONETIZATION.md +++ b/docs/MONETIZATION.md @@ -42,23 +42,36 @@ any purchase unlocked every premium pack. tile/customization packs at/below it — **one subscription drives both chat perks and cosmetics** (see [CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md)). -Everything downstream — pack gating, the served catalog, the client unlock UI — already consumes `tier`, so -"premium buys cosmetics" is a **data-source** change: populate `user.subscription_tier` from a subscription -check (against the premium backend / npub) when the user is loaded. Absent ⇒ unchanged legacy behavior. +Everything downstream — pack gating, the served catalog, the client unlock UI — already consumes `tier`. +The data source is `src/services/subscriptionService.js` (`SubscriptionService.tierForNpub`), which +`identityService.entitlementsForUser` calls to populate `user.subscription_tier` before the snapshot: ``` -active premium sub ──► user.subscription_tier ──► levelForUser ──► entitlement tier - ├─ relay: chat perks (badge, no-PoW, VIP) - └─ catalog: tile / skin / customization unlocks +active premium sub ──► SubscriptionService.tierForNpub(npub) ──► user.subscription_tier ──► levelForUser ──► tier + ├─ relay: chat perks (badge, no-PoW, VIP) + └─ catalog: tile / skin / customization unlocks ``` +Two sources, checked in order (both optional — absent ⇒ tier null ⇒ unchanged legacy behavior): + +| Env | Meaning | +|-----|---------| +| `PREMIUM_NPUBS` | operator allowlist `npub1…\|hex[:tier],…` — works **today**, zero backend dependency | +| `PREMIUM_DEFAULT_TIER` | tier for allowlist entries without an explicit `:tier` (default `premium`) | +| `SMIRK_PREMIUM_STATUS_URL` / `_KEY` | optional HTTP endpoint answering premium-by-npub `{active, tier?}`, for full automation | + +> The Smirk backend's `/premium/status` is **self-only** (user-token auth), so the game can't look up +> an arbitrary npub through it. Full automation needs a small backend addition — a service/by-npub +> premium endpoint (or a webhook that sets the tier). Until then, `PREMIUM_NPUBS` is the working source. + ## Files ``` src/migrations/024_cosmetic_catalog.sql operator-owned catalog table src/services/catalogService.js load/cache + DEFAULT_CATALOG fallback src/multiplayer/entitlements.js snapshotForUser · levelForUser · TIER_OF · unlock rule -src/network/identityService.js entitlementsForUser (loads catalog + grants) +src/services/subscriptionService.js premium tier by npub (PREMIUM_NPUBS allowlist + HTTP seam) +src/network/identityService.js entitlementsForUser (loads catalog + grants + sub tier) src/game/gameModeManager.js recordDirectEntryPurchase (direct entry = 1 credit) html/js/render/assetPacks.js client consumes the served catalog ``` @@ -67,5 +80,5 @@ html/js/render/assetPacks.js client consumes the served catalog - Premium tiers: one flat "premium," or a ladder (supporter/premium/operator) where each rung unlocks progressively more cosmetics *and* chat perks? (`TIER_OF` already has the rungs.) -- Where `subscription_tier` is sourced (sync a `users` column vs. live oracle query against the premium - backend by npub). +- Full-automation source: add a service/by-npub premium endpoint to the Smirk backend (then set + `SMIRK_PREMIUM_STATUS_URL`) vs. a webhook that stamps the tier. `PREMIUM_NPUBS` covers it manually today. diff --git a/html/js/smirkChatSign.js b/html/js/smirkChatSign.js index 3bc525c..02fe997 100644 --- a/html/js/smirkChatSign.js +++ b/html/js/smirkChatSign.js @@ -8,6 +8,21 @@ // 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. + // Sign an event, granting the Nostr scope on demand. Signing FIRST means a returning user + // (scope already granted for this origin — e.g. from the game's NIP-98 login) gets a single + // approval; only a first-time origin hits NOT_AUTHORIZED and needs the one-time scope grant. + // Same pattern as SmirkAuth (html/js/network/smirkAuth.js). + function signWithScope(smirk, tmpl) { + return Promise.resolve() + .then(function () { return smirk.signNostrEvent(tmpl); }) + .catch(function (err) { + var needsScope = (err && err.code === 'NOT_AUTHORIZED') + || /nostr scope|getNostrPublicKey/i.test((err && err.message) || ''); + if (!needsScope || typeof smirk.getNostrPublicKey !== 'function') throw err; + return smirk.getNostrPublicKey().then(function () { return smirk.signNostrEvent(tmpl); }); + }); + } + function smirkChatSign(socket, text, tag) { try { var smirk = root.smirk; @@ -18,7 +33,7 @@ tags: [['t', tag || 'wowngeon-global']], content: String(text) }; - return Promise.resolve(smirk.signNostrEvent(tmpl)).then(function (signed) { + return signWithScope(smirk, tmpl).then(function (signed) { if (!signed || !signed.id || !signed.sig || !signed.pubkey) return false; socket.emit('chat_signed', { event: signed }); return true; diff --git a/src/network/identityService.js b/src/network/identityService.js index bdb093b..8a913e5 100644 --- a/src/network/identityService.js +++ b/src/network/identityService.js @@ -1,6 +1,7 @@ const Appearance = require('../multiplayer/appearance'); const Entitlements = require('../multiplayer/entitlements'); const CatalogService = require('../services/catalogService'); +const SubscriptionService = require('../services/subscriptionService'); function parseAppearance(value) { if (!value) return Appearance.normalizeAppearance('default'); @@ -15,7 +16,7 @@ function parseAppearance(value) { } class IdentityService { - constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null, catalogService = null } = {}) { + constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null, catalogService = null, subscriptionService = null } = {}) { this.db = db || gameModeManager?.db || null; this.gameModeManager = gameModeManager; this.sessionManager = sessionManager; @@ -23,6 +24,9 @@ class IdentityService { // Operator-owned cosmetic catalog (DB-backed, cached). Falls back to the built-in default // when the table is absent, so this never breaks a fresh/partly-migrated DB. this.catalogService = catalogService || (this.db ? new CatalogService({ db: this.db }) : null); + // Resolves a premium-subscription tier by npub so a sub unlocks cosmetics. No source + // configured (PREMIUM_NPUBS / SMIRK_PREMIUM_STATUS_URL) => inert (returns null). + this.subscriptionService = subscriptionService || new SubscriptionService(); } async _catalog() { @@ -75,6 +79,14 @@ class IdentityService { const catalog = await this._catalog(); if (!user) return Entitlements.snapshotForUser({}, [], catalog); const grants = await this.packGrantsForUser(user.id); + // An active premium subscription (resolved by npub) sets subscription_tier, which unlocks + // the cosmetic packs at/below that tier. Best-effort: any resolver failure leaves it unset. + if (this.subscriptionService && user.smirk_public_key && user.subscription_tier == null) { + try { + const tier = await this.subscriptionService.tierForNpub(user.smirk_public_key); + if (tier) user = { ...user, subscription_tier: tier }; + } catch (_) { /* leave unset */ } + } return Entitlements.snapshotForUser(user, grants, catalog); } diff --git a/src/services/subscriptionService.js b/src/services/subscriptionService.js new file mode 100644 index 0000000..a13028d --- /dev/null +++ b/src/services/subscriptionService.js @@ -0,0 +1,82 @@ +const { loadNostrTools } = require('../utils/nostrLoader'); + +/** + * Resolves a player's premium-subscription TIER by their nostr pubkey, so an active subscription + * unlocks cosmetics (see docs/MONETIZATION.md). The game can't read the Smirk backend's self-only + * /premium/status by npub, so this resolves from two sources, in order: + * + * 1. PREMIUM_NPUBS — an operator allowlist ("npub1…|hex[:tier],…"). Works TODAY, zero backend + * dependency: mark specific npubs premium and they get the tier immediately. + * 2. SMIRK_PREMIUM_STATUS_URL — an optional HTTP endpoint that answers premium-by-npub, for full + * automation once the Smirk backend exposes a service lookup ({active, tier?}). Degrades to (1). + * + * Results are cached (TTL). With no source configured, tierForNpub() returns null → unchanged + * legacy behavior (no one is auto-promoted). + */ +class SubscriptionService { + constructor({ env = process.env, fetchImpl = null, ttlMs = 300000, now = () => Date.now() } = {}) { + this.ttlMs = ttlMs; + this.now = now; + this.cache = new Map(); // pubkeyHex -> { tier, exp } + this.fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null); + this.defaultTier = String(env.PREMIUM_DEFAULT_TIER || 'premium').trim().toLowerCase(); + this.allowlist = parseAllowlist(env.PREMIUM_NPUBS, this.defaultTier); + this.statusUrl = env.SMIRK_PREMIUM_STATUS_URL || null; + this.statusKey = env.SMIRK_PREMIUM_STATUS_KEY || null; + } + + get enabled() { return this.allowlist.size > 0 || !!this.statusUrl; } + + /** @returns {Promise} a premium tier name (e.g. 'premium') or null. */ + async tierForNpub(pubkey) { + if (!pubkey) return null; + const key = String(pubkey).toLowerCase(); + const hit = this.cache.get(key); + if (hit && hit.exp > this.now()) return hit.tier; + + let tier = this.allowlist.get(key) || null; + if (!tier && this.statusUrl && this.fetch) tier = await this._httpTier(key); + + this.cache.set(key, { tier, exp: this.now() + this.ttlMs }); + return tier; + } + + async _httpTier(pubkeyHex) { + try { + const sep = this.statusUrl.includes('?') ? '&' : '?'; + const res = await this.fetch(`${this.statusUrl}${sep}npub=${encodeURIComponent(pubkeyHex)}`, { + headers: this.statusKey ? { Authorization: `Bearer ${this.statusKey}` } : {} + }); + if (!res || !res.ok) return null; + const j = await res.json(); + if (j && j.active) return String(j.tier || this.defaultTier).toLowerCase(); + return null; + } catch (_) { return null; } + } +} + +// "npub1…|64hex[:tier], …" -> Map(pubkeyHex -> tier). npub1 entries are decoded to x-only hex so +// they match users.smirk_public_key. Bad entries are skipped. +function parseAllowlist(raw, defaultTier) { + const map = new Map(); + if (!raw) return map; + let tools = null; + for (const entry of String(raw).split(',')) { + const [idRaw, tierRaw] = entry.split(':').map((s) => (s || '').trim()); + if (!idRaw) continue; + let hex = null; + if (/^[0-9a-fA-F]{64}$/.test(idRaw)) { + hex = idRaw.toLowerCase(); + } else if (idRaw.startsWith('npub1')) { + try { + tools = tools || loadNostrTools(); + const dec = tools.nip19.decode(idRaw); + if (dec.type === 'npub' && typeof dec.data === 'string') hex = dec.data.toLowerCase(); + } catch (_) { /* skip */ } + } + if (hex) map.set(hex, (tierRaw || '').toLowerCase() || defaultTier); + } + return map; +} + +module.exports = SubscriptionService; diff --git a/test/subscriptionTier_ultra.test.js b/test/subscriptionTier_ultra.test.js new file mode 100644 index 0000000..6af874a --- /dev/null +++ b/test/subscriptionTier_ultra.test.js @@ -0,0 +1,69 @@ +/** + * Premium-subscription -> cosmetic-tier data source (decaveat: entitlements now HAS a source). + */ +const SubscriptionService = require('../src/services/subscriptionService'); +const IdentityService = require('../src/network/identityService'); +const { loadNostrTools } = require('../src/utils/nostrLoader'); + +const tools = loadNostrTools(); +const sk = tools.generateSecretKey(); +const pubHex = tools.getPublicKey(sk); +const npub = tools.nip19.npubEncode(pubHex); + +describe('SubscriptionService', () => { + test('no source configured -> null (inert, unchanged behavior)', async () => { + const svc = new SubscriptionService({ env: {} }); + expect(svc.enabled).toBe(false); + expect(await svc.tierForNpub(pubHex)).toBeNull(); + }); + + test('PREMIUM_NPUBS allowlist grants the tier — hex and npub1 both match the stored pubkey', async () => { + const byHex = new SubscriptionService({ env: { PREMIUM_NPUBS: pubHex } }); + expect(await byHex.tierForNpub(pubHex)).toBe('premium'); // default tier + + const byNpub = new SubscriptionService({ env: { PREMIUM_NPUBS: `${npub}:operator` } }); + expect(await byNpub.tierForNpub(pubHex)).toBe('operator'); // decoded npub1 matches the hex pubkey + expect(await byNpub.tierForNpub('cc'.repeat(32))).toBeNull(); // someone else + }); + + test('HTTP source is queried when configured and caches the result', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ active: true, tier: 'premium' }) }); + const svc = new SubscriptionService({ env: { SMIRK_PREMIUM_STATUS_URL: 'https://api.example/premium' }, fetchImpl, now: () => 1000 }); + expect(await svc.tierForNpub(pubHex)).toBe('premium'); + expect(await svc.tierForNpub(pubHex)).toBe('premium'); // cached + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('HTTP inactive subscription -> null', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ active: false }) }); + const svc = new SubscriptionService({ env: { SMIRK_PREMIUM_STATUS_URL: 'https://api.example/premium' }, fetchImpl }); + expect(await svc.tierForNpub(pubHex)).toBeNull(); + }); +}); + +describe('IdentityService wires the subscription tier into entitlements', () => { + test('an allowlisted npub unlocks premium cosmetics with no purchase', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [] }) }; // no pack grants + const identity = new IdentityService({ + db, + catalogService: { getCatalog: async () => undefined }, // -> DEFAULT_CATALOG + subscriptionService: new SubscriptionService({ env: { PREMIUM_NPUBS: pubHex } }) + }); + const ent = await identity.entitlementsForUser({ id: 7, smirk_public_key: pubHex, total_credits_purchased: 0 }); + expect(ent.level).toBe('premium'); + expect(ent.tier).toBe(2); + expect(ent.packs['generated-skins']).toBe(true); // tier-1 pack unlocked by the sub + }); + + test('a non-premium user is unchanged (tier 0)', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [] }) }; + const identity = new IdentityService({ + db, + catalogService: { getCatalog: async () => undefined }, + subscriptionService: new SubscriptionService({ env: {} }) + }); + const ent = await identity.entitlementsForUser({ id: 8, smirk_public_key: 'dd'.repeat(32), total_credits_purchased: 0 }); + expect(ent.tier).toBe(0); + expect(ent.packs['generated-skins']).toBe(false); + }); +});