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);
+});
