Modules
Module: own licence keys (for a provider without them)
Status: designed, not built. It is built the first time a product needs a
provider without native licence keys: Stripe, Paddle, a generic processor.
/add-payment-provider stops and asks before starting it, because it changes
one of this template's defining properties: the site stores nothing
(root AGENTS.md §4, site/.env.example).
What it replaces
Creem and Polar issue a key per purchase, cap its activations per product, and
answer activate / validate / deactivate. This module does those three things
itself, behind the same PaymentProvider.licences interface. The app and the
frozen /api/license/* routes do not change at all.
Design
- Keys are signed, not random. A key is
<PREFIX>-<base32 payload>.<base32 Ed25519 signature>. The payload holds the order id, rung (device count) and issue date.- The private key lives in one env var (
LICENCE_SIGNING_KEY); the public half is in the site. - A key can be checked for authenticity with no database round trip, so a forged key is refused before any query.
- The private key lives in one env var (
- Activations need state, so this is where the database comes in:
licences(key_hash PK, order_id UNIQUE, devices, status, created_at)andactivations(id PK, key_hash FK, label_hash, created_at).- Key and label are stored hashed: the server-stores-nothing rule shrinks to "stores nothing readable".
- Activation is one statement that inserts only while the count is below the
limit (
INSERT … SELECT … WHERE (SELECT count(*) …) < devices). No transaction is needed, so it works on Neon's HTTP driver.
- Issuing happens in the webhook's
onPaid:- the insert is guarded by
order_id UNIQUE, so retries are idempotent; - the key is emailed through the licence-key template.
- the insert is guarded by
- Refunds and disputes set
status = 'revoked', so validation answersvalid: falsefrom the next weekly check. - Recovery by email needs
email_hashonlicences.
Degradation
With no DATABASE_URL, the licence routes answer 502, exactly as a missing
provider key does today. The webhook answers 500, so paid orders stay in the
provider's retry queue until the database exists, and no key is ever lost.
Tests it must come with
- The adapter passes
site/lib/payments/conformance/with a fixture whose "provider" is an in-memory database. - Activation under concurrency never exceeds the limit (two parallel
activations at
devices = 1: one wins). - A key signed with another private key is refused without a query.
See docs/modules/database.md for adding the database itself.
This page is docs/modules/own-licences.md in the repository, copied 2026-09-25.