source: Klonkt/docs/paid-posts-design.md@ 4a08bfc

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

Docs: paid-posts design (slice 0 spike)

The build reference for the paid-posts feature (klonkt-demo-aki),
grounded in verified facts: Patreon API v2 (authorize/token endpoints,
identity?include=memberships.campaign for the patron path, creator
token direct on client registration, patron_status +
currently_entitled_amount_cents, rate limits), the WebAuthn dependency
(@simplewebauthn/server 13.3.2, MIT, node>=20, attestation none), the
cookie-less trick (HMAC-signed short-lived blob reused for both the
OAuth state and the WebAuthn challenge, so no session is needed), the
additive data model (paid_patreon per owner, paid_entitlements per
passkey with no patron identity, posts.paid + paid_min_cents), the new
aes-256-gcm helper for the creator token, the three flows and the
federation teaser rule. Confirms the design keeps the privacy
statement's promises. Slices 1 to 5 filed under the epic.

New file:
docs/paid-posts-design.md

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

  • Property mode set to 100644
File size: 7.7 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## Data model (additive)
74
75New table `paid_patreon` (one row per site owner's campaign):
76
77| column | note |
78| --- | --- |
79| `site_id` | PK |
80| `client_id` | the owner's Patreon API client id |
81| `client_secret_enc` | AES-256-GCM, key from env |
82| `campaign_id` | the owner's campaign |
83| `access_token_enc`, `refresh_token_enc`, `token_exp` | creator token, encrypted |
84| `default_min_cents` | default price gate for a paid post |
85| `updated_at` | |
86
87New table `paid_entitlements` (one row per passkey, NO patron identity):
88
89| column | note |
90| --- | --- |
91| `credential_id` | PK, the WebAuthn credential id (opaque) |
92| `site_id` | which site this passkey is entitled on |
93| `public_key` | COSE public key for assertion verification |
94| `counter` | WebAuthn signature counter |
95| `transports` | optional |
96| `min_cents` | the amount the patron proved at link time (the tier they hold) |
97| `expires_at` | TTL; re-link after |
98| `created_at` | |
99
100Posts gain two additive columns: `paid` (INTEGER 0/1) and `paid_min_cents`
101(INTEGER, null = use `default_min_cents`).
102
103Encryption helper is new (the codebase has none): `aes-256-gcm`, key =
104`scryptSync(PAID_SECRET, 'paid', 32)`, random iv per value, store `iv:tag:ct`.
105
106## Flows
107
108Owner link (slice 1, premium-gated in Beheer):
1091. Owner pastes Patreon client id + secret (or does a one-time creator OAuth),
110 sets the campaign and a default price.
1112. We store the (encrypted) creator token + campaign id in `paid_patreon`.
1123. A refresh path renews the creator token before `token_exp`, and a de-auth
113 clears the row.
114
115Patron link (slice 3, no cookie):
1161. Visitor clicks "unlock via Patreon" on a paid post. We build a signed `state`
117 (purpose `link`, site, the post's required cents) and redirect to Patreon
118 authorize with `identity.memberships`.
1192. Callback verifies `state`, exchanges the code, calls identity?include=
120 memberships.campaign, finds the membership for the owner's campaign, checks
121 `patron_status == 'active_patron'` and `currently_entitled_amount_cents >=`
122 the required cents.
1233. If ok, we run WebAuthn registration (discoverable credential,
124 `attestationType: 'none'`), store `{credential_id, site, public_key, counter,
125 min_cents = entitled cents, expires_at = now + TTL}` in `paid_entitlements`.
126 The Patreon code/token is discarded here; nothing identifying is kept.
127
128Unlock (slice 4, per post, no cookie):
1291. Paid post page shows the teaser + an "unlock" button and a WebAuthn assert
130 challenge (our signed token, purpose `assert`, carrying the post's required
131 cents).
1322. Browser produces an assertion with the discoverable credential; we verify it
133 with `@simplewebauthn/server` against the stored `public_key`, check the
134 entitlement's `min_cents >=` required and `expires_at` in the future, bump
135 `counter`, and return the full content in that same response. No unlock token
136 becomes state.
137
138Expiry (slice 5): the Scheduler prunes rows past `expires_at`; the unlock button
139falls back to the link flow when no valid entitlement asserts. A "forget this
140passkey" action deletes the row after an assertion proves ownership (the GDPR
141delete path from the privacy statement).
142
143## Federation
144
145A paid post MUST NOT federate its full content (that would leak past the gate).
146It federates a teaser + a link back, like the `fan_only` path already limits
147delivery. Touches `buildNote`. Decide the exact teaser at slice 2.
148
149## Open items to settle while building
150
151- Owner setup: creator-token-paste vs one-time creator OAuth. Paste is simplest
152 (Patreon hands the token on client registration); OAuth is friendlier. Pick in
153 slice 1.
154- WebAuthn RP id: the site host. In hub mode each site is a subpath, not a
155 subdomain, so one RP id per instance host, scoped by `site_id` in the row.
156 Confirm hub behavior in slice 3.
157- TTL exact value + whether to also cap by Patreon's `currently_entitled` at
158 assert time (we do not re-call Patreon on unlock by design; the TTL is the
159 freshness bound).
160- No-JS: WebAuthn needs JS. A paid post without JS shows only the teaser + the
161 link-to-Patreon path. Acceptable.
Note: See TracBrowser for help on using the repository browser.