Darstellung
Licensing integration plan
Companion to docs/EXISTING_ARCHITECTURE_AUDIT.md. Describes what will be built, where it attaches to the existing code, and which decisions are already settled by the audited architecture.
No implementation has started. Phase 1 begins on approval.
1. Platform contract
| Concern | Value |
|---|---|
| Dashboard | https://dashboard.andredingfelder.com |
| API | https://api.andredingfelder.com |
| API version | /v1/ |
| Product id | ad_forms |
| Upgrade route | https://dashboard.andredingfelder.com/products/ad-forms |
| Signature algorithm | Ed25519, sodium_crypto_sign_verify_detached |
| Transport | wp_remote_post() only, via LicenseApiClient |
Base URL, version and upgrade route live in one class (Licensing\LicenseConfig) with filters for staging overrides. No domain string appears anywhere else.
The plugin never contacts Stripe, never reads a price, never computes a plan and never decides whether an activation is within limits. It sends signals and consumes signed answers.
System A / System B separation
| System A — AD Form licence | System B — form payments | |
|---|---|---|
| Who pays | The customer pays us | Website visitors pay the customer |
| Stripe account | Platform operator's | The customer's own |
| Code namespace | Dingfelder\AdForm\Licensing | Dingfelder\AdForm\Payments |
| Storage | Options (ad_form_license_*) | ad_form_payments table |
| Identifiers | license_id, installation_id, plan | gateway, gateway_payment_id |
Enforced structurally: 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.
2. Decisions already fixed by the existing architecture
These follow from the audit and need no product input.
Multisite policy — per-site licence scope
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; Uninstaller purges per blog. The plugin is already uniformly per-site.
| Aspect | Policy |
|---|---|
| Network activation | Supported. Each blog activates its own installation independently |
| Site-specific activation | Supported, identical code path |
| Licence scope | Per blog. One licence key may be entered on several blogs; the API decides whether that is within the plan |
| Installation UUID scope | Per blog. Each blog gets its own UUID and its own installation_token |
| Network admin screen | Not in Phase 1. Would be additive later, not a scope change |
| Signals sent | is_multisite, blog_id, network_home_url reported so the API can recognise a network and bill it as one customer if the product decides to |
Rejected alternative: a network-wide licence in wp_sitemeta. It would be the only network-scoped state in the plugin, would break the per-blog uninstall path, and would let one blog's administrator revoke a sibling's entitlements.
Installation identity
installation_uuid is a UUID v4 from Support\Uuid (already present, already used for form and submission UUIDs), stored in its own non-autoloaded option, seeded in Activator::activate_site() and never regenerated.
| Scenario | Behaviour |
|---|---|
| First activation | Generate and store |
| Normal boot | Read only. Never generate outside activation and an explicit repair path |
| Site clone / staging copy | UUID is copied too. Reported alongside site_url; the API detects the collision and decides |
| Backup restore | UUID returns with the backup, which is correct — same installation |
| Migration to a new domain | UUID stays, site_url changes. The API reconciles |
| Multisite | One UUID per blog |
The plugin never treats a UUID collision as a local decision. It reports and obeys.
Site URL handling
No client-side URL comparison of any kind. site_url and home_url are reported verbatim on every validation; scheme changes, www changes and domain migrations are the API's problem. This is deliberate — naive string equality is the classic way licensing breaks a working production site.
Environment detection
EnvironmentDetector reports signals, never verdicts:
wp_get_environment_type()- host pattern matches (
staging.,stage.,dev.,test.,preview.,local,.local,.test) WP_DEBUG,WP_ENVIRONMENT_TYPEpresence- known host provider markers where cheaply detectable
The site operator controls PHP, so none of this is trustworthy. The API combines it with registrable domain, activation history and its own anomaly detection to decide whether an installation counts as a free staging site.
Deactivation policy
| Event | Licence effect |
|---|---|
| WordPress plugin deactivation | None. No API call. Deactivator keeps its current single responsibility |
| Explicit "Disconnect licence" in the admin UI | POST /v1/licenses/deactivate, local state cleared |
Plugin delete with delete_data_on_uninstall off | Local licence options removed, no API call |
| Plugin delete with the option on | Best-effort deactivate, then purge. Failure is not fatal |
The architecture never assumes the server is reachable at delete time. The customer can always remove the installation from the dashboard, which is the authoritative path.
Storage layout
Three separate options, so the hot path and the cold path have different costs:
| Option | Autoload | Content |
|---|---|---|
ad_form_license_identity | no | installation_uuid, installation_token, license_id |
ad_form_license_payload | no | Raw signed payload, signature, kid, algorithm |
ad_form_license_state | yes | Derived read-only slice: plan, license_status, entitlement list, expires_at, refresh_after, last_check |
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.
The autoloaded slice is a cache, never a source of truth. On every boot the signature over the stored payload is re-verified before the derived slice is trusted; a hand-edited option therefore yields no entitlements rather than new ones. That is the whole point of storing the signature alongside the payload.
Offline validity
Two independent clocks that must never be conflated:
| Clock | Meaning | Value |
|---|---|---|
| Refresh interval | How often we ask | refresh_after from the server, default ~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 not remove paid features. A signed payload saying plan: free is an answer and takes effect immediately. LicenseState distinguishes these as separate states, and the admin UI wording distinguishes them too, because "we cannot reach the server" and "your subscription ended" are different messages to a paying customer.
After the offline grace expires the site degrades to Free — data intact, premium locked, upgrade guidance shown.
Scheduling
wp_schedule_event with a daily recurrence plus per-installation jitter derived deterministically from the installation_uuid, so a given site keeps a stable slot and ten thousand sites do not stampede the API on the hour.
WP-Cron fires on requests, not on a clock. The design therefore treats every scheduled run as "eventually, roughly daily" and never as a deadline. All freshness decisions compare timestamps rather than assuming a run happened. Additional opportunistic checks: opening the licence page, a plugin version change, and a cache miss before a premium action.
No check on frontend page views. Ever.
Downgrade and runtime policy
Per feature category, four independent switches:
| Category | creation | editing | frontend execution | background execution |
|---|---|---|---|---|
| Builder features (multistep, conditions, calculations, repeater) | denied | denied | allowed | allowed |
| Notifications beyond entitlement | denied | denied | denied | denied |
| Payments | denied | denied | denied | denied |
| denied | denied | denied | denied | |
| Integrations | denied | denied | denied | denied |
| Page builder widgets | denied | denied | allowed for existing embeds | n/a |
The split is deliberate. A visitor filling in an existing multi-step form should not see it break because the site owner's card expired — the form keeps working, the owner just cannot build another one. Payments are the opposite: letting a paid gateway keep processing money indefinitely would make the entitlement meaningless, so new payment execution stops.
Never deleted on downgrade, under any circumstance: forms, fields, submissions, payment settings, integration settings, PDF settings, conditional logic, analytics data, templates, actions, webhook settings. Premium configuration is retained verbatim and becomes usable again on re-upgrade. revoked tightens enforcement to match the payments row across all categories; it still deletes nothing.
3. Proposed classes
src/Licensing/
| Class | Responsibility |
|---|---|
LicenseConfig | Base URL, API version, product id, upgrade routes, timeouts. Single source for every platform URL |
LicenseApiClient | The only wp_remote_post() caller. Headers, user agent, JSON encoding, timeout, error normalisation, response shape validation |
ApiResponse | Value object: HTTP status, decoded body, normalised error code |
ApiTransportInterface | Seam so tests run without network; WpHttpTransport is the production implementation |
InstallationIdentity | UUID generation and read, token storage, masked accessors for logging |
EnvironmentDetector | Environment and staging signals. Reports, never decides |
ActivationService | POST /v1/licenses/activate |
ValidationService | POST /v1/licenses/validate |
DeactivationService | POST /v1/licenses/deactivate |
SignatureVerifier | Ed25519 detached verification. Rejects the whole payload on failure |
PublicKeyRegistry | kid → public key, with algorithm and retirement date. Supports rotation |
LicensePayload | Immutable parsed payload: version, license_id, installation_id, product, plan, billing_interval, license_status, entitlements, issued_at, refresh_after, expires_at |
LicenseValidator | Structural and semantic payload checks: product match, installation match, version support, clock sanity |
LicenseState | The state machine. Distinguishes unlicensed / active / suspended / revoked / expired / offline-grace / offline-expired |
LicenseRepository | Option read/write for the three options |
LicenseCache | Verify-on-read of the stored payload, derived-slice maintenance |
LicenseManager | Thin façade the rest of the plugin talks to. Delegates; holds no logic |
LicenseScheduler | Cron registration, jitter, opportunistic refresh triggers |
LicenseLogger | license channel wrapper over Logging\Logger with mandatory masking |
src/Licensing/Entitlements/
| Class | Responsibility |
|---|---|
FeatureRegistry | Every licensable feature: key, name, category, dependencies, default_marketing_plan |
Feature | Immutable feature descriptor |
EntitlementManager | allows(), denies(), require(), all(), state(). Resolves dependencies transitively |
EntitlementSet | Immutable resolved entitlement list for the current request |
FeatureGate | Convenience wrapper for callers that need a boolean plus a reason |
RuntimePolicy | The four-switch table above, per category |
NotEntitledException | Implements PluginException; status() 403, error_code() feature_not_entitled |
src/Licensing/Enforcement/
| Class | Responsibility |
|---|---|
FormEntitlementValidator | Inspects a sanitised definition, returns required-but-missing entitlements. Diff-aware against the stored definition |
DefinitionFeatureScanner | Maps definition structures to feature keys (steps → forms.multistep, field.conditions.enabled → forms.conditional_logic, action type → action feature, and so on) |
LockedFeatureMarker | Maintains contains_locked_features in ad_form_form_meta |
ActionEntitlementGuard | Hooked into ActionRunner; denies unentitled actions with skip-and-log |
ModuleRegistry | First real consumer of the dormant ModuleInterface; resolves required_entitlements before registering a module |
src/Licensing/Admin/
| Class | Responsibility |
|---|---|
LicensePage | Renders the screen, follows the FormsPage / EntriesPage pattern |
LicenseFormHandler | admin_post handlers for connect / disconnect / refresh. Capability, nonce, sanitisation |
LicenseNotices | Admin notices for revoked, expired, offline-expired, locked-feature forms |
UpgradeUrlBuilder | Product-specific dashboard routes, centrally configurable |
FeatureStatusTable | The Active/Locked feature overview |
src/Licensing/REST/
| Class | Responsibility |
|---|---|
LicenseController | GET /license, POST /license/activate, POST /license/deactivate, POST /license/refresh. Never returns the token |
4. Files touched in Phase 1
Phase 1 is the licensing foundation only: identity, transport, signature, state, cache, scheduler. No feature gating, no entitlement enforcement, no builder changes. Nothing in Phase 1 can refuse an existing operation, which is what keeps it safe to land.
New
src/Licensing/LicenseConfig.php
src/Licensing/LicensingServiceProvider.php
src/Licensing/ApiTransportInterface.php
src/Licensing/WpHttpTransport.php
src/Licensing/ApiResponse.php
src/Licensing/LicenseApiClient.php
src/Licensing/LicenseException.php
src/Licensing/InstallationIdentity.php
src/Licensing/EnvironmentDetector.php
src/Licensing/SignatureVerifier.php
src/Licensing/PublicKeyRegistry.php
src/Licensing/LicensePayload.php
src/Licensing/LicenseValidator.php
src/Licensing/LicenseStatus.php # enum: active|suspended|revoked|expired
src/Licensing/LicensePlan.php # enum: free|pro|pro_plus|business
src/Licensing/LicenseState.php
src/Licensing/LicenseRepository.php
src/Licensing/LicenseCache.php
src/Licensing/ActivationService.php
src/Licensing/ValidationService.php
src/Licensing/DeactivationService.php
src/Licensing/LicenseScheduler.php
src/Licensing/LicenseManager.php
src/Licensing/LicenseLogger.php
docs/LICENSING.md
docs/LICENSE_API.md
docs/SECURITY_LICENSING.md
docs/STAGING_AND_INSTALLATIONS.md
docs/adr/0001-license-architecture.md
docs/adr/0002-signed-entitlements.md
docs/adr/0003-offline-cache.md
docs/adr/0006-installation-identity.md
docs/adr/0007-multisite-policy.md
tests/Support/FakeLicenseTransport.php
tests/Support/LicensePayloadFactory.php
tests/Unit/Licensing/InstallationIdentityTest.php
tests/Unit/Licensing/EnvironmentDetectorTest.php
tests/Unit/Licensing/SignatureVerifierTest.php
tests/Unit/Licensing/PublicKeyRegistryTest.php
tests/Unit/Licensing/LicensePayloadTest.php
tests/Unit/Licensing/LicenseValidatorTest.php
tests/Unit/Licensing/LicenseStateTest.php
tests/Unit/Licensing/LicenseCacheTest.php
tests/Unit/Licensing/LicenseApiClientTest.php
tests/Unit/Licensing/ActivationServiceTest.php
tests/Unit/Licensing/ValidationServiceTest.php
tests/Unit/Licensing/LicenseSchedulerTest.phpChanged
| File | Change | Risk |
|---|---|---|
src/Plugin.php | Insert LicensingServiceProvider after DatabaseServiceProvider | low, additive to a filtered list |
src/Core/PluginInfo.php | Add licence option-name constants | low |
src/Core/Activator.php | Seed installation_uuid in activate_site() | low, idempotent |
src/Core/Deactivator.php | Comment recording that deactivation deliberately does not release the installation | none |
src/Core/Uninstaller.php | Include licence options in the opt-in purge | low |
src/Core/Hooks.php | Licensing hook constants | low |
src/Logging/Logger.php | Add license_key, installation_token, signature to the redaction list | low |
tests/bootstrap.php | Stub wp_remote_post, wp_remote_retrieve_*, wp_schedule_event, wp_next_scheduled, wp_clear_scheduled_hook, wp_get_environment_type, home_url, get_bloginfo, is_multisite, get_current_blog_id | medium — shared by all 163 existing tests |
docs/ARCHITECTURE.md, docs/HOOKS.md | Document the new provider, services and hooks | none |
tests/bootstrap.php is the one shared file, and the reason the phase ends with a full-suite run rather than only the new tests.
Phase 1 exit criteria
- 163 existing tests still green
- UUID generated once, stable across boots, unique per blog
LicenseApiClientsends the documented activate/validate/deactivate payloads over a fake transport- Valid signature accepted; tampered payload, tampered signature, wrong
kid, unknown algorithm, wrong product, wrong installation, expired payload all rejected with the whole payload discarded - Hand-edited
ad_form_license_stateyields no entitlements - API timeout leaves a previously valid payload usable inside the offline grace
- A signed
plan: freepayload takes effect immediately - Scheduler registers once, jitter is deterministic per UUID, unscheduled cleanly
- No secret in any log line; licence key masked as
ADFORM-****-****-1234 - PHPCS clean
5. Later phases
| Phase | Scope | Primary anchors |
|---|---|---|
| 2 | Entitlement system | FeatureRegistry, EntitlementManager, FeatureGate, RuntimePolicy, ModuleRegistry, required_entitlements on AbstractField / AbstractAction |
| 3 | Admin UI | Menu::register(), View::TEMPLATES, templates/admin/license.php, LicensePage, notices |
| 4 | Server enforcement | FormService::assert_definition(), AbstractRestController::deny_feature(), ActionRunner::run(), ElementorIntegration, GutenbergIntegration |
| 5 | Feature matrix | Resolve docs/FEATURE_MATRIX_REVIEW.md, complete docs/FEATURE_ENFORCEMENT_AUDIT.md |
| 6 | Test matrix | Plan × feature × layer, plus the six bypass attempts |
| 7 | Hardening | Security, architecture, WordPress, REST, payment and licence-bypass review |
Phases 2 and 4 are separated deliberately: Phase 2 builds the vocabulary and can land without changing any decision, Phase 4 is the first phase that can break a working site.
6. Bypass attempts to test in Phase 6
Each must fail to unlock anything.
- Mutate React state to enable a locked palette item, then save
- Remove the
disabledattribute on a locked control and submit - Send
PATCH /forms/{id}directly with premium definition JSON - Hand-craft definition JSON containing a Stripe action on Free
- Write
ad_form_license_statedirectly with a forged entitlement list - Replay an expired but validly signed payload
- Copy another installation's payload into this site
- Add a hidden input naming a premium action type
- Call a premium REST route with a valid nonce but no entitlement
- Re-enable a disabled premium action in an already-stored definition
The first two are expected to succeed at the UI layer and fail at the server layer. That is the design: the UI is guidance, the server is enforcement.
7. Privacy
Sent to the licence server: installation_uuid, installation_token, site_url, home_url, environment signals, wordpress_version, php_version, plugin_version, is_multisite, blog_id, license_key (on activation only).
Never sent: form definitions, entry data, entry values, field labels, visitor IPs, visitor email addresses, submission counts per form, customer records, or anything from ad_form_submissions, ad_form_submission_meta or ad_form_payments.
Licence telemetry and form data are separated at the namespace level: nothing in Licensing may take a Submission or a Form as a parameter, with the single exception of FormEntitlementValidator, which reads a definition's structure to determine required entitlements and never transmits it.