Changeset 61e3daf in Klonkt


Ignore:
Timestamp:
07/21/2026 12:38:08 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
928d1c7
Parents:
4a08bfc
git-author:
Robin <roboburr@…> (07/21/2026 12:38:07 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 12:38:08 AM)
Message:

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@…>

Files:
5 added
5 edited

Legend:

Unmodified
Added
Removed
  • docs/paid-posts-design.md

    r4a08bfc r61e3daf  
    7070
    7171`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).
    7285
    7386## Data model (additive)
  • docs/privacy-betaalde-posts.md

    r4a08bfc r61e3daf  
    5959geen gemis.
    6060
     61## Meerdere mensen tegelijk
     62
     63Omdat er geen sessie en geen "ingelogde gebruiker" bestaat, kunnen meerdere
     64mensen tegelijk en los van elkaar posts ontgrendelen: elke bevestiging staat op
     65zichzelf. Er is geen gedeelde toestand die van elkaar afhangt. Deel je hetzelfde
     66apparaat en profiel met iemand, dan kan de passkey-kiezer wel tonen dat de ander
     67een passkey heeft (geen toegang, alleen zichtbaar); gebruik dan aparte apparaten
     68of profielen.
     69
    6170## Bewaartermijn en verlopen
    6271
  • src/config/database.js

    r4a08bfc r61e3daf  
    310310      last_used_at DATETIME
    311311    );
     312    -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
     313    -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
     314    -- patreon_* settings, which are Klonkt Premium's separate license flow.
     315    CREATE TABLE IF NOT EXISTS paid_patreon (
     316      site_id TEXT PRIMARY KEY,
     317      client_id TEXT,
     318      client_secret_enc TEXT,
     319      campaign_id TEXT,
     320      access_token_enc TEXT,
     321      refresh_token_enc TEXT,
     322      token_exp INTEGER,               -- unix seconds
     323      default_min_cents INTEGER DEFAULT 0,
     324      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
     325    );
    312326    CREATE TABLE IF NOT EXISTS ap_outbox (
    313327      id TEXT PRIMARY KEY,            -- note path segment (uuid) → /ap/notes/<id>
  • src/server.js

    r4a08bfc r61e3daf  
    4545import adminPatreonRoutes from './routes/admin-patreon.js';
    4646import adminStatsRoutes from './routes/admin-stats.js';
     47import adminPaidRoutes from './routes/admin-paid.js';
    4748import adminMediaRoutes from './routes/admin-media.js';
    4849import circleRoutes from './routes/circle.js';
     
    378379app.use('/admin/patreon', adminPatreonRoutes);
    379380app.use('/admin/stats', adminStatsRoutes);
     381app.use('/admin/paid', adminPaidRoutes);
    380382app.use('/admin/newsletter', adminNewsletterRoutes);
    381383app.use('/admin/shows', adminShowsRoutes);
  • src/views/pages/admin.ejs

    r4a08bfc r61e3daf  
    4646    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    4747      <a href="/admin/stats" class="btn"><%= t('admin.b_stats') %></a>
     48      <a href="/admin/paid" class="btn">Betaalde posts</a>
    4849      <a href="/admin/newsletter" class="btn"><%= t('admin.b_newsletter') %></a>
    4950      <% if (tenancy !== 'hub' && primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>
Note: See TracChangeset for help on using the changeset viewer.