source: Klonkt/src/services/PatreonService.js@ 49f698b

main
Last change on this file since 49f698b was 8aa85d0, checked in by roboburr <roboburr@…>, 3 months ago

Prutter = premium Hub feature: Hub-only + behind premium gate

Prutter (DMs) is now hard-scoped to Hub mode (route 404s outside hub) and
locked behind the premium gate via the new premiumUnlocked() (= premium off
→ freely available, demos keep working; premium on → Patreon required).
UI entry points (topnav/bottom-tab/Send DM) follow the same condition.
Added to the premium description in Admin → Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 4.3 KB
RevLine 
[1b4d5dd]1// Patreon-entitlement (premium-laag).
2//
3// Model (Klonkt, 2026-06): de app + alle updates zijn gratis. Een paar premium-
4// modules (Hub-modus, Statistieken, Fan-login) zitten achter een $10-lifetime
5// Patreon-supporter-status. De centrale license-server (license.klonkt.com)
6// checkt Patreon en tekent een Ed25519-JWT "entitlement-token". DEZE instance
7// verifieert dat token OFFLINE met de publieke sleutel van de server — een
8// gekraakte/geforkte self-host kan dus geen geldig token verzinnen (alleen de
9// license-server kan tekenen). Dat is het echte slot; de feature-flags zelf zijn
10// op self-host wel te patchen (bewust geaccepteerd: $10 < moeite om te kraken).
11//
12// Premium staat STANDAARD UIT (KLONKT_PREMIUM_ENABLED != 'on'): dan is er geen
13// premium-UI en wordt er niets gegate. De self-hoster zet 'm aan zodra Patreon
14// geregeld is.
15
16import crypto from 'node:crypto';
17import { getSetting, setSetting } from './SettingsService.js';
18
19const LICENSE_URL = (process.env.KLONKT_LICENSE_URL || 'https://license.klonkt.com').replace(/\/$/, '');
20const ISSUER = 'klonkt-license';
21
22export function premiumEnabled() {
23 return String(process.env.KLONKT_PREMIUM_ENABLED || '').toLowerCase() === 'on';
24}
25export function licenseBase() { return LICENSE_URL; }
26
27// --- Publieke sleutel van de license-server cachen (voor offline verificatie) ---
28let _pubKey = null;
29async function licensePublicKey() {
30 if (_pubKey) return _pubKey;
31 const res = await fetch(`${LICENSE_URL}/pubkey`);
32 if (!res.ok) throw new Error('pubkey fetch faalde: ' + res.status);
33 const pem = await res.text();
34 _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
35 return _pubKey;
36}
37
38function b64urlToBuf(s) {
39 return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
40}
41
42// Verifieer een entitlement-token (EdDSA-JWT van de license-server). Gooit bij
43// ongeldige handtekening/issuer/verlooptijd. Geeft de claims terug.
44export async function verifyEntitlementToken(token) {
45 const parts = String(token || '').split('.');
46 if (parts.length !== 3) throw new Error('malformed token');
47 const [h, p, s] = parts;
48 const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
49 if (header.alg !== 'EdDSA') throw new Error('onverwacht alg');
50 const key = await licensePublicKey();
51 const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
52 if (!ok) throw new Error('ongeldige handtekening');
53 const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
54 if (payload.iss !== ISSUER) throw new Error('onverwachte issuer');
55 if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('verlopen token');
56 return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
57}
58
59export function storeEntitlement(payload, token) {
60 setSetting('patreon_entitled', payload.entitled ? '1' : '0');
61 setSetting('patreon_sub', String(payload.sub || ''));
62 setSetting('patreon_support_cents', String(payload.lifetime_support_cents || 0));
63 setSetting('patreon_token_exp', String(payload.exp || 0));
64 setSetting('patreon_token', token || '');
65}
66
67export function clearEntitlement() {
68 for (const k of ['patreon_entitled', 'patreon_sub', 'patreon_support_cents', 'patreon_token_exp', 'patreon_token']) {
69 setSetting(k, '');
70 }
71}
72
73// Is deze instance premium? Premium-laag aan + een geldig, niet-verlopen,
74// entitled opgeslagen token. Patreon-lifetime daalt nooit, dus opnieuw koppelen
75// na verloop slaagt altijd.
76export function isPremium() {
77 if (!premiumEnabled()) return false;
78 if (getSetting('patreon_entitled') !== '1') return false;
79 const exp = Number(getSetting('patreon_token_exp', '0')) || 0;
80 if (exp && exp * 1000 < Date.now()) return false;
81 return true;
82}
83
[8aa85d0]84// Is een premium-feature beschikbaar? True als de premium-laag UIT staat (dan is
85// niets gegate — huidige gedrag), of AAN én deze instance is entitled. False alleen
86// als premium aan staat maar er geen geldige Patreon-koppeling is (= betaalmuur).
87export function premiumUnlocked() {
88 return !premiumEnabled() || isPremium();
89}
90
[1b4d5dd]91export function entitlementStatus() {
92 return {
93 enabled: premiumEnabled(),
94 premium: isPremium(),
95 connected: getSetting('patreon_entitled') === '1',
96 sub: getSetting('patreon_sub', '') || null,
97 supportCents: Number(getSetting('patreon_support_cents', '0')) || 0,
98 exp: Number(getSetting('patreon_token_exp', '0')) || 0,
99 };
100}
Note: See TracBrowser for help on using the repository browser.