| 1 | # Paid posts: design (slice 0 spike)
|
|---|
| 2 |
|
|---|
| 3 | Reference for building the paid-posts feature (klonkt-demo-aki). Premium
|
|---|
| 4 | feature: site-owner's own Patreon patrons unlock paid posts with a passkey.
|
|---|
| 5 | No cookies, no patron identity stored, re-link on expiry. The privacy statement
|
|---|
| 6 | `privacy-betaalde-posts.md` is the promise this design must keep.
|
|---|
| 7 |
|
|---|
| 8 | ## Decisions (fixed)
|
|---|
| 9 |
|
|---|
| 10 | 1. Revocation = re-link + TTL (~30 days). Store NO patron id, not even hashed or
|
|---|
| 11 | encrypted. An entitlement is `{ passkey, site, proven cents, expiry }`.
|
|---|
| 12 | 2. WebAuthn via a dependency: `@simplewebauthn/server` (server) +
|
|---|
| 13 | `@simplewebauthn/browser` (client).
|
|---|
| 14 | 3. Strictly no cookie / session / localStorage in this flow. Every unlock is a
|
|---|
| 15 | standalone passkey assertion.
|
|---|
| 16 |
|
|---|
| 17 | Klonkt Premium (the license-server Ed25519 flow, `patreon_*` settings) is a
|
|---|
| 18 | separate layer and stays untouched. This feature never reuses those settings.
|
|---|
| 19 |
|
|---|
| 20 | ## Dependency check
|
|---|
| 21 |
|
|---|
| 22 | `@simplewebauthn/server` 13.3.2, MIT, `engines.node >=20` (we run v20.18). Pulls
|
|---|
| 23 | `@levischuck/tiny-cbor`, `@hexagon/base64`, and several `@peculiar/asn1-*` +
|
|---|
| 24 | `@peculiar/x509` (attestation/cert parsing). Footprint is real but all MIT and
|
|---|
| 25 | maintained; acceptable given the explicit go-ahead for a dependency. Client uses
|
|---|
| 26 | `@simplewebauthn/browser` (MIT). We only need registration + assertion of
|
|---|
| 27 | discoverable credentials; no attestation is required (`attestationType: 'none'`),
|
|---|
| 28 | which keeps the cert-parsing paths cold.
|
|---|
| 29 |
|
|---|
| 30 | ## Patreon API v2 (verified)
|
|---|
| 31 |
|
|---|
| 32 | - Authorize: `GET https://www.patreon.com/oauth2/authorize`
|
|---|
| 33 | - Token: `POST https://www.patreon.com/api/oauth2/token` (also `grant_type=refresh_token`)
|
|---|
| 34 | - Scopes: `identity`, `identity.memberships`, `campaigns`, `campaigns.members`.
|
|---|
| 35 | - Owner (creator): when they register an API client they get client id + secret
|
|---|
| 36 | AND a creator access + refresh token directly, no OAuth dance needed. Tokens
|
|---|
| 37 | carry `expires_in`; refresh with the refresh token. So the owner setup can be
|
|---|
| 38 | as light as "paste your Patreon client id + secret" and we mint/refresh from
|
|---|
| 39 | there, or a one-time creator OAuth. We only need the creator token to verify a
|
|---|
| 40 | patron during their link step (below).
|
|---|
| 41 | - Patron (visitor): OAuth with `identity.memberships`, then
|
|---|
| 42 | `GET /api/oauth2/v2/identity?include=memberships.campaign&fields[member]=patron_status,currently_entitled_amount_cents`.
|
|---|
| 43 | Response is JSON:API: walk `data.relationships.memberships.data` for member ids,
|
|---|
| 44 | match them in `included`, and for each member read `patron_status` +
|
|---|
| 45 | `currently_entitled_amount_cents`; the member's `relationships.campaign`
|
|---|
| 46 | points at the campaign object (also in `included`) so we can pick the one that
|
|---|
| 47 | matches the owner's campaign id.
|
|---|
| 48 | - Rate limits: 100 req / 2s per client, 100 req/min per token; 429 carries
|
|---|
| 49 | `retry_after_seconds`. Fine: the patron path is one call per link, and the
|
|---|
| 50 | owner-token path is one call per link too. No polling.
|
|---|
| 51 |
|
|---|
| 52 | ## The cookie-less trick (used twice)
|
|---|
| 53 |
|
|---|
| 54 | No session means no server-side "pending state" bound to a browser. Both the
|
|---|
| 55 | OAuth `state` and the WebAuthn `challenge` are made stateless with a signed,
|
|---|
| 56 | short-lived blob:
|
|---|
| 57 |
|
|---|
| 58 | ```
|
|---|
| 59 | blob = base64url( JSON{ nonce, site, purpose, cents?, exp } )
|
|---|
| 60 | tag = HMAC-SHA256(PAID_SECRET, blob) // key from env, never in the DB dump for this
|
|---|
| 61 | token = blob + "." + tag
|
|---|
| 62 | ```
|
|---|
| 63 |
|
|---|
| 64 | - OAuth: `state = token`. On the Patreon callback we verify the tag + `exp`,
|
|---|
| 65 | read `site`/`purpose`, and never needed a cookie.
|
|---|
| 66 | - WebAuthn: the challenge we hand the browser is such a token (purpose
|
|---|
| 67 | `register` or `assert`, plus the target post/tier). On verify we re-derive and
|
|---|
| 68 | check it. `@simplewebauthn/server` lets us pass the expected challenge in, so
|
|---|
| 69 | we compare the returned challenge to our re-verified token.
|
|---|
| 70 |
|
|---|
| 71 | `PAID_SECRET` (32 random bytes) lives in env, like the other secrets.
|
|---|
| 72 |
|
|---|
| 73 | ## Concurrency (a property of the cookie-less model)
|
|---|
| 74 |
|
|---|
| 75 | Because there is no session and no "current user", the model is inherently
|
|---|
| 76 | multi-user. Two people unlock side by side with no shared state to collide:
|
|---|
| 77 | each request carries its own assertion, verified against that credential's own
|
|---|
| 78 | public key, and the content goes back in that one response. `paid_entitlements`
|
|---|
| 79 | is keyed per credential, so N passkeys are N independent rows. The challenge is
|
|---|
| 80 | stateless (the signed blob), so there is no single "pending challenge" slot a
|
|---|
| 81 | second visitor could overwrite. Unlike a cookie session, "two people in the same
|
|---|
| 82 | browser" cannot clobber each other. The only caveat is a shared browser profile:
|
|---|
| 83 | the passkey picker would then list both passkeys (a small visibility hint, not
|
|---|
| 84 | access).
|
|---|
| 85 |
|
|---|
| 86 | ## Data model (additive)
|
|---|
| 87 |
|
|---|
| 88 | New table `paid_patreon` (one row per site owner's campaign):
|
|---|
| 89 |
|
|---|
| 90 | | column | note |
|
|---|
| 91 | | --- | --- |
|
|---|
| 92 | | `site_id` | PK |
|
|---|
| 93 | | `client_id` | the owner's Patreon API client id |
|
|---|
| 94 | | `client_secret_enc` | AES-256-GCM, key from env |
|
|---|
| 95 | | `campaign_id` | the owner's campaign |
|
|---|
| 96 | | `access_token_enc`, `refresh_token_enc`, `token_exp` | creator token, encrypted |
|
|---|
| 97 | | `default_min_cents` | default price gate for a paid post |
|
|---|
| 98 | | `updated_at` | |
|
|---|
| 99 |
|
|---|
| 100 | New table `paid_entitlements` (one row per passkey, NO patron identity):
|
|---|
| 101 |
|
|---|
| 102 | | column | note |
|
|---|
| 103 | | --- | --- |
|
|---|
| 104 | | `credential_id` | PK, the WebAuthn credential id (opaque) |
|
|---|
| 105 | | `site_id` | which site this passkey is entitled on |
|
|---|
| 106 | | `public_key` | COSE public key for assertion verification |
|
|---|
| 107 | | `counter` | WebAuthn signature counter |
|
|---|
| 108 | | `transports` | optional |
|
|---|
| 109 | | `min_cents` | the amount the patron proved at link time (the tier they hold) |
|
|---|
| 110 | | `expires_at` | TTL; re-link after |
|
|---|
| 111 | | `created_at` | |
|
|---|
| 112 |
|
|---|
| 113 | Posts gain two additive columns: `paid` (INTEGER 0/1) and `paid_min_cents`
|
|---|
| 114 | (INTEGER, null = use `default_min_cents`).
|
|---|
| 115 |
|
|---|
| 116 | Encryption helper is new (the codebase has none): `aes-256-gcm`, key =
|
|---|
| 117 | `scryptSync(PAID_SECRET, 'paid', 32)`, random iv per value, store `iv:tag:ct`.
|
|---|
| 118 |
|
|---|
| 119 | ## Flows
|
|---|
| 120 |
|
|---|
| 121 | Owner link (slice 1, premium-gated in Beheer):
|
|---|
| 122 | 1. Owner pastes Patreon client id + secret (or does a one-time creator OAuth),
|
|---|
| 123 | sets the campaign and a default price.
|
|---|
| 124 | 2. We store the (encrypted) creator token + campaign id in `paid_patreon`.
|
|---|
| 125 | 3. A refresh path renews the creator token before `token_exp`, and a de-auth
|
|---|
| 126 | clears the row.
|
|---|
| 127 |
|
|---|
| 128 | Patron link (slice 3, no cookie):
|
|---|
| 129 | 1. Visitor clicks "unlock via Patreon" on a paid post. We build a signed `state`
|
|---|
| 130 | (purpose `link`, site, the post's required cents) and redirect to Patreon
|
|---|
| 131 | authorize with `identity.memberships`.
|
|---|
| 132 | 2. Callback verifies `state`, exchanges the code, calls identity?include=
|
|---|
| 133 | memberships.campaign, finds the membership for the owner's campaign, checks
|
|---|
| 134 | `patron_status == 'active_patron'` and `currently_entitled_amount_cents >=`
|
|---|
| 135 | the required cents.
|
|---|
| 136 | 3. If ok, we run WebAuthn registration (discoverable credential,
|
|---|
| 137 | `attestationType: 'none'`), store `{credential_id, site, public_key, counter,
|
|---|
| 138 | min_cents = entitled cents, expires_at = now + TTL}` in `paid_entitlements`.
|
|---|
| 139 | The Patreon code/token is discarded here; nothing identifying is kept.
|
|---|
| 140 |
|
|---|
| 141 | Unlock (slice 4, per post, no cookie):
|
|---|
| 142 | 1. Paid post page shows the teaser + an "unlock" button and a WebAuthn assert
|
|---|
| 143 | challenge (our signed token, purpose `assert`, carrying the post's required
|
|---|
| 144 | cents).
|
|---|
| 145 | 2. Browser produces an assertion with the discoverable credential; we verify it
|
|---|
| 146 | with `@simplewebauthn/server` against the stored `public_key`, check the
|
|---|
| 147 | entitlement's `min_cents >=` required and `expires_at` in the future, bump
|
|---|
| 148 | `counter`, and return the full content in that same response. No unlock token
|
|---|
| 149 | becomes state.
|
|---|
| 150 |
|
|---|
| 151 | Expiry (slice 5): the Scheduler prunes rows past `expires_at`; the unlock button
|
|---|
| 152 | falls back to the link flow when no valid entitlement asserts. A "forget this
|
|---|
| 153 | passkey" action deletes the row after an assertion proves ownership (the GDPR
|
|---|
| 154 | delete path from the privacy statement).
|
|---|
| 155 |
|
|---|
| 156 | ## Federation
|
|---|
| 157 |
|
|---|
| 158 | A paid post MUST NOT federate its full content (that would leak past the gate).
|
|---|
| 159 | It federates a teaser + a link back, like the `fan_only` path already limits
|
|---|
| 160 | delivery. Touches `buildNote`. Decide the exact teaser at slice 2.
|
|---|
| 161 |
|
|---|
| 162 | ## Open items to settle while building
|
|---|
| 163 |
|
|---|
| 164 | - Owner setup: creator-token-paste vs one-time creator OAuth. Paste is simplest
|
|---|
| 165 | (Patreon hands the token on client registration); OAuth is friendlier. Pick in
|
|---|
| 166 | slice 1.
|
|---|
| 167 | - WebAuthn RP id: the site host. In hub mode each site is a subpath, not a
|
|---|
| 168 | subdomain, so one RP id per instance host, scoped by `site_id` in the row.
|
|---|
| 169 | Confirm hub behavior in slice 3.
|
|---|
| 170 | - TTL exact value + whether to also cap by Patreon's `currently_entitled` at
|
|---|
| 171 | assert time (we do not re-call Patreon on unlock by design; the TTL is the
|
|---|
| 172 | freshness bound).
|
|---|
| 173 | - No-JS: WebAuthn needs JS. A paid post without JS shows only the teaser + the
|
|---|
| 174 | link-to-Patreon path. Acceptable.
|
|---|