mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-21 16:18:33 +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:
@@ -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;
|
||||
Reference in New Issue
Block a user