A payment middle layer whose contract is deliberately agnostic to how payment was actually verified. A product only ever deals in one concept:
type PurchaseToken struct {
Token string
ExpiresAt time.Time
}A product's own write path checks exactly one thing — "is this token
present and not yet expired?" — and never sees macaroon, invoice,
Lightning, or any other payment-mechanism-specific detail. That
vocabulary is confined entirely to a real Backend's own
implementation, one layer below this package.
-
PurchaseToken, theBackendinterface, andRequireToken— anhttp.Handlerwrapper that 404s unless a request carries a token aBackendconfirms is currently valid. -
StaticBackend— a fixed, operator-configured value with no expiry. The simplest possible real backend; also what a consumer not ready to wire up genuine payment verification can start with. -
Mock— no real payment, for local development and tests. Deterministic (seeded) fault injection: configurable latency, timeouts (respects the caller's owncontext.Contextdeadline), hard failures, malformed responses, and premature expiry, plus a configurable failure rate for soak-style resilience testing. A mock that always succeeds instantly only ever proves the happy path. -
HMACBackend— a self-verifyingBackend:Issuemints a token whose own bytes carry a random nonce, an embedded expiry, and an HMAC-SHA256 signature over both;Verifychecks the signature and expiry with no network call and no shared state — same cost profile asStaticBackend, while still proving whoever presents a token is holding something onlyIssue's own caller could have produced (the same signing key,NewHMACBackend(key []byte)).paylayeritself never generates, stores, or rotates that key — that's the embedding product's own operational concern (EphemNet's case: a flag/env-provided secret, generated once viaopenssl rand -hex 32). Only becomes a key-distribution question if a consumer runs issuance and verification in separate processes — most consumers (EphemNet included) run both in one process sharing one instance. Full decision history:EphemNet's ownplan/designs/D012-real-lightning-backed-paylayer-backend.md. -
StripeBackend— a self-verifyingBackend+Issuerfor Stripe-gated purchases, mechanically near-identical toHMACBackend(same nonce+expiry+HMAC-SHA256 wire shape, no network call, no shared state), with one real addition: every token also carries a signed, tamper-evidentQuantity(IssueTopUp(ctx, quantity)+Quantity(token) (int, bool)), so a caller can recover not just "was this purchase valid" but "how many units were purchased" — for a metered product sold in predefined top-up packages (e.g. EphemNet's ownephemnet-relay, by the GB), where a flat yes/no isn't enough information to credit the right amount to an internal usage ledger. PlainIssue(satisfyingIssuer) mints a flatQuantityof 1, for a non-metered purchase (e.g.ephemnet-domain's own annual subscription) with nothing to count. Contains zero Stripe-specific vocabulary of its own (no webhook signature verification, no price IDs, no Checkout Session parsing, no Stripe SDK dependency) — same reasoningHMACBackendhas no Lightning vocabulary; a product's own Stripe webhook handler decides when to callIssue/IssueTopUpand with whatQuantity, entirely outside this package. Interim/parallel toHMACBackend, not a replacement — either can issue a token for the same product, and a listener gated byRequireTokencan't tell which payment method produced the token it's holding, by design. Full decision history:EphemNet's ownplan/designs/D012-real-lightning-backed-paylayer-backend.md. -
Ed25519Backend— a self-verifyingBackend+IssuerlikeHMACBackend, but asymmetric:NewEd25519IssuerBackendholds the real private key and can both mint and verify;NewEd25519VerifierBackendholds only the public half and can only verify — safe to embed in any number of distributed clients this project doesn't control the deployment of at all (cinder's own case:cindertunnel, run on a customer's machine, independently verifying a peer's license with no network call and nothing that could forge one).HMACBackend's own doc comment already explains why that split matters: its verification key IS its signing key, so no consumer can safely embed it client-side. Same wire shape otherwise (nonce + expiry + signature), sameBackend/Issuerinterfaces — drop-in alongsideHMACBackend/StripeBackend, combinable viaMultiBackendtoo. -
IssuerandIssueHandler(issuer Issuer)— the generic "mint a token and hand it back as JSON" glue any issuer-fronting service needs, regardless of what actually gates the call.Issueris deliberately separate fromBackend(onlyMock/HMACBackend/StripeBackend/Ed25519Backendimplement it;StaticBackendnever issues anything).IssueHandlerhas no payment vocabulary of its own — whatever sits in front of it (a self-hosted Aperture instance gating on a real Lightning payment, a test harness, anything else) decides whether a given request is even allowed to reach it. POST-only (issuing a token is a real side effect, not an idempotent read). -
MultiBackend— aBackendcomposed of several real backends, valid if ANY confirms a token. Exists for exactly the situation a second payment rail creates (EphemNet's own case:HMACBackendfor Lightning,StripeBackendfor Stripe, different keys and wire formats, one listener needing to accept either's output) — tries each in order, short-circuits on the first confirmed-valid result, and only surfaces an error if every backend failed outright (none could even answer) rather than merely "didn't confirm this one." -
VerifyStripeWebhookSignatureandStripeCheckoutClient(+NewStripeCheckoutClient,StripeLineItem,StripeCheckoutSessionParams,StripeCheckoutSession,StripePrice/GetPrice— fetches a Price's real amount/currency, so a trigger page can show a human what something actually costs without hand-maintaining that figure separately from the Stripe dashboard;Refund— a plain full refund of a completed session's own PaymentIntent, for a purchase that charged successfully but genuinely couldn't be fulfilled) — the genuinely Stripe-generic mechanics any consumer wiring in a Stripe billing rail needs, pulled out of EphemNet's owninternal/once a second consumer (cinder/offgridapp) needed the identical code: hand-rolled webhook signature verification (Stripe's own documentedt=...,v1=...scheme, nostripe-goSDK dependency) and Checkout Session creation (POST /v1/checkout/sessions, plain form-encoded REST). Neither has any product vocabulary of its own — no domains, no line-item meaning beyond what the caller supplies — matchingStripeBackend's own reasoning for living here. What each consumer still builds itself: the actual product dispatch (which Price maps to what, which of its own internal endpoints a completed purchase redeems against) — that part is inherently per-product, same as it's always been.
The actual Lightning/L402 gating itself (invoice, macaroon, preimage)
— that's entirely Aperture's own job, configured to front
IssueHandler, fronted by whatever reverse proxy a consumer already
uses (EphemNet's case: the Aperture-facing listener and
-paid-registration-addr wiring, both genuinely product-specific, so
built directly in EphemNet, not here). paylayer never gains
Lightning/Aperture vocabulary of its own.
Consumers other than EphemNet (cinder, persona) pick up
HMACBackend/IssueHandler for free on their next version bump — no
action needed until then.
Extracted from cinder's
internal/paylayer once a second consumer (EphemNet) needed to
depend on it — Go's internal/ visibility rule meant no other module
could import it from there regardless of how stable it was. Full
design history and rationale: cinder's
plan/designs/D012-purchase-token-contract.md and
plan/projects/P017-payment-middle-layer.md.