/** * Supporter (subscription) state. * * The user pastes an access key once; it is stored locally and re-verified * against the self-hosted verify API on load. The last good result is cached so * gated content shows instantly on reload (and keeps working briefly offline) * without waiting for the network round-trip. * * This gate is a convenience/honor-system gate: the frontend is open source and * static, so it can be bypassed. Keeping the subscriber list server-side (in the * paywall repo) is what makes it meaningful in practice. */ import { derived, get, writable } from 'svelte/store'; import { persisted } from 'svelte-persisted-store'; import { PAYWALL_API_BASE } from './config'; /** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */ export const storedLicenseKey = persisted('license_key', ''); export interface SupporterCache { valid: boolean; tier?: string; /** ISO date the subscription lapses, or null for a lifetime key. */ expires?: string | null; /** epoch ms of the last verify call */ checkedAt: number; } const CACHE_KEY = 'supporter_cache_v1'; const LEGACY_CACHE_KEY = 'premium_cache_v1'; /** * Carries the pre-rename cache over on first load, so supporters who already * verified aren't shown a locked page while the key re-verifies. */ function readLegacyCache(): SupporterCache | null { if (typeof localStorage === 'undefined') return null; try { if (localStorage.getItem(CACHE_KEY)) return null; const legacy = localStorage.getItem(LEGACY_CACHE_KEY); if (!legacy) return null; localStorage.removeItem(LEGACY_CACHE_KEY); return JSON.parse(legacy) as SupporterCache; } catch { return null; } } /** Last verify result, persisted so the UI doesn't flash "locked" on reload. */ export const storedSupporterCache = persisted(CACHE_KEY, readLegacyCache()); export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error'; export interface SupporterState { status: SupporterStatus; tier?: string; expires?: string | null; error?: string; } /** Live verification state for the current session. */ export const supporterState = writable({ status: 'idle' }); function notExpired(expires: string | null | undefined): boolean { if (!expires) return true; // lifetime key const t = Date.parse(expires); return Number.isFinite(t) && t > Date.now(); } /** * Whether supporter content should be shown. A live "valid"/"invalid" result * wins; otherwise we fall back to the cached result (so a reload or a brief * network blip doesn't lock a paying user out). */ export const isSupporter = derived( [supporterState, storedSupporterCache], ([$state, $cache]): boolean => { if ($state.status === 'valid') return true; if ($state.status === 'invalid') return false; return !!($cache && $cache.valid && notExpired($cache.expires)); } ); export interface VerifyResult { valid: boolean; tier?: string; expires?: string | null; error?: string; } /** * Verify a key against the API. On success the key is persisted and the cache * updated. On an invalid key the stored key is left untouched (so an expired * subscription can still show a "renew" state) but the cache is marked invalid. */ export async function verifyKey(key: string): Promise { const trimmed = key.trim(); if (!trimmed) { supporterState.set({ status: 'invalid' }); return { valid: false, error: 'Enter your access key.' }; } supporterState.set({ status: 'checking' }); try { const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, { headers: { accept: 'application/json' } }); const data = (await res.json()) as VerifyResult; if (res.ok && data.valid) { storedLicenseKey.set(trimmed); storedSupporterCache.set({ valid: true, tier: data.tier, expires: data.expires ?? null, checkedAt: Date.now() }); supporterState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null }); return { valid: true, tier: data.tier, expires: data.expires ?? null }; } storedSupporterCache.set({ valid: false, checkedAt: Date.now() }); supporterState.set({ status: 'invalid' }); return { valid: false }; } catch (err) { const message = err instanceof Error ? err.message : String(err); supporterState.set({ status: 'error', error: message }); return { valid: false, error: message }; } } /** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */ export async function refreshSupporter(): Promise { const key = get(storedLicenseKey); if (!key) { supporterState.set({ status: 'idle' }); return; } await verifyKey(key); } /** Forget the key and supporter state ("sign out"). */ export function clearLicense(): void { storedLicenseKey.set(''); storedSupporterCache.set(null); supporterState.set({ status: 'idle' }); }