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

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

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

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

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