This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/lib/paywall/supporter.ts
T

150 lines
4.8 KiB
TypeScript

/**
* 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<string>('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<SupporterCache | null>(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<SupporterState>({ 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<VerifyResult> {
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<void> {
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' });
}