Index: docs/paid-posts-design.md
===================================================================
--- docs/paid-posts-design.md	(revision 4a08bfc845c79a2a45147da7178576bc27d03c64)
+++ docs/paid-posts-design.md	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -70,4 +70,17 @@
 
 `PAID_SECRET` (32 random bytes) lives in env, like the other secrets.
+
+## Concurrency (a property of the cookie-less model)
+
+Because there is no session and no "current user", the model is inherently
+multi-user. Two people unlock side by side with no shared state to collide:
+each request carries its own assertion, verified against that credential's own
+public key, and the content goes back in that one response. `paid_entitlements`
+is keyed per credential, so N passkeys are N independent rows. The challenge is
+stateless (the signed blob), so there is no single "pending challenge" slot a
+second visitor could overwrite. Unlike a cookie session, "two people in the same
+browser" cannot clobber each other. The only caveat is a shared browser profile:
+the passkey picker would then list both passkeys (a small visibility hint, not
+access).
 
 ## Data model (additive)
Index: docs/privacy-betaalde-posts.md
===================================================================
--- docs/privacy-betaalde-posts.md	(revision 4a08bfc845c79a2a45147da7178576bc27d03c64)
+++ docs/privacy-betaalde-posts.md	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -59,4 +59,13 @@
 geen gemis.
 
+## Meerdere mensen tegelijk
+
+Omdat er geen sessie en geen "ingelogde gebruiker" bestaat, kunnen meerdere
+mensen tegelijk en los van elkaar posts ontgrendelen: elke bevestiging staat op
+zichzelf. Er is geen gedeelde toestand die van elkaar afhangt. Deel je hetzelfde
+apparaat en profiel met iemand, dan kan de passkey-kiezer wel tonen dat de ander
+een passkey heeft (geen toegang, alleen zichtbaar); gebruik dan aparte apparaten
+of profielen.
+
 ## Bewaartermijn en verlopen
 
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 4a08bfc845c79a2a45147da7178576bc27d03c64)
+++ src/config/database.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -310,4 +310,18 @@
       last_used_at DATETIME
     );
+    -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
+    -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
+    -- patreon_* settings, which are Klonkt Premium's separate license flow.
+    CREATE TABLE IF NOT EXISTS paid_patreon (
+      site_id TEXT PRIMARY KEY,
+      client_id TEXT,
+      client_secret_enc TEXT,
+      campaign_id TEXT,
+      access_token_enc TEXT,
+      refresh_token_enc TEXT,
+      token_exp INTEGER,               -- unix seconds
+      default_min_cents INTEGER DEFAULT 0,
+      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
     CREATE TABLE IF NOT EXISTS ap_outbox (
       id TEXT PRIMARY KEY,            -- note path segment (uuid) → /ap/notes/<id>
Index: src/routes/admin-paid.js
===================================================================
--- src/routes/admin-paid.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/routes/admin-paid.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -0,0 +1,69 @@
+/**
+ * Admin: Paid posts (premium module, god-only). Slice 1 of klonkt-demo-aki.
+ *
+ * GET  /admin/paid          -> the owner's Patreon config form + status
+ * POST /admin/paid          -> save config (secret/token stored encrypted)
+ * POST /admin/paid/disconnect -> forget the config
+ *
+ * Premium-gated via premiumUnlocked(), like stats/downloads. This is the site
+ * owner's OWN Patreon campaign, separate from Klonkt Premium's license flow.
+ */
+import express from 'express';
+import { renderPage } from '../middleware/render.js';
+import { requireGod } from '../middleware/auth.js';
+import { premiumUnlocked } from '../services/PatreonService.js';
+import { cryptoBoxReady } from '../services/CryptoBox.js';
+import PaidPatreon from '../services/PaidPatreonService.js';
+
+const router = express.Router();
+
+function gate(req, res) {
+  if (!premiumUnlocked()) {
+    res.status(403).send('Betaalde posts is een premium-functie: koppel Patreon in Beheer, Instellingen.');
+    return false;
+  }
+  if (!res.locals.site) { res.status(400).send('Geen site.'); return false; }
+  return true;
+}
+
+router.get('/', requireGod, (req, res) => {
+  if (!gate(req, res)) return;
+  renderPage(req, res, 'pages/admin-paid', {
+    pageTitle: 'Betaalde posts',
+    bodyClass: 'on-admin',
+    status: PaidPatreon.ownerStatus(res.locals.site.id),
+    secretReady: cryptoBoxReady(),
+    saved: req.query.saved === '1',
+    error: req.query.error || null,
+  });
+});
+
+router.post('/', requireGod, (req, res) => {
+  if (!gate(req, res)) return;
+  if (!cryptoBoxReady()) return res.redirect('/admin/paid?error=' + encodeURIComponent('PAID_SECRET ontbreekt in de serverconfig; secrets kunnen niet versleuteld worden opgeslagen.'));
+  const b = req.body || {};
+  const eur = String(b.default_min_eur || '').replace(',', '.').trim();
+  const cents = eur ? Math.round(parseFloat(eur) * 100) : undefined;
+  try {
+    PaidPatreon.saveOwnerConfig(res.locals.site.id, {
+      clientId: (b.client_id || '').trim() || undefined,
+      // Empty secret/token fields keep the stored value (no re-paste needed).
+      clientSecret: (b.client_secret || '').trim() || undefined,
+      campaignId: (b.campaign_id || '').trim() || undefined,
+      accessToken: (b.access_token || '').trim() || undefined,
+      refreshToken: (b.refresh_token || '').trim() || undefined,
+      defaultMinCents: Number.isFinite(cents) ? cents : undefined,
+    });
+    return res.redirect('/admin/paid?saved=1');
+  } catch (e) {
+    return res.redirect('/admin/paid?error=' + encodeURIComponent(e.message || 'Opslaan mislukt'));
+  }
+});
+
+router.post('/disconnect', requireGod, (req, res) => {
+  if (!gate(req, res)) return;
+  PaidPatreon.disconnect(res.locals.site.id);
+  res.redirect('/admin/paid?saved=1');
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 4a08bfc845c79a2a45147da7178576bc27d03c64)
+++ src/server.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -45,4 +45,5 @@
 import adminPatreonRoutes from './routes/admin-patreon.js';
 import adminStatsRoutes from './routes/admin-stats.js';
+import adminPaidRoutes from './routes/admin-paid.js';
 import adminMediaRoutes from './routes/admin-media.js';
 import circleRoutes from './routes/circle.js';
@@ -378,4 +379,5 @@
 app.use('/admin/patreon', adminPatreonRoutes);
 app.use('/admin/stats', adminStatsRoutes);
+app.use('/admin/paid', adminPaidRoutes);
 app.use('/admin/newsletter', adminNewsletterRoutes);
 app.use('/admin/shows', adminShowsRoutes);
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,
+};
Index: src/views/pages/admin-paid.ejs
===================================================================
--- src/views/pages/admin-paid.ejs	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/views/pages/admin-paid.ejs	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -0,0 +1,59 @@
+<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
+  <h1 style="margin:0 0 .3rem">Betaalde posts</h1>
+  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
+    Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey,
+    zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters,
+    alleen het versleutelde token van jouw campagne.
+  </p>
+
+  <% if (saved) { %><p style="color:var(--accent);font-weight:600">Opgeslagen.</p><% } %>
+  <% if (error) { %><p style="color:#c0392b"><%= error %></p><% } %>
+  <% if (!secretReady) { %>
+    <p style="color:#c0392b">Let op: <code>PAID_SECRET</code> staat niet in de serverconfig. Zonder die sleutel kunnen secrets niet versleuteld worden opgeslagen.</p>
+  <% } %>
+
+  <p style="margin:.2rem 0 1.2rem">
+    Status:
+    <% if (status.connected) { %>
+      <strong style="color:var(--accent)">verbonden</strong> (campagne <%= status.campaignId %>)
+    <% } else if (status.configured) { %>
+      <strong>ingesteld, nog niet verbonden</strong> (vul een token in)
+    <% } else { %>
+      <strong>nog niet ingesteld</strong>
+    <% } %>
+  </p>
+
+  <form method="post" action="/admin/paid" style="display:flex;flex-direction:column;gap:.9rem">
+    <label>Patreon client id
+      <input type="text" name="client_id" value="<%= status.clientId || '' %>" autocomplete="off" style="width:100%">
+    </label>
+    <label>Patreon client secret
+      <input type="password" name="client_secret" placeholder="<%= status.hasSecret ? 'blijft ongewijzigd' : '' %>" autocomplete="off" style="width:100%">
+      <small style="color:var(--ink-soft,#888)">Leeg laten = huidige waarde behouden.</small>
+    </label>
+    <label>Campagne-id
+      <input type="text" name="campaign_id" value="<%= status.campaignId || '' %>" autocomplete="off" style="width:100%">
+    </label>
+    <label>Creator access token
+      <input type="password" name="access_token" placeholder="blijft ongewijzigd" autocomplete="off" style="width:100%">
+    </label>
+    <label>Creator refresh token
+      <input type="password" name="refresh_token" placeholder="blijft ongewijzigd" autocomplete="off" style="width:100%">
+      <small style="color:var(--ink-soft,#888)">De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.</small>
+    </label>
+    <label>Standaard-steunbedrag voor een betaalde post (euro)
+      <input type="text" name="default_min_eur" value="<%= status.defaultMinCents ? (status.defaultMinCents/100).toFixed(2) : '' %>" inputmode="decimal" style="width:120px">
+    </label>
+    <div style="display:flex;gap:.6rem;align-items:center">
+      <button type="submit" class="btn btn-primary">Opslaan</button>
+    </div>
+  </form>
+
+  <% if (status.configured || status.connected) { %>
+    <form method="post" action="/admin/paid/disconnect" data-confirm="Patreon-koppeling verwijderen?" style="margin-top:1rem">
+      <button type="submit" class="btn btn-danger">Koppeling verwijderen</button>
+    </form>
+  <% } %>
+
+  <p style="margin-top:1.5rem"><a href="/admin">&larr; Terug naar Beheer</a></p>
+</div>
Index: src/views/pages/admin.ejs
===================================================================
--- src/views/pages/admin.ejs	(revision 4a08bfc845c79a2a45147da7178576bc27d03c64)
+++ src/views/pages/admin.ejs	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -46,4 +46,5 @@
     <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
       <a href="/admin/stats" class="btn"><%= t('admin.b_stats') %></a>
+      <a href="/admin/paid" class="btn">Betaalde posts</a>
       <a href="/admin/newsletter" class="btn"><%= t('admin.b_newsletter') %></a>
       <% if (tenancy !== 'hub' && primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>
Index: test/paid-patreon.test.js
===================================================================
--- test/paid-patreon.test.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ test/paid-patreon.test.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -0,0 +1,90 @@
+// Paid posts slice 1 (klonkt-demo-aki): owner Patreon config is stored with the
+// creator token encrypted at rest, and refreshes. In-memory SQLite.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+process.env.PAID_SECRET = 'a-test-paid-secret-of-sufficient-length';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const PP = (await import('../src/services/PaidPatreonService.js')).default;
+const { encrypt, decrypt, signBlob, verifyBlob } = await import('../src/services/CryptoBox.js');
+
+test('CryptoBox roundtrips and rejects tampering', () => {
+  const c = encrypt('super-secret-token');
+  assert.notEqual(c, 'super-secret-token');
+  assert.equal(decrypt(c), 'super-secret-token');
+  const parts = c.split(':'); parts[2] = Buffer.from('tampered').toString('base64');
+  assert.throws(() => decrypt(parts.join(':')));
+});
+
+test('signBlob/verifyBlob: valid passes, tampered and expired fail', () => {
+  const t = signBlob({ site: 's1', purpose: 'link', cents: 500 }, 600);
+  const p = verifyBlob(t);
+  assert.equal(p.site, 's1'); assert.equal(p.purpose, 'link'); assert.equal(p.cents, 500);
+  assert.equal(verifyBlob(t.slice(0, -2) + 'xx'), null);          // bad tag
+  assert.equal(verifyBlob(signBlob({ x: 1 }, -1)), null);         // already expired
+});
+
+test('owner config stores the creator secret + token ENCRYPTED, never plaintext', () => {
+  PP.saveOwnerConfig('s1', {
+    clientId: 'cid', clientSecret: 'the-secret', campaignId: '42',
+    accessToken: 'acc-token', refreshToken: 'ref-token',
+    tokenExp: Math.floor(Date.now() / 1000) + 3600, defaultMinCents: 500,
+  });
+  // Raw DB row must not contain the plaintext secret/token.
+  const raw = db.prepare('SELECT * FROM paid_patreon WHERE site_id = ?').get('s1');
+  const dump = JSON.stringify(raw);
+  assert.ok(!dump.includes('the-secret'), 'client secret leaked in plaintext');
+  assert.ok(!dump.includes('acc-token'), 'access token leaked in plaintext');
+  assert.ok(!dump.includes('ref-token'), 'refresh token leaked in plaintext');
+  // But the service decrypts it back.
+  const c = PP.getOwnerConfig('s1');
+  assert.equal(c.clientSecret, 'the-secret');
+  assert.equal(c.accessToken, 'acc-token');
+  assert.equal(c.campaignId, '42');
+  assert.equal(c.defaultMinCents, 500);
+});
+
+test('ownerStatus never exposes secrets', () => {
+  const st = PP.ownerStatus('s1');
+  assert.equal(st.configured, true);
+  assert.equal(st.connected, true);
+  assert.equal(JSON.stringify(st).includes('the-secret'), false);
+  assert.equal(JSON.stringify(st).includes('acc-token'), false);
+});
+
+test('re-saving without a secret keeps the old one (no re-paste needed)', () => {
+  PP.saveOwnerConfig('s1', { defaultMinCents: 999 });
+  const c = PP.getOwnerConfig('s1');
+  assert.equal(c.clientSecret, 'the-secret');   // preserved
+  assert.equal(c.defaultMinCents, 999);         // updated
+});
+
+test('refreshCreatorToken stores the new token (encrypted) via injected fetch', async () => {
+  let called = null;
+  const fakeFetch = async (url, opts) => {
+    called = { url, body: opts.body };
+    return { ok: true, json: async () => ({ access_token: 'new-acc', refresh_token: 'new-ref', expires_in: 2592000 }) };
+  };
+  const ok = await PP.refreshCreatorToken('s1', fakeFetch);
+  assert.equal(ok, true);
+  assert.ok(called.url.includes('patreon.com'));
+  assert.ok(called.body.includes('grant_type=refresh_token'));
+  const c = PP.getOwnerConfig('s1');
+  assert.equal(c.accessToken, 'new-acc');
+  assert.equal(c.refreshToken, 'new-ref');
+  // and still encrypted on disk
+  const raw = db.prepare('SELECT access_token_enc FROM paid_patreon WHERE site_id = ?').get('s1');
+  assert.ok(!raw.access_token_enc.includes('new-acc'));
+});
+
+test('needsRefresh true when near expiry, false when fresh', () => {
+  PP.saveOwnerConfig('s2', { clientId: 'c', clientSecret: 's', refreshToken: 'r', accessToken: 'a', tokenExp: Math.floor(Date.now()/1000) + 60 });
+  assert.equal(PP.needsRefresh('s2'), true);    // 60s < 1h skew
+  PP.saveOwnerConfig('s2', { tokenExp: Math.floor(Date.now()/1000) + 7200 });
+  assert.equal(PP.needsRefresh('s2'), false);
+});
