source: Klonkt/docs/paid-posts-design.md

main
Last change on this file was 61e3daf, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: paid posts slice 1, owner Patreon config (encrypted)

The site owner can connect their OWN Patreon campaign for paid posts
(klonkt-demo-aki), premium-gated in Beheer. Client id/secret, campaign
id and the creator access/refresh token are stored ENCRYPTED at rest
(new CryptoBox AES-256-GCM helper, key from PAID_SECRET), so a database
dump leaks nothing usable; the token auto-refreshes. Separate from
Klonkt Premium's license flow, which is untouched. Degrades gracefully:
without PAID_SECRET the admin page refuses to save rather than storing
plaintext. Nothing patron-facing yet (posts.paid + unlock come in
slices 2 to 4), so no changelog entry.

CryptoBox also carries the cookie-less signed-blob helper (signBlob/
verifyBlob) that slices 3 and 4 reuse for the OAuth state and the
WebAuthn challenge.

Changed files:
src/config/database.js

  • paid_patreon table (site_id PK, secrets encrypted)

src/server.js

  • mount /admin/paid

src/views/pages/admin.ejs

  • "Betaalde posts" button in Beheer

New file:
src/services/CryptoBox.js

  • aes-256-gcm encrypt/decrypt + HMAC signBlob/verifyBlob

src/services/PaidPatreonService.js

  • owner config CRUD (encrypted), token refresh, creatorAccessToken

src/routes/admin-paid.js

  • premium-gated config form (GET/POST/disconnect)

src/views/pages/admin-paid.ejs

  • the form + status

test/paid-patreon.test.js

  • crypto roundtrip, no-plaintext-in-DB, refresh, blob signing

docs/paid-posts-design.md, docs/privacy-betaalde-posts.md

  • concurrency property documented

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 8.4 KB
Line 
1# Paid posts: design (slice 0 spike)
2
3Reference for building the paid-posts feature (klonkt-demo-aki). Premium
4feature: site-owner's own Patreon patrons unlock paid posts with a passkey.
5No 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
101. Revocation = re-link + TTL (~30 days). Store NO patron id, not even hashed or
11 encrypted. An entitlement is `{ passkey, site, proven cents, expiry }`.
122. WebAuthn via a dependency: `@simplewebauthn/server` (server) +
13 `@simplewebauthn/browser` (client).
143. Strictly no cookie / session / localStorage in this flow. Every unlock is a
15 standalone passkey assertion.
16
17Klonkt Premium (the license-server Ed25519 flow, `patreon_*` settings) is a
18separate 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
25maintained; acceptable given the explicit go-ahead for a dependency. Client uses
26`@simplewebauthn/browser` (MIT). We only need registration + assertion of
27discoverable credentials; no attestation is required (`attestationType: 'none'`),
28which 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
54No session means no server-side "pending state" bound to a browser. Both the
55OAuth `state` and the WebAuthn `challenge` are made stateless with a signed,
56short-lived blob:
57
58```
59blob = base64url( JSON{ nonce, site, purpose, cents?, exp } )
60tag = HMAC-SHA256(PAID_SECRET, blob) // key from env, never in the DB dump for this
61token = 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
75Because there is no session and no "current user", the model is inherently
76multi-user. Two people unlock side by side with no shared state to collide:
77each request carries its own assertion, verified against that credential's own
78public key, and the content goes back in that one response. `paid_entitlements`
79is keyed per credential, so N passkeys are N independent rows. The challenge is
80stateless (the signed blob), so there is no single "pending challenge" slot a
81second visitor could overwrite. Unlike a cookie session, "two people in the same
82browser" cannot clobber each other. The only caveat is a shared browser profile:
83the passkey picker would then list both passkeys (a small visibility hint, not
84access).
85
86## Data model (additive)
87
88New 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
100New 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
113Posts gain two additive columns: `paid` (INTEGER 0/1) and `paid_min_cents`
114(INTEGER, null = use `default_min_cents`).
115
116Encryption 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
121Owner link (slice 1, premium-gated in Beheer):
1221. Owner pastes Patreon client id + secret (or does a one-time creator OAuth),
123 sets the campaign and a default price.
1242. We store the (encrypted) creator token + campaign id in `paid_patreon`.
1253. A refresh path renews the creator token before `token_exp`, and a de-auth
126 clears the row.
127
128Patron link (slice 3, no cookie):
1291. 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`.
1322. 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.
1363. 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
141Unlock (slice 4, per post, no cookie):
1421. 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).
1452. 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
151Expiry (slice 5): the Scheduler prunes rows past `expires_at`; the unlock button
152falls back to the link flow when no valid entitlement asserts. A "forget this
153passkey" action deletes the row after an assertion proves ownership (the GDPR
154delete path from the privacy statement).
155
156## Federation
157
158A paid post MUST NOT federate its full content (that would leak past the gate).
159It federates a teaser + a link back, like the `fan_only` path already limits
160delivery. 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.
Note: See TracBrowser for help on using the repository browser.