Index: src/services/CryptoBox.js
===================================================================
--- src/services/CryptoBox.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/services/CryptoBox.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -0,0 +1,66 @@
+// Symmetric encryption for secrets at rest (paid posts: the site owner's
+// Patreon creator token, klonkt-demo-aki slice 1). AES-256-GCM with a key
+// derived from PAID_SECRET (env), so a database dump alone leaks nothing usable.
+// Format: base64(iv) : base64(tag) : base64(ciphertext).
+//
+// The codebase had no symmetric-crypto helper; this is that one, kept tiny and
+// native (no dependency). If PAID_SECRET is unset, encryption is refused loudly
+// rather than storing plaintext.
+import crypto from 'crypto';
+
+let _key = null;
+function key() {
+  if (_key) return _key;
+  const secret = process.env.PAID_SECRET;
+  if (!secret || String(secret).length < 16) {
+    throw new Error('PAID_SECRET is missing or too short (need >= 16 chars) to encrypt paid-posts secrets');
+  }
+  _key = crypto.scryptSync(String(secret), 'klonkt-paid', 32);
+  return _key;
+}
+
+// True when a key is configured, so callers can gate the feature instead of throwing.
+export function cryptoBoxReady() {
+  try { key(); return true; } catch { return false; }
+}
+
+export function encrypt(plaintext) {
+  if (plaintext == null) return null;
+  const iv = crypto.randomBytes(12);
+  const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
+  const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
+  const tag = cipher.getAuthTag();
+  return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
+}
+
+export function decrypt(blob) {
+  if (blob == null || blob === '') return null;
+  const parts = String(blob).split(':');
+  if (parts.length !== 3) throw new Error('malformed ciphertext');
+  const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64'));
+  const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv);
+  decipher.setAuthTag(tag);
+  return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
+}
+
+// The stateless signed blob reused for the OAuth state and the WebAuthn
+// challenge (design doc "cookie-less trick"): HMAC over a short-lived payload,
+// so no server session is needed to bind pending state to a browser.
+export function signBlob(payload, ttlSeconds = 600) {
+  const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds, nonce: crypto.randomBytes(8).toString('hex') };
+  const b = Buffer.from(JSON.stringify(body)).toString('base64url');
+  const tag = crypto.createHmac('sha256', key()).update(b).digest('base64url');
+  return `${b}.${tag}`;
+}
+
+// Returns the payload if valid and unexpired, else null. Constant-time tag check.
+export function verifyBlob(token) {
+  const [b, tag] = String(token || '').split('.');
+  if (!b || !tag) return null;
+  const expected = crypto.createHmac('sha256', key()).update(b).digest('base64url');
+  const a = Buffer.from(tag); const e = Buffer.from(expected);
+  if (a.length !== e.length || !crypto.timingSafeEqual(a, e)) return null;
+  let payload; try { payload = JSON.parse(Buffer.from(b, 'base64url').toString('utf8')); } catch { return null; }
+  if (!payload || (payload.exp && payload.exp * 1000 < Date.now())) return null;
+  return payload;
+}
Index: src/services/PaidPatreonService.js
===================================================================
--- src/services/PaidPatreonService.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/services/PaidPatreonService.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -0,0 +1,133 @@
+// Paid posts (klonkt-demo-aki) slice 1: the site owner's own Patreon campaign.
+// Stores client id/secret + the creator access/refresh token (encrypted) and a
+// default price. Separate from PatreonService, which is Klonkt Premium's
+// instance-level license flow and stays untouched.
+import db from '../config/database.js';
+import { encrypt, decrypt, cryptoBoxReady } from './CryptoBox.js';
+
+const TOKEN_URL = 'https://www.patreon.com/api/oauth2/token';
+
+// The owner's config, secrets decrypted. Returns null when unconfigured.
+export function getOwnerConfig(siteId) {
+  const row = db.prepare('SELECT * FROM paid_patreon WHERE site_id = ?').get(siteId);
+  if (!row) return null;
+  return {
+    siteId: row.site_id,
+    clientId: row.client_id || null,
+    clientSecret: row.client_secret_enc ? safeDecrypt(row.client_secret_enc) : null,
+    campaignId: row.campaign_id || null,
+    accessToken: row.access_token_enc ? safeDecrypt(row.access_token_enc) : null,
+    refreshToken: row.refresh_token_enc ? safeDecrypt(row.refresh_token_enc) : null,
+    tokenExp: row.token_exp || 0,
+    defaultMinCents: row.default_min_cents || 0,
+  };
+}
+
+// Non-secret status for the admin screen (never returns tokens/secret).
+export function ownerStatus(siteId) {
+  const c = getOwnerConfig(siteId);
+  if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
+  return {
+    configured: !!(c.clientId && c.clientSecret),
+    connected: !!(c.accessToken && c.campaignId),
+    clientId: c.clientId || null,
+    campaignId: c.campaignId || null,
+    defaultMinCents: c.defaultMinCents || 0,
+    tokenExp: c.tokenExp || 0,
+    hasSecret: !!c.clientSecret,
+  };
+}
+
+// Upsert. Only overwrites secret/token fields when a new value is provided, so
+// the admin form can be re-saved without re-pasting the secret.
+export function saveOwnerConfig(siteId, patch) {
+  if (!cryptoBoxReady()) throw new Error('PAID_SECRET is not set: cannot store Patreon secrets');
+  const cur = getOwnerConfig(siteId) || {};
+  const merged = {
+    clientId: patch.clientId ?? cur.clientId ?? null,
+    clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
+    campaignId: patch.campaignId ?? cur.campaignId ?? null,
+    accessToken: patch.accessToken ?? cur.accessToken ?? null,
+    refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
+    tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
+    defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
+  };
+  db.prepare(`INSERT INTO paid_patreon
+      (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, updated_at)
+      VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
+    ON CONFLICT(site_id) DO UPDATE SET
+      client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
+      campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
+      refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
+      default_min_cents=excluded.default_min_cents, updated_at=CURRENT_TIMESTAMP`)
+    .run(
+      siteId,
+      merged.clientId,
+      merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
+      merged.campaignId,
+      merged.accessToken != null ? encrypt(merged.accessToken) : null,
+      merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
+      merged.tokenExp || 0,
+      Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
+    );
+}
+
+export function disconnect(siteId) {
+  db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
+}
+
+export function defaultMinCents(siteId) {
+  const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
+  return row ? (row.default_min_cents || 0) : 0;
+}
+
+// True when the stored creator token is missing or within `skewSeconds` of exp.
+export function needsRefresh(siteId, skewSeconds = 3600) {
+  const c = getOwnerConfig(siteId);
+  if (!c || !c.refreshToken) return false;
+  return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
+}
+
+// Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
+// is injectable for tests; defaults to global fetch.
+export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
+  const c = getOwnerConfig(siteId);
+  if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
+  const body = new URLSearchParams({
+    grant_type: 'refresh_token',
+    refresh_token: c.refreshToken,
+    client_id: c.clientId,
+    client_secret: c.clientSecret,
+  });
+  const res = await fetchImpl(TOKEN_URL, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: body.toString(),
+  });
+  if (!res.ok) return false;
+  const j = await res.json();
+  if (!j || !j.access_token) return false;
+  saveOwnerConfig(siteId, {
+    accessToken: j.access_token,
+    refreshToken: j.refresh_token || c.refreshToken,
+    tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
+  });
+  return true;
+}
+
+// A valid creator access token, refreshing first if it is stale. Null when the
+// owner has not connected. Used by the patron verify path (slice 3).
+export async function creatorAccessToken(siteId, fetchImpl = fetch) {
+  if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
+  const c = getOwnerConfig(siteId);
+  return c && c.accessToken ? c.accessToken : null;
+}
+
+function safeDecrypt(blob) {
+  try { return decrypt(blob); } catch { return null; }
+}
+
+export default {
+  getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
+  defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
+};
