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

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

feat(premium): gate premium extras by default (KLONKT_PREMIUM_ENABLED defaults on)

The premium layer was off by default, so a fresh self-host got the newsletter,
statistics and the other extras for free — mismatching the salespage ($16
unlocks them). Now premiumEnabled() defaults ON; set KLONKT_PREMIUM_ENABLED=off
to make everything free on an instance. Documented in .env.example.

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

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