Build Mac AppDocsMenu

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.
  • Activations need state, so this is where the database comes in: licences(key_hash PK, order_id UNIQUE, devices, status, created_at) and activations(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.
  • Refunds and disputes set status = 'revoked', so validation answers valid: false from the next weekly check.
  • Recovery by email needs email_hash on licences.

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.