Darstellung
Licensing
How AD Form decides what a site may do.
Companion documents: docs/ENTITLEMENTS.md (the gate), docs/LICENSE_API.md (the wire contract), docs/DOWNGRADE_POLICY.md (what happens when a plan shrinks), docs/STAGING_AND_INSTALLATIONS.md (identity and environments), docs/SECURITY_LICENSING.md (trust boundary).
The short version
- The platform is authoritative. The plugin sends signals and consumes signed answers. It never computes a plan, never reads a price and never contacts Stripe.
- A payload is only used after its Ed25519 signature verifies. An invalid signature discards the whole payload, never part of it.
- Features are unlocked by entitlement keys, never by comparing plans.
- Anything less than a verified active license lands on exactly the Free baseline. Never on "nothing", never on "everything".
- Nothing is ever deleted. A downgrade locks features; it does not remove forms, entries or configuration.
Two payment systems, kept apart
| System A — the AD Form license | System B — payments inside a form | |
|---|---|---|
| Who pays | The customer pays Dingfelder Digital | Website visitors pay the customer |
| Stripe account | The platform operator's | The customer's own |
| Namespace | Dingfelder\AdForm\Licensing | Dingfelder\AdForm\Payments |
| Storage | Options ad_form_license_* | Table ad_form_payments |
| Identifiers | license_id, installation_id, plan | gateway, gateway_payment_id |
Enforced structurally, not by convention: the Licensing namespace declares no dependency on Payments, writes nothing to ad_form_payments, and stores no gateway identifier. Payments depends on Licensing only through EntitlementManager. No Stripe customer id, price id, API key or webhook secret appears anywhere in the licensing code.
Platform contract
| Concern | Value |
|---|---|
| API | https://api.andredingfelder.com, version /v1/ |
| Dashboard | https://dashboard.andredingfelder.com |
| Upgrade route | https://dashboard.andredingfelder.com/products/ad-forms |
| Product id | ad_forms |
| Signature | Ed25519, sodium_crypto_sign_verify_detached |
| Transport | wp_remote_post() only |
All of it lives in Licensing\LicenseConfig. No domain string appears anywhere else in the plugin.
Object graph
Registered by LicensingServiceProvider, inserted after DatabaseServiceProvider (it needs options and the logger) and before the field, action, REST, frontend and integration providers, so each of those can resolve entitlements while it boots.
| Class | Responsibility |
|---|---|
LicenseConfig | URLs, timeouts, intervals, supported payload versions |
ApiTransportInterface / WpHttpTransport | The only outbound HTTP in the plugin |
LicenseApiClient | Headers, encoding, endpoints, error normalisation |
ApiResponse | Distinguishes a transport failure from an HTTP verdict |
RequestPayloadFactory | The exact set of transmitted fields, in one file |
InstallationIdentity | UUID, installation token, masked accessors |
EnvironmentDetector | Environment signals. Reports, never decides |
LicenseEnvelope | The signed wrapper, holding the exact signed bytes |
PayloadCanonicalizer | Deterministic JSON when the payload arrives as an object |
PublicKeyRegistry | Pinned public keys with kid and retirement |
SignatureVerifier | Ed25519 verification. Fails closed |
LicensePayload | Immutable verified payload |
LicenseValidator | Product and installation binding, clock sanity |
LicenseCache | Verify-on-read, memoised per request |
LicenseState / LicenseStateKind | The resolved state machine |
ActivationService / ValidationService / DeactivationService | The three endpoints |
LicenseScheduler | Jittered daily cron plus opportunistic triggers |
LicenseManager | Façade. The only class the rest of the plugin talks to |
LicenseLogger / Mask | The license log channel, with mandatory masking |
Lifecycle
Activation
POST /v1/licenses/activateThe license key is sent exactly once, here. The response carries a signed payload plus an installation_token. Order of operations, and nothing from the response is used before step 3 succeeds:
LicenseEnvelope::from_response()— structural shape onlySignatureVerifier::verify()— Ed25519 over the exact signed bytesLicensePayload::from_array()— required members, sane timestampsLicenseValidator::assert_acceptable()— product match, not expired, not future-dated- store token, installation id, license id and the masked key
- store the envelope
Validation
POST /v1/licenses/validateAuthenticated with the installation token. The license key is never sent again. Three outcomes, deliberately kept apart:
| Outcome | Effect |
|---|---|
| Signed success | Store the new payload, whatever it says |
| Transport failure or 5xx | Keep the existing payload, record the failure, retry in an hour |
| Documented credential refusal | Drop the local credential and fall to Free |
Only the first can grant entitlements, because only the first carries a signature. The third can only reduce access, which is why it does not need one. Granting requires proof; withdrawing does not. That asymmetry is intentional.
Deactivation
POST /v1/licenses/deactivateOnly ever from an explicit administrator action on the License screen. Local state is cleared even when the API cannot be reached, and the installation UUID is kept so a reconnect maps to the same installation instead of consuming another activation slot.
Plugin deactivation is not license deactivation. WordPress deactivates plugins during updates, migrations, staging refreshes and debugging; silently freeing a slot in those cases would be surprising and abusable. Deactivator only clears the cron event.
Plugin deletion removes the local options when the customer opted into data deletion. The API is not contacted: uninstall runs in a short-lived request that may have no network access, so the architecture never depends on reaching the server at delete time. The customer removes the installation from the dashboard.
Scheduling
wp_schedule_event, daily, hook ad_form_license_validate. The initial offset is derived deterministically from the installation UUID, so a given site keeps a stable slot in the day and thousands of installations do not hit the API in the same minute. Deterministic rather than random so the slot survives a reschedule and stays diagnosable in support.
WP-Cron fires on requests, not on a clock. Nothing here depends on running at an exact moment: every freshness decision compares timestamps, and a missed run simply means the next request that triggers cron catches up.
Additional triggers, none of them on a frontend page view:
- opening the License screen, but only when a check is actually due
- a manual "Check now", rate limited to once a minute
- a cache miss before a premium action
The two clocks
Conflating these would be the single worst thing this system could do, so they are separate types.
| Clock | Meaning | Value |
|---|---|---|
| Refresh interval | How often we ask | refresh_after from the server, ~24 h, plus jitter |
| Offline grace | How long a validly signed payload stays usable when the API is unreachable | 7 days past expires_at |
| Business grace | Grace for non-payment | Zero. There is none |
An unreachable API is a transport failure and must never remove paid features. A signed payload saying plan: free is an answer and takes effect immediately. LicenseStateKind models both, and the admin wording distinguishes them, because telling a paying customer their subscription lapsed when the API merely timed out is unacceptable.
States
LicenseStateKind is wider than the platform's license_status because the plugin also has to express "no license here" and "we cannot reach the server".
| State | Cause | Paid entitlements | Customer-facing meaning |
|---|---|---|---|
unlicensed | No license connected | no | Running on Free. Not an error |
active | Signed, fresh, active | yes | Everything works |
offline_grace | Payload aged out while the API was unreachable | yes | Nothing is wrong with the subscription |
offline_expired | Grace ran out | no | Technical, not billing |
suspended | Platform said so | no | Billing |
revoked | Platform said so | no | Billing, strictest enforcement |
expired | Platform said so | no | Billing |
untrusted | Stored payload failed re-verification | no | Tampering or corruption |
crypto_unavailable | No sodium, or no pinned key in this build | no | Server or packaging problem |
is_technical() separates the states caused by our side from the states caused by billing, which is what drives the wording in the admin UI.
Storage
Three options, none autoloaded. Licensing is read on licensing paths and on the first entitlement lookup of a request, never on every page view.
| Option | Content |
|---|---|
ad_form_license_identity | installation_uuid, installation_token, installation_id, license_id, masked key |
ad_form_license_payload | The exact signed bytes, signature, kid, algorithm |
ad_form_license_runtime | checked_at, next_check_at, last_error |
Nothing goes into ad_form_settings: that option is autoloaded, editable from the settings screen, and its allow-list sanitiser would silently drop unknown keys.
Verify on read
The signature is stored next to the payload and re-checked on every read. That is the entire reason the signature is persisted at all: editing ad_form_license_payload by hand yields untrusted, which means the Free baseline, rather than new entitlements. Verification is memoised per request, so an entitlement lookup inside a render loop costs an array read.
Key rotation
PublicKeyRegistry pins keys by kid, each with an algorithm and an optional retirement timestamp. Rotating means shipping a plugin update that adds the new key and sets retires_at on the old one, so payloads signed with the previous key stay verifiable for a defined window.
The registry is deliberately not filterable and not configurable through a constant. A site owner who could define a constant could otherwise self-sign payloads, which would make the whole system decorative.
A build whose only entry is still the placeholder cannot verify anything and therefore stays on Free. LicenseManager::can_verify() surfaces that on the License screen so a packaging mistake looks like a packaging mistake rather than a licensing failure.
Multisite
The plugin is uniformly per-site: Core\WpOptions wraps get_option exclusively, there is no *_site_option call anywhere in src/, Activator installs schema per blog and hooks wp_initialize_site, and Uninstaller purges per blog. Licensing follows that rather than introducing the only network-scoped state in the plugin.
| Aspect | Policy |
|---|---|
| Network activation | Supported. Each blog activates independently |
| Site-specific activation | Supported, identical code path |
| License scope | Per blog |
| Installation UUID scope | Per blog |
| Signals sent | is_multisite, blog_id, network_home_url, so the API can recognise a network and bill it as one customer if the product decides to |
Rejected alternative: a network-wide license in wp_sitemeta. It would break the per-blog uninstall path and let one blog's administrator revoke a sibling's entitlements.
What the plugin never does
- contact Stripe to determine its own license
- query the dashboard database
- decide a price
- decide which paid plan a customer holds
- enforce its own activation limit as the authority
- compare
site_urlfor equality to decide anything - treat
wp_get_environment_type()as proof - delete customer data on downgrade
- make an API call on a frontend page view
- contain a private key or any platform secret
Logging
Channel license, through LicenseLogger, which strips credential keys before they can reach Logger::redact(). Two layers on purpose: the layer closest to the secret should be the one that removes it.
Recorded: activation, deactivation, validation success and failure, API unreachable, invalid signature, rejected cache, plan and status transitions.
License keys appear as ADFORM-****-****-1234. Installation tokens are never logged, not even partially — only as **** or as a 12-character fingerprint for correlating log lines.