source: Klonkt/src/services/PatreonService.js@ 8ad1784

main
Last change on this file since 8ad1784 was 0c8768a, checked in by Robin Genis <roboburr@…>, 3 months ago

premium price: display €14 -> $16 (USD campaign charges $16; gate floor stays $14/1400)

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 4.3 KB
RevLine 
[834bcc3]1// Patreon entitlement (premium layer).
[1b4d5dd]2//
[f0c3f83]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)
[0c8768a]6// is gated behind a $16 lifetime Patreon supporter status. The central license
[f0c3f83]7// server (license.klonkt.com)
[834bcc3]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
[0c8768a]12// (deliberately accepted: $16 < effort to crack).
[1b4d5dd]13//
[834bcc3]14// Premium is OFF by default (KLONKT_PREMIUM_ENABLED != 'on'): no premium UI
15// is shown and nothing is gated. Self-hosters enable it once Patreon is set up.
[1b4d5dd]16
17import crypto from 'node:crypto';
18import { getSetting, setSetting } from './SettingsService.js';
19
20const LICENSE_URL = (process.env.KLONKT_LICENSE_URL || 'https://license.klonkt.com').replace(/\/$/, '');
21const ISSUER = 'klonkt-license';
22
23export function premiumEnabled() {
24 return String(process.env.KLONKT_PREMIUM_ENABLED || '').toLowerCase() === 'on';
25}
26export function licenseBase() { return LICENSE_URL; }
27
[834bcc3]28// --- Cache the license-server public key (for offline verification) ---
[1b4d5dd]29let _pubKey = null;
30async function licensePublicKey() {
31 if (_pubKey) return _pubKey;
32 const res = await fetch(`${LICENSE_URL}/pubkey`);
[834bcc3]33 if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
[1b4d5dd]34 const pem = await res.text();
35 _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
36 return _pubKey;
37}
38
39function b64urlToBuf(s) {
40 return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
41}
42
[834bcc3]43// Verify an entitlement token (EdDSA JWT from the license server). Throws on
44// invalid signature, issuer, or expiry. Returns the claims on success.
[1b4d5dd]45export async function verifyEntitlementToken(token) {
46 const parts = String(token || '').split('.');
47 if (parts.length !== 3) throw new Error('malformed token');
48 const [h, p, s] = parts;
49 const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
[834bcc3]50 if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
[1b4d5dd]51 const key = await licensePublicKey();
52 const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
[834bcc3]53 if (!ok) throw new Error('invalid signature');
[1b4d5dd]54 const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
[834bcc3]55 if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
56 if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
[1b4d5dd]57 return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
58}
59
60export function storeEntitlement(payload, token) {
61 setSetting('patreon_entitled', payload.entitled ? '1' : '0');
62 setSetting('patreon_sub', String(payload.sub || ''));
63 setSetting('patreon_support_cents', String(payload.lifetime_support_cents || 0));
64 setSetting('patreon_token_exp', String(payload.exp || 0));
65 setSetting('patreon_token', token || '');
66}
67
68export function clearEntitlement() {
69 for (const k of ['patreon_entitled', 'patreon_sub', 'patreon_support_cents', 'patreon_token_exp', 'patreon_token']) {
70 setSetting(k, '');
71 }
72}
73
[834bcc3]74// Is this instance premium? Premium layer enabled + a valid, non-expired,
75// entitled stored token. Patreon lifetime never decreases, so re-linking
76// after expiry always succeeds.
[1b4d5dd]77export function isPremium() {
78 if (!premiumEnabled()) return false;
79 if (getSetting('patreon_entitled') !== '1') return false;
80 const exp = Number(getSetting('patreon_token_exp', '0')) || 0;
81 if (exp && exp * 1000 < Date.now()) return false;
82 return true;
83}
84
[834bcc3]85// Is a premium feature available? True if the premium layer is OFF (nothing is
86// gated — current behavior), or ON and this instance is entitled. False only
87// if premium is on but there is no valid Patreon connection (= paywall).
[8aa85d0]88export function premiumUnlocked() {
89 return !premiumEnabled() || isPremium();
90}
91
[1b4d5dd]92export function entitlementStatus() {
93 return {
94 enabled: premiumEnabled(),
95 premium: isPremium(),
96 connected: getSetting('patreon_entitled') === '1',
97 sub: getSetting('patreon_sub', '') || null,
98 supportCents: Number(getSetting('patreon_support_cents', '0')) || 0,
99 exp: Number(getSetting('patreon_token_exp', '0')) || 0,
100 };
101}
Note: See TracBrowser for help on using the repository browser.