Index: test/paid-unlock.test.js
===================================================================
--- test/paid-unlock.test.js	(revision 6cbd0142768e2cd623437742cd6c08cc33fda44f)
+++ test/paid-unlock.test.js	(revision 6cbd0142768e2cd623437742cd6c08cc33fda44f)
@@ -0,0 +1,44 @@
+// Paid posts slice 4 (klonkt-demo-3lz): the per-post unlock. The WebAuthn
+// assertion needs a browser, so here we cover the pure pieces: authentication
+// options carry a challenge and empty allowCredentials (discoverable), the
+// counter bumps, and the tier gate compares entitlement cents to the post's.
+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 Passkey = (await import('../src/services/PasskeyService.js')).default;
+
+test('authentication options carry a challenge, rpID host, and empty allowCredentials', async () => {
+  const opts = await Passkey.authenticationOptions('https://test.example');
+  assert.ok(opts.challenge && typeof opts.challenge === 'string');
+  assert.equal(opts.rpId, 'test.example');
+  assert.deepEqual(opts.allowCredentials || [], []);   // discoverable: browser offers the passkeys
+});
+
+test('bumpCounter persists the new signature counter (clone detection)', () => {
+  Passkey.storeEntitlement({ credentialId: 'uc1', siteId: 's1', publicKey: 'PK', counter: 4, minCents: 300 });
+  Passkey.bumpCounter('uc1', 7);
+  const row = db.prepare('SELECT counter FROM paid_entitlements WHERE credential_id = ?').get('uc1');
+  assert.equal(row.counter, 7);
+});
+
+test('tier gate: an entitlement below the post cents is refused, at/above passes', () => {
+  Passkey.storeEntitlement({ credentialId: 'uc2', siteId: 's1', publicKey: 'PK', counter: 0, minCents: 300 });
+  const ent = Passkey.getEntitlement('uc2', 's1');
+  // mirrors the /paid/unlock check: (ent.min_cents || 0) < payload.cents  -> refuse
+  assert.equal((ent.min_cents || 0) < 500, true);    // post needs 500, entitlement 300 -> blocked
+  assert.equal((ent.min_cents || 0) < 300, false);   // post needs 300 -> allowed
+  assert.equal((ent.min_cents || 0) < 100, false);   // post needs 100 -> allowed
+});
+
+test('an expired entitlement is not returned to the unlock path', () => {
+  Passkey.storeEntitlement({ credentialId: 'uc3', siteId: 's1', publicKey: 'PK', minCents: 100, ttlDays: 30 });
+  db.prepare('UPDATE paid_entitlements SET expires_at = 1 WHERE credential_id = ?').run('uc3');
+  assert.equal(Passkey.getEntitlement('uc3', 's1'), null);
+});
