mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+22
-9
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string|null>} 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user