Index: src/services/PasskeyService.js
===================================================================
--- src/services/PasskeyService.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/services/PasskeyService.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,92 @@
+// Paid posts (klonkt-demo-aki) slice 3: passkey registration + verification for
+// pseudonymous entitlements. Uses @simplewebauthn/server. Cookie-less: the
+// challenge is not kept in a session but travels inside a signed blob
+// (CryptoBox.signBlob) that the client returns, so there is nothing to store
+// between the two requests. An entitlement is {passkey, site, cents, expiry}
+// with NO patron identity.
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
+
+const DEFAULT_TTL_DAYS = 32;   // aligns with Patreon's monthly cycle; re-link after
+
+// rpID is the site host; origin is the full base URL.
+export function rpFor(base) {
+  let host = ''; try { host = new URL(base).host.split(':')[0]; } catch { /* keep empty */ }
+  return { rpID: host, origin: String(base).replace(/\/+$/, '') };
+}
+
+// Registration options for a fresh, discoverable (usernameless) passkey. The
+// user handle is random: the credential is pseudonymous by design.
+export async function registrationOptions(base, siteSlug) {
+  const { rpID } = rpFor(base);
+  return generateRegistrationOptions({
+    rpName: `Supporter of ${siteSlug}`,
+    rpID,
+    userName: 'supporter',
+    userDisplayName: 'Supporter',
+    userID: crypto.randomBytes(16),
+    attestationType: 'none',
+    authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
+    timeout: 120000,
+  });
+}
+
+// Verify a registration response against the challenge (read from the signed
+// blob by the caller). Returns the credential to store, or null.
+export async function verifyRegistration(base, response, expectedChallenge) {
+  const { rpID, origin } = rpFor(base);
+  let v;
+  try {
+    v = await verifyRegistrationResponse({
+      response,
+      expectedChallenge,
+      expectedOrigin: origin,
+      expectedRPID: rpID,
+      requireUserVerification: false,
+    });
+  } catch { return null; }
+  if (!v || !v.verified || !v.registrationInfo) return null;
+  const cred = v.registrationInfo.credential;
+  return {
+    credentialId: cred.id,                                        // base64url string
+    publicKey: Buffer.from(cred.publicKey).toString('base64url'), // COSE key bytes
+    counter: cred.counter || 0,
+    transports: response.response && response.response.transports ? JSON.stringify(response.response.transports) : null,
+  };
+}
+
+// Store (or refresh) a pseudonymous entitlement for this passkey.
+export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
+  const expiresAt = Math.floor(Date.now() / 1000) + ttlDays * 86400;
+  db.prepare(`INSERT INTO paid_entitlements
+      (credential_id, site_id, public_key, counter, transports, min_cents, expires_at, created_at)
+      VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
+    ON CONFLICT(credential_id) DO UPDATE SET
+      public_key=excluded.public_key, counter=excluded.counter, transports=excluded.transports,
+      min_cents=excluded.min_cents, expires_at=excluded.expires_at`)
+    .run(credentialId, siteId, publicKey, counter || 0, transports || null, Math.max(0, minCents || 0), expiresAt);
+  return expiresAt;
+}
+
+// A valid, unexpired entitlement for this passkey on this site, else null.
+export function getEntitlement(credentialId, siteId) {
+  const row = db.prepare('SELECT * FROM paid_entitlements WHERE credential_id = ? AND site_id = ?').get(credentialId, siteId);
+  if (!row) return null;
+  if ((row.expires_at || 0) < Math.floor(Date.now() / 1000)) return null;
+  return row;
+}
+
+export function deleteEntitlement(credentialId) {
+  return db.prepare('DELETE FROM paid_entitlements WHERE credential_id = ?').run(credentialId).changes > 0;
+}
+
+// Prune expired entitlements (Scheduler, slice 5).
+export function pruneExpired() {
+  return db.prepare('DELETE FROM paid_entitlements WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)).changes;
+}
+
+export default {
+  rpFor, registrationOptions, verifyRegistration, storeEntitlement,
+  getEntitlement, deleteEntitlement, pruneExpired,
+};
