source: Klonkt/src/services/PatreonService.js@ db81e56

main
Last change on this file since db81e56 was cbcc8a4, checked in by Robin Genis <roboburr@…>, 3 months ago

chore: tidy premium config docs

  • Property mode set to 100644
File size: 4.5 KB
Line 
1// Patreon entitlement (premium layer).
2//
3// Model (Klonkt, 2026-06): the app + all updates are free. A set of premium
4// extras (newsletter, download-for-email, release planning + fan-only posts,
5// EPK/press kit, pro statistics, link-in-bio, embeddable player, show agenda)
6// is gated behind a $16 lifetime Patreon supporter status. The central license
7// server (license.klonkt.com)
8// checks Patreon and signs an Ed25519 JWT "entitlement token". THIS instance
9// verifies that token OFFLINE using the server's public key — a cracked/forked
10// self-host cannot forge a valid token (only the license server can sign).
11// That is the real lock; feature flags themselves can be patched on self-host
12// (deliberately accepted: $16 < effort to crack).
13//
14// Premium gating is ON by default: the extras (newsletter, statistics, …)
15// require a linked Patreon supporter. KLONKT_PREMIUM_ENABLED=off disables the
16// premium layer (intended for internal/demo instances).
17
18import crypto from 'node:crypto';
19import { getSetting, setSetting } from './SettingsService.js';
20
21const LICENSE_URL = (process.env.KLONKT_LICENSE_URL || 'https://license.klonkt.com').replace(/\/$/, '');
22const ISSUER = 'klonkt-license';
23
24export function premiumEnabled() {
25 // Default ON; only an explicit 'off' disables the premium layer.
26 return String(process.env.KLONKT_PREMIUM_ENABLED || 'on').toLowerCase() !== 'off';
27}
28export function licenseBase() { return LICENSE_URL; }
29
30// --- Cache the license-server public key (for offline verification) ---
31let _pubKey = null;
32async function licensePublicKey() {
33 if (_pubKey) return _pubKey;
34 const res = await fetch(`${LICENSE_URL}/pubkey`);
35 if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
36 const pem = await res.text();
37 _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
38 return _pubKey;
39}
40
41function b64urlToBuf(s) {
42 return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
43}
44
45// Verify an entitlement token (EdDSA JWT from the license server). Throws on
46// invalid signature, issuer, or expiry. Returns the claims on success.
47export async function verifyEntitlementToken(token) {
48 const parts = String(token || '').split('.');
49 if (parts.length !== 3) throw new Error('malformed token');
50 const [h, p, s] = parts;
51 const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
52 if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
53 const key = await licensePublicKey();
54 const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
55 if (!ok) throw new Error('invalid signature');
56 const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
57 if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
58 if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
59 return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
60}
61
62export function storeEntitlement(payload, token) {
63 setSetting('patreon_entitled', payload.entitled ? '1' : '0');
64 setSetting('patreon_sub', String(payload.sub || ''));
65 setSetting('patreon_support_cents', String(payload.lifetime_support_cents || 0));
66 setSetting('patreon_token_exp', String(payload.exp || 0));
67 setSetting('patreon_token', token || '');
68}
69
70export function clearEntitlement() {
71 for (const k of ['patreon_entitled', 'patreon_sub', 'patreon_support_cents', 'patreon_token_exp', 'patreon_token']) {
72 setSetting(k, '');
73 }
74}
75
76// Is this instance premium? Premium layer enabled + a valid, non-expired,
77// entitled stored token. Patreon lifetime never decreases, so re-linking
78// after expiry always succeeds.
79export function isPremium() {
80 if (!premiumEnabled()) return false;
81 if (getSetting('patreon_entitled') !== '1') return false;
82 const exp = Number(getSetting('patreon_token_exp', '0')) || 0;
83 if (exp && exp * 1000 < Date.now()) return false;
84 return true;
85}
86
87// Is a premium feature available? True if the premium layer is OFF (nothing is
88// gated — current behavior), or ON and this instance is entitled. False only
89// if premium is on but there is no valid Patreon connection (= paywall).
90export function premiumUnlocked() {
91 return !premiumEnabled() || isPremium();
92}
93
94export function entitlementStatus() {
95 return {
96 enabled: premiumEnabled(),
97 premium: isPremium(),
98 connected: getSetting('patreon_entitled') === '1',
99 sub: getSetting('patreon_sub', '') || null,
100 supportCents: Number(getSetting('patreon_support_cents', '0')) || 0,
101 exp: Number(getSetting('patreon_token_exp', '0')) || 0,
102 };
103}
Note: See TracBrowser for help on using the repository browser.