Index: package-lock.json
===================================================================
--- package-lock.json	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ package-lock.json	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -1,5 +1,5 @@
 {
   "name": "klonkt",
-  "version": "1.0.0",
+  "version": "1.6.0-dev",
   "lockfileVersion": 3,
   "requires": true,
@@ -7,8 +7,10 @@
     "": {
       "name": "klonkt",
-      "version": "1.0.0",
+      "version": "1.6.0-dev",
       "license": "AGPL-3.0-or-later",
       "dependencies": {
         "@resvg/resvg-js": "^2.6.2",
+        "@simplewebauthn/browser": "^13.3.0",
+        "@simplewebauthn/server": "^13.3.2",
         "bcryptjs": "^2.4.3",
         "better-sqlite3": "^11.3.0",
@@ -64,4 +66,184 @@
       }
     },
+    "node_modules/@hexagon/base64": {
+      "version": "1.1.28",
+      "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+      "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
+      "license": "MIT"
+    },
+    "node_modules/@levischuck/tiny-cbor": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
+      "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
+      "license": "MIT"
+    },
+    "node_modules/@peculiar/asn1-android": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
+      "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-cms": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
+      "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/asn1-x509": "^2.8.0",
+        "@peculiar/asn1-x509-attr": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-csr": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
+      "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/asn1-x509": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-ecc": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
+      "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/asn1-x509": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-pfx": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
+      "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-cms": "^2.8.0",
+        "@peculiar/asn1-pkcs8": "^2.8.0",
+        "@peculiar/asn1-rsa": "^2.8.0",
+        "@peculiar/asn1-schema": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-pkcs8": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
+      "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/asn1-x509": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-pkcs9": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
+      "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-cms": "^2.8.0",
+        "@peculiar/asn1-pfx": "^2.8.0",
+        "@peculiar/asn1-pkcs8": "^2.8.0",
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/asn1-x509": "^2.8.0",
+        "@peculiar/asn1-x509-attr": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-rsa": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
+      "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/asn1-x509": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-schema": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
+      "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/utils": "^2.0.2",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-x509": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
+      "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/utils": "^2.0.2",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-x509-attr": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
+      "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.8.0",
+        "@peculiar/asn1-x509": "^2.8.0",
+        "asn1js": "^3.0.10",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/utils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+      "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/x509": {
+      "version": "1.14.3",
+      "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
+      "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-cms": "^2.6.0",
+        "@peculiar/asn1-csr": "^2.6.0",
+        "@peculiar/asn1-ecc": "^2.6.0",
+        "@peculiar/asn1-pkcs9": "^2.6.0",
+        "@peculiar/asn1-rsa": "^2.6.0",
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.0",
+        "pvtsutils": "^1.3.6",
+        "reflect-metadata": "^0.2.2",
+        "tslib": "^2.8.1",
+        "tsyringe": "^4.10.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
     "node_modules/@resvg/resvg-js": {
       "version": "2.6.2",
@@ -279,4 +461,29 @@
       }
     },
+    "node_modules/@simplewebauthn/browser": {
+      "version": "13.3.0",
+      "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
+      "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
+      "license": "MIT"
+    },
+    "node_modules/@simplewebauthn/server": {
+      "version": "13.3.2",
+      "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
+      "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@hexagon/base64": "^1.1.27",
+        "@levischuck/tiny-cbor": "^0.2.2",
+        "@peculiar/asn1-android": "^2.6.0",
+        "@peculiar/asn1-ecc": "^2.6.1",
+        "@peculiar/asn1-rsa": "^2.6.1",
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "@peculiar/x509": "^1.14.3"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
     "node_modules/@types/node": {
       "version": "10.17.60",
@@ -344,4 +551,18 @@
       "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
       "license": "MIT"
+    },
+    "node_modules/asn1js": {
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+      "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "pvtsutils": "^1.3.6",
+        "pvutils": "^1.1.5",
+        "tslib": "^2.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
     },
     "node_modules/async": {
@@ -1776,4 +1997,22 @@
       }
     },
+    "node_modules/pvtsutils": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+      "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/pvutils": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+      "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
     "node_modules/qs": {
       "version": "6.15.1",
@@ -1852,4 +2091,10 @@
         "node": ">= 6"
       }
+    },
+    "node_modules/reflect-metadata": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+      "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+      "license": "Apache-2.0"
     },
     "node_modules/safe-buffer": {
@@ -2154,4 +2399,28 @@
       }
     },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/tsyringe": {
+      "version": "4.10.0",
+      "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+      "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^1.9.3"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/tsyringe/node_modules/tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+      "license": "0BSD"
+    },
     "node_modules/tunnel-agent": {
       "version": "0.6.0",
Index: package.json
===================================================================
--- package.json	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ package.json	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -16,4 +16,6 @@
   "dependencies": {
     "@resvg/resvg-js": "^2.6.2",
+    "@simplewebauthn/browser": "^13.3.0",
+    "@simplewebauthn/server": "^13.3.2",
     "bcryptjs": "^2.4.3",
     "better-sqlite3": "^11.3.0",
Index: src/assets/vendor/simplewebauthn-browser.umd.min.js
===================================================================
--- src/assets/vendor/simplewebauthn-browser.umd.min.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/assets/vendor/simplewebauthn-browser.umd.min.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,2 @@
+/* [@simplewebauthn/browser@13.3.0] */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let r="";for(const e of t)r+=String.fromCharCode(e);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function r(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;e<o.length;e++)a[e]=o.charCodeAt(e);return i}function n(){return o.stubThis(void 0!==globalThis?.PublicKeyCredential&&"function"==typeof globalThis.PublicKeyCredential)}const o={stubThis:e=>e};function i(e){const{id:t}=e;return{...e,id:r(t),transports:e.transports}}function a(e){return"localhost"===e||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}class s extends Error{constructor({message:e,code:t,cause:r,name:n}){super(e,{cause:r}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??r.name,this.code=t}}const l=new class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){const e=new Error("Cancelling existing WebAuthn API call for new one");e.name="AbortError",this.controller.abort(e)}const e=new AbortController;return this.controller=e,e.signal}cancelCeremony(){if(this.controller){const e=new Error("Manually cancelling existing WebAuthn API call");e.name="AbortError",this.controller.abort(e),this.controller=void 0}}},c=["cross-platform","platform"];function u(e){if(e&&!(c.indexOf(e)<0))return e}function d(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.\n`,t)}function h(){if(!n())return p.stubThis(new Promise((e=>e(!1))));const e=globalThis.PublicKeyCredential;return void 0===e?.isConditionalMediationAvailable?p.stubThis(new Promise((e=>e(!1)))):p.stubThis(e.isConditionalMediationAvailable())}const p={stubThis:e=>e};e.WebAuthnAbortService=l,e.WebAuthnError=s,e._browserSupportsWebAuthnAutofillInternals=p,e._browserSupportsWebAuthnInternals=o,e.base64URLStringToBuffer=r,e.browserSupportsWebAuthn=n,e.browserSupportsWebAuthnAutofill=h,e.bufferToBase64URLString=t,e.platformAuthenticatorIsAvailable=function(){return n()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise((e=>e(!1)))},e.startAuthentication=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useBrowserAutofill:c=!1,verifyBrowserAutofillInput:d=!0}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");let p;0!==o.allowCredentials?.length&&(p=o.allowCredentials?.map(i));const f={...o,challenge:r(o.challenge),allowCredentials:p},b={};if(c){if(!await h())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&d)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');b.mediation="conditional",f.allowCredentials=[]}let R;b.publicKey=f,b.signal=l.createNewAbortSignal();try{R=await navigator.credentials.get(b)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rpId!==t)return new s({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:b})}if(!R)throw new Error("Authentication was not completed");const{id:g,rawId:w,response:A,type:E}=R;let m;return A.userHandle&&(m=t(A.userHandle)),{id:g,rawId:t(w),response:{authenticatorData:t(A.authenticatorData),clientDataJSON:t(A.clientDataJSON),signature:t(A.signature),userHandle:m},type:E,clientExtensionResults:R.getClientExtensionResults(),authenticatorAttachment:u(R.authenticatorAttachment)}},e.startRegistration=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useAutoRegister:c=!1}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");const h={...o,challenge:r(o.challenge),user:{...o.user,id:r(o.user.id)},excludeCredentials:o.excludeCredentials?.map(i)},p={};let f;c&&(p.mediation="conditional"),p.publicKey=h,p.signal=l.createNewAbortSignal();try{f=await navigator.credentials.create(p)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if("ConstraintError"===e.name){if(!0===r.authenticatorSelection?.requireResidentKey)return new s({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if("conditional"===t.mediation&&"required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if("required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if("InvalidStateError"===e.name)return new s({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("NotSupportedError"===e.name)return 0===r.pubKeyCredParams.filter((e=>"public-key"===e.type)).length?new s({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new s({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rp.id!==t)return new s({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new s({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:p})}if(!f)throw new Error("Registration was not completed");const{id:b,rawId:R,response:g,type:w}=f;let A,E,m,y;if("function"==typeof g.getTransports&&(A=g.getTransports()),"function"==typeof g.getPublicKeyAlgorithm)try{E=g.getPublicKeyAlgorithm()}catch(e){d("getPublicKeyAlgorithm()",e)}if("function"==typeof g.getPublicKey)try{const e=g.getPublicKey();null!==e&&(m=t(e))}catch(e){d("getPublicKey()",e)}if("function"==typeof g.getAuthenticatorData)try{y=t(g.getAuthenticatorData())}catch(e){d("getAuthenticatorData()",e)}return{id:b,rawId:t(R),response:{attestationObject:t(g.attestationObject),clientDataJSON:t(g.clientDataJSON),transports:A,publicKeyAlgorithm:E,publicKey:m,authenticatorData:y},type:w,clientExtensionResults:f.getClientExtensionResults(),authenticatorAttachment:u(f.authenticatorAttachment)}}}));
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/config/database.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -323,4 +323,16 @@
       default_min_cents INTEGER DEFAULT 0,
       updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    -- One row per passkey. NO patron identity is stored (design decision):
+    -- {passkey, site, proven cents, expiry}. Not traceable to a person.
+    CREATE TABLE IF NOT EXISTS paid_entitlements (
+      credential_id TEXT PRIMARY KEY,   -- WebAuthn credential id (opaque, base64url)
+      site_id TEXT NOT NULL,
+      public_key TEXT NOT NULL,         -- COSE public key, base64url
+      counter INTEGER DEFAULT 0,
+      transports TEXT,
+      min_cents INTEGER DEFAULT 0,      -- the amount proven at link time
+      expires_at INTEGER NOT NULL,      -- unix seconds; re-link after
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
     );
     CREATE TABLE IF NOT EXISTS ap_outbox (
Index: src/routes/paid.js
===================================================================
--- src/routes/paid.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/routes/paid.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,97 @@
+/**
+ * Paid posts (klonkt-demo-aki) slice 3: the patron link + passkey flow.
+ * Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in
+ * signed blobs (CryptoBox), never a session.
+ *
+ * GET  /paid/link?post=<slug>  -> redirect to Patreon authorize
+ * GET  /paid/callback          -> verify patron, render the passkey page
+ * POST /paid/register          -> verify the passkey, store the entitlement
+ */
+import express from 'express';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
+import { premiumUnlocked } from '../services/PatreonService.js';
+import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
+import PaidPatreon from '../services/PaidPatreonService.js';
+import Passkey from '../services/PasskeyService.js';
+
+const router = express.Router();
+const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
+
+const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
+
+// The feature is only live when premium is on, secrets can be encrypted, and the
+// owner has connected a campaign.
+function ready(req, res) {
+  const site = res.locals.site;
+  if (!site) { res.status(404).end(); return null; }
+  if (!premiumUnlocked() || !cryptoBoxReady()) { res.status(404).end(); return null; }
+  const cfg = PaidPatreon.getOwnerConfig(site.id);
+  if (!cfg || !cfg.clientId || !cfg.campaignId) { res.status(404).end(); return null; }
+  return { site, cfg };
+}
+
+// Step 1: send the visitor to Patreon.
+router.get('/link', (req, res) => {
+  const r = ready(req, res); if (!r) return;
+  const slug = String(req.query.post || '').trim();
+  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
+  if (!post || !post.paid) return res.redirect((res.locals.siteUrlBase || '') + '/' + (slug || ''));
+  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
+  const state = signBlob({ purpose: 'patron', siteId: r.site.id, cents, post: post.slug }, 900);
+  const url = `${AUTHORIZE}?response_type=code&client_id=${encodeURIComponent(r.cfg.clientId)}`
+    + `&redirect_uri=${encodeURIComponent(baseUrl(req) + '/paid/callback')}`
+    + `&scope=${encodeURIComponent('identity identity.memberships')}`
+    + `&state=${encodeURIComponent(state)}`;
+  res.redirect(url);
+});
+
+// Step 2: Patreon returns. Verify the patron; if a supporter at the right tier,
+// render the passkey-creation page.
+router.get('/callback', async (req, res) => {
+  const r = ready(req, res); if (!r) return;
+  const payload = verifyBlob(String(req.query.state || ''));
+  if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
+    return res.status(400).send('Ongeldige of verlopen aanvraag. Probeer opnieuw vanaf de post.');
+  }
+  const code = String(req.query.code || '');
+  if (req.query.error || !code) {
+    return renderPage(req, res, 'pages/paid-result', { pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post });
+  }
+  const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
+  const cents = membership ? (membership.cents || 0) : 0;
+  const active = membership && membership.status === 'active_patron';
+  if (!active || cents < payload.cents) {
+    return renderPage(req, res, 'pages/paid-result', {
+      pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false,
+      reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post,
+    });
+  }
+  // Supporter at the right tier. Hand out registration options + a signed blob
+  // carrying the challenge and the proven cents; the passkey page returns both.
+  const options = await Passkey.registrationOptions(baseUrl(req), r.site.slug);
+  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
+  renderPage(req, res, 'pages/paid-passkey', {
+    pageTitle: 'Maak je passkey', bodyClass: 'on-special',
+    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
+  });
+});
+
+// Step 3: verify the passkey and store the pseudonymous entitlement.
+router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
+  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
+  const { response, blob } = req.body || {};
+  const payload = verifyBlob(String(blob || ''));
+  if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
+    return res.status(400).json({ error: 'bad_challenge' });
+  }
+  const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
+  if (!cred) return res.status(400).json({ error: 'verify_failed' });
+  Passkey.storeEntitlement({
+    credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
+    counter: cred.counter, transports: cred.transports, minCents: payload.cents,
+  });
+  res.json({ ok: true });
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/server.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -41,4 +41,5 @@
 import feedRoutes from './routes/feed.js';
 import postsRoutes from './routes/posts.js';
+import paidRoutes from './routes/paid.js';
 import langRoutes from './routes/lang.js';
 import adminUpdatesRoutes from './routes/admin-updates.js';
@@ -400,4 +401,5 @@
 app.use('/', changelogRoutes); // /changelog publieke release-/wijzigingen-pagina
 app.use('/', langRoutes); // /lang/:code — interface-taal kiezen (vóór de catch-all)
+app.use('/paid', paidRoutes);   // paid-posts patron/passkey flow (before the /:slug catch-all)
 app.use('/', postsRoutes);
 
Index: src/services/PaidPatreonService.js
===================================================================
--- src/services/PaidPatreonService.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/services/PaidPatreonService.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -124,4 +124,45 @@
 }
 
+// Pure: pick the membership for the owner's campaign out of a Patreon
+// identity?include=memberships.campaign response (JSON:API). Returns
+// { status, cents } or null.
+export function pickCampaignMembership(identity, campaignId) {
+  const inc = (identity && identity.included) || [];
+  for (const it of inc) {
+    if (it.type !== 'member') continue;
+    const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
+    if (!camp || String(camp.id) !== String(campaignId)) continue;
+    const a = it.attributes || {};
+    return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
+  }
+  return null;
+}
+
+// Exchange a patron's auth code and read their membership of the owner's
+// campaign. Returns { status, cents } or null. The patron token is used once
+// and discarded here: nothing identifying is stored (design decision).
+export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
+  const c = getOwnerConfig(siteId);
+  if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
+  const tokenRes = await fetchImpl(TOKEN_URL, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      grant_type: 'authorization_code', code,
+      client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
+    }).toString(),
+  });
+  if (!tokenRes.ok) return null;
+  const tok = await tokenRes.json();
+  if (!tok || !tok.access_token) return null;
+  const url = 'https://www.patreon.com/api/oauth2/v2/identity'
+    + '?include=memberships.campaign'
+    + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
+  const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
+  if (!idRes.ok) return null;
+  const identity = await idRes.json();
+  return pickCampaignMembership(identity, c.campaignId);   // token goes out of scope, discarded
+}
+
 function safeDecrypt(blob) {
   try { return decrypt(blob); } catch { return null; }
@@ -131,3 +172,4 @@
   getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
   defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
+  pickCampaignMembership, verifyPatron,
 };
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,
+};
Index: src/views/pages/paid-passkey.ejs
===================================================================
--- src/views/pages/paid-passkey.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/views/pages/paid-passkey.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,59 @@
+<section class="pk">
+  <div class="pk-card">
+    <div class="pk-ic">🔑</div>
+    <h1 class="pk-h1">Je bent supporter, mooi.</h1>
+    <p class="pk-sub">
+      Maak nu een passkey aan. Die wordt je sleutel voor betaalde posts, zonder
+      account en zonder cookie. We bewaren geen naam of e-mailadres.
+    </p>
+    <button type="button" id="pk-go" class="pk-btn">Maak passkey</button>
+    <p id="pk-status" class="pk-status" hidden></p>
+  </div>
+</section>
+
+<script src="/assets/vendor/simplewebauthn-browser.umd.min.js" nonce="<%= cspNonce %>"></script>
+<script nonce="<%= cspNonce %>">
+(function () {
+  var options = <%- optionsJson %>;
+  var blob = "<%= regBlob %>";
+  var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>";
+  var btn = document.getElementById('pk-go');
+  var status = document.getElementById('pk-status');
+  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
+
+  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
+    btn.disabled = true;
+    say('Passkeys worden niet ondersteund in deze browser.', true);
+    return;
+  }
+  btn.addEventListener('click', function () {
+    btn.disabled = true;
+    say('Volg de vraag van je apparaat…');
+    window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
+      .then(function (response) {
+        return fetch('/paid/register', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ response: response, blob: blob }),
+        });
+      })
+      .then(function (r) { return r.json(); })
+      .then(function (j) {
+        if (j && j.ok) { say('Gelukt. Je passkey is aangemaakt.'); setTimeout(function () { location.href = postUrl; }, 900); }
+        else { btn.disabled = false; say('Aanmaken mislukt (' + ((j && j.error) || 'onbekend') + '). Probeer opnieuw.', true); }
+      })
+      .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? 'Geannuleerd.' : 'Er ging iets mis. Probeer opnieuw.', true); });
+  });
+})();
+</script>
+
+<style>
+  .pk { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
+  .pk-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
+  .pk-ic { font-size: 40px; margin-bottom: 8px; }
+  .pk-h1 { font-size: 24px; margin: 0 0 6px; }
+  .pk-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
+  .pk-btn { padding: 12px 24px; border: none; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font: inherit; font-weight: 600; cursor: pointer; }
+  .pk-btn:disabled { opacity: .6; cursor: default; }
+  .pk-status { margin: 14px 0 0; opacity: .9; }
+  .pk-status.is-err { color: #c0392b; }
+</style>
Index: src/views/pages/paid-result.ejs
===================================================================
--- src/views/pages/paid-result.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/views/pages/paid-result.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,30 @@
+<section class="pr">
+  <div class="pr-card">
+    <div class="pr-ic">🔒</div>
+    <% if (reason === 'notpatron') { %>
+      <h1 class="pr-h1">Nog geen supporter</h1>
+      <p class="pr-sub">Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het opnieuw.</p>
+    <% } else if (reason === 'tier') { %>
+      <h1 class="pr-h1">Een niveau hoger nodig</h1>
+      <p class="pr-sub">
+        Deze post vraagt vanaf &euro;<%= ((typeof neededCents !== 'undefined' ? neededCents : 0)/100).toFixed(2) %>.
+        Jouw steun is nu &euro;<%= ((typeof haveCents !== 'undefined' ? haveCents : 0)/100).toFixed(2) %>. Verhoog je steun en probeer opnieuw.
+      </p>
+    <% } else { %>
+      <h1 class="pr-h1">Ontgrendelen afgebroken</h1>
+      <p class="pr-sub">Er is niets gekoppeld. Je kunt het opnieuw proberen vanaf de post.</p>
+    <% } %>
+    <% if (typeof postSlug !== 'undefined' && postSlug) { %>
+      <p><a class="pr-btn" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>">Terug naar de post</a></p>
+    <% } %>
+  </div>
+</section>
+
+<style>
+  .pr { max-width: 460px; margin: 0 auto; padding: 56px 18px; text-align: center; }
+  .pr-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
+  .pr-ic { font-size: 38px; margin-bottom: 6px; }
+  .pr-h1 { font-size: 23px; margin: 0 0 8px; }
+  .pr-sub { opacity: .85; line-height: 1.6; margin: 0 0 18px; }
+  .pr-btn { display: inline-block; padding: 11px 20px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
+</style>
Index: test/paid-patron.test.js
===================================================================
--- test/paid-patron.test.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ test/paid-patron.test.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,79 @@
+// Paid posts slice 3 (klonkt-demo-aki): patron verification + pseudonymous
+// passkey entitlements. The WebAuthn ceremony itself needs a browser, so here
+// we cover the pure parsing, the Patreon exchange (mock fetch), the entitlement
+// store, and that registration options carry a challenge.
+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 Passkey = (await import('../src/services/PasskeyService.js')).default;
+
+PP.saveOwnerConfig('s1', { clientId: 'cid', clientSecret: 'sec', campaignId: '42', defaultMinCents: 300 });
+
+const identityWith = (campaignId, status, cents) => ({
+  data: { type: 'user', id: 'u', relationships: { memberships: { data: [{ type: 'member', id: 'm1' }] } } },
+  included: [
+    { type: 'member', id: 'm1', attributes: { patron_status: status, currently_entitled_amount_cents: cents },
+      relationships: { campaign: { data: { type: 'campaign', id: campaignId } } } },
+    { type: 'campaign', id: campaignId },
+  ],
+});
+
+test('pickCampaignMembership finds the right campaign, ignores others', async () => {
+  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
+  const id = identityWith('42', 'active_patron', 500);
+  const m = pickCampaignMembership(id, '42');
+  assert.equal(m.status, 'active_patron');
+  assert.equal(m.cents, 500);
+  assert.equal(pickCampaignMembership(id, '999'), null);   // different campaign
+});
+
+test('verifyPatron exchanges the code and reads the membership (mock fetch)', async () => {
+  const calls = [];
+  const fetchMock = async (url, opts) => {
+    calls.push(url);
+    if (url.includes('/token')) return { ok: true, json: async () => ({ access_token: 'patron-tok' }) };
+    return { ok: true, json: async () => identityWith('42', 'active_patron', 800) };
+  };
+  const m = await PP.verifyPatron('s1', 'the-code', 'https://test.example/paid/callback', fetchMock);
+  assert.equal(m.status, 'active_patron');
+  assert.equal(m.cents, 800);
+  assert.ok(calls[0].includes('patreon.com'));
+  assert.ok(calls[1].includes('identity'));
+});
+
+test('registration options carry a challenge and the site host as rpID', async () => {
+  const opts = await Passkey.registrationOptions('https://test.example', 's1');
+  assert.ok(opts.challenge && typeof opts.challenge === 'string');
+  assert.equal(opts.rp.id, 'test.example');
+  assert.equal(opts.authenticatorSelection.residentKey, 'required');
+});
+
+test('entitlement stores, reads, expires, prunes; no patron identity present', () => {
+  Passkey.storeEntitlement({ credentialId: 'cred1', siteId: 's1', publicKey: 'PUBKEY', counter: 0, minCents: 500, ttlDays: 30 });
+  const e = Passkey.getEntitlement('cred1', 's1');
+  assert.ok(e);
+  assert.equal(e.min_cents, 500);
+  // the row has no name/email/patreon id
+  const cols = Object.keys(e);
+  assert.ok(!cols.some((c) => /name|email|patron|user/i.test(c)), 'no identity columns');
+  // expired entitlement is not returned and gets pruned
+  Passkey.storeEntitlement({ credentialId: 'cred2', siteId: 's1', publicKey: 'PK', minCents: 100, ttlDays: 30 });
+  db.prepare('UPDATE paid_entitlements SET expires_at = 1 WHERE credential_id = ?').run('cred2');
+  assert.equal(Passkey.getEntitlement('cred2', 's1'), null);
+  assert.equal(Passkey.pruneExpired() >= 1, true);
+  assert.ok(Passkey.getEntitlement('cred1', 's1'));   // the fresh one survives
+});
+
+test('deleteEntitlement removes the row (forget-passkey path)', () => {
+  Passkey.storeEntitlement({ credentialId: 'cred3', siteId: 's1', publicKey: 'PK', minCents: 100 });
+  assert.equal(Passkey.deleteEntitlement('cred3'), true);
+  assert.equal(Passkey.getEntitlement('cred3', 's1'), null);
+});
