source: Klonkt/src/services/PatreonService.js@ 1976a10

main
Last change on this file since 1976a10 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
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//
[cb4eda5]14// Premium gating is ON by default: the extras (newsletter, statistics, …)
[cbcc8a4]15// require a linked Patreon supporter. KLONKT_PREMIUM_ENABLED=off disables the
16// premium layer (intended for internal/demo instances).
[1b4d5dd]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() {
[cb4eda5]25 // Default ON; only an explicit 'off' disables the premium layer.
26 return String(process.env.KLONKT_PREMIUM_ENABLED || 'on').toLowerCase() !== 'off';
[1b4d5dd]27}
28export function licenseBase() { return LICENSE_URL; }
29
[834bcc3]30// --- Cache the license-server public key (for offline verification) ---
[1b4d5dd]31let _pubKey = null;
32async function licensePublicKey() {
33 if (_pubKey) return _pubKey;
34 const res = await fetch(`${LICENSE_URL}/pubkey`);
[834bcc3]35 if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
[1b4d5dd]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
[834bcc3]45// Verify an entitlement token (EdDSA JWT from the license server). Throws on
46// invalid signature, issuer, or expiry. Returns the claims on success.
[1b4d5dd]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'));
[834bcc3]52 if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
[1b4d5dd]53 const key = await licensePublicKey();
54 const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
[834bcc3]55 if (!ok) throw new Error('invalid signature');
[1b4d5dd]56 const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
[834bcc3]57 if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
58 if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
[1b4d5dd]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
[834bcc3]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.
[1b4d5dd]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
[834bcc3]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).
[8aa85d0]90export function premiumUnlocked() {
91 return !premiumEnabled() || isPremium();
92}
93
[1b4d5dd]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.