Skip to content

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

ConcernValue
Dashboardhttps://dashboard.andredingfelder.com
APIhttps://api.andredingfelder.com
API version/v1/
Product idad_forms
Upgrade routehttps://dashboard.andredingfelder.com/products/ad-forms
Signature algorithmEd25519, sodium_crypto_sign_verify_detached
Transportwp_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 licenceSystem B — form payments
Who paysThe customer pays usWebsite visitors pay the customer
Stripe accountPlatform operator'sThe customer's own
Code namespaceDingfelder\AdForm\LicensingDingfelder\AdForm\Payments
StorageOptions (ad_form_license_*)ad_form_payments table
Identifierslicense_id, installation_id, plangateway, 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.

AspectPolicy
Network activationSupported. Each blog activates its own installation independently
Site-specific activationSupported, identical code path
Licence scopePer blog. One licence key may be entered on several blogs; the API decides whether that is within the plan
Installation UUID scopePer blog. Each blog gets its own UUID and its own installation_token
Network admin screenNot in Phase 1. Would be additive later, not a scope change
Signals sentis_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.

ScenarioBehaviour
First activationGenerate and store
Normal bootRead only. Never generate outside activation and an explicit repair path
Site clone / staging copyUUID is copied too. Reported alongside site_url; the API detects the collision and decides
Backup restoreUUID returns with the backup, which is correct — same installation
Migration to a new domainUUID stays, site_url changes. The API reconciles
MultisiteOne 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_TYPE presence
  • 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

EventLicence effect
WordPress plugin deactivationNone. No API call. Deactivator keeps its current single responsibility
Explicit "Disconnect licence" in the admin UIPOST /v1/licenses/deactivate, local state cleared
Plugin delete with delete_data_on_uninstall offLocal licence options removed, no API call
Plugin delete with the option onBest-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:

OptionAutoloadContent
ad_form_license_identitynoinstallation_uuid, installation_token, license_id
ad_form_license_payloadnoRaw signed payload, signature, kid, algorithm
ad_form_license_stateyesDerived 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:

ClockMeaningValue
Refresh intervalHow often we askrefresh_after from the server, default ~24 h, plus jitter
Offline graceHow long a validly signed payload stays usable when the API is unreachable7 days past expires_at
Business graceGrace for non-paymentZero. 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:

Categorycreationeditingfrontend executionbackground execution
Builder features (multistep, conditions, calculations, repeater)denieddeniedallowedallowed
Notifications beyond entitlementdenieddenieddenieddenied
Paymentsdenieddenieddenieddenied
PDFdenieddenieddenieddenied
Integrationsdenieddenieddenieddenied
Page builder widgetsdenieddeniedallowed for existing embedsn/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/

ClassResponsibility
LicenseConfigBase URL, API version, product id, upgrade routes, timeouts. Single source for every platform URL
LicenseApiClientThe only wp_remote_post() caller. Headers, user agent, JSON encoding, timeout, error normalisation, response shape validation
ApiResponseValue object: HTTP status, decoded body, normalised error code
ApiTransportInterfaceSeam so tests run without network; WpHttpTransport is the production implementation
InstallationIdentityUUID generation and read, token storage, masked accessors for logging
EnvironmentDetectorEnvironment and staging signals. Reports, never decides
ActivationServicePOST /v1/licenses/activate
ValidationServicePOST /v1/licenses/validate
DeactivationServicePOST /v1/licenses/deactivate
SignatureVerifierEd25519 detached verification. Rejects the whole payload on failure
PublicKeyRegistrykid → public key, with algorithm and retirement date. Supports rotation
LicensePayloadImmutable parsed payload: version, license_id, installation_id, product, plan, billing_interval, license_status, entitlements, issued_at, refresh_after, expires_at
LicenseValidatorStructural and semantic payload checks: product match, installation match, version support, clock sanity
LicenseStateThe state machine. Distinguishes unlicensed / active / suspended / revoked / expired / offline-grace / offline-expired
LicenseRepositoryOption read/write for the three options
LicenseCacheVerify-on-read of the stored payload, derived-slice maintenance
LicenseManagerThin façade the rest of the plugin talks to. Delegates; holds no logic
LicenseSchedulerCron registration, jitter, opportunistic refresh triggers
LicenseLoggerlicense channel wrapper over Logging\Logger with mandatory masking

src/Licensing/Entitlements/

ClassResponsibility
FeatureRegistryEvery licensable feature: key, name, category, dependencies, default_marketing_plan
FeatureImmutable feature descriptor
EntitlementManagerallows(), denies(), require(), all(), state(). Resolves dependencies transitively
EntitlementSetImmutable resolved entitlement list for the current request
FeatureGateConvenience wrapper for callers that need a boolean plus a reason
RuntimePolicyThe four-switch table above, per category
NotEntitledExceptionImplements PluginException; status() 403, error_code() feature_not_entitled

src/Licensing/Enforcement/

ClassResponsibility
FormEntitlementValidatorInspects a sanitised definition, returns required-but-missing entitlements. Diff-aware against the stored definition
DefinitionFeatureScannerMaps definition structures to feature keys (stepsforms.multistep, field.conditions.enabledforms.conditional_logic, action type → action feature, and so on)
LockedFeatureMarkerMaintains contains_locked_features in ad_form_form_meta
ActionEntitlementGuardHooked into ActionRunner; denies unentitled actions with skip-and-log
ModuleRegistryFirst real consumer of the dormant ModuleInterface; resolves required_entitlements before registering a module

src/Licensing/Admin/

ClassResponsibility
LicensePageRenders the screen, follows the FormsPage / EntriesPage pattern
LicenseFormHandleradmin_post handlers for connect / disconnect / refresh. Capability, nonce, sanitisation
LicenseNoticesAdmin notices for revoked, expired, offline-expired, locked-feature forms
UpgradeUrlBuilderProduct-specific dashboard routes, centrally configurable
FeatureStatusTableThe Active/Locked feature overview

src/Licensing/REST/

ClassResponsibility
LicenseControllerGET /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.php

Changed

FileChangeRisk
src/Plugin.phpInsert LicensingServiceProvider after DatabaseServiceProviderlow, additive to a filtered list
src/Core/PluginInfo.phpAdd licence option-name constantslow
src/Core/Activator.phpSeed installation_uuid in activate_site()low, idempotent
src/Core/Deactivator.phpComment recording that deactivation deliberately does not release the installationnone
src/Core/Uninstaller.phpInclude licence options in the opt-in purgelow
src/Core/Hooks.phpLicensing hook constantslow
src/Logging/Logger.phpAdd license_key, installation_token, signature to the redaction listlow
tests/bootstrap.phpStub 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_idmedium — shared by all 163 existing tests
docs/ARCHITECTURE.md, docs/HOOKS.mdDocument the new provider, services and hooksnone

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
  • LicenseApiClient sends 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_state yields no entitlements
  • API timeout leaves a previously valid payload usable inside the offline grace
  • A signed plan: free payload 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

PhaseScopePrimary anchors
2Entitlement systemFeatureRegistry, EntitlementManager, FeatureGate, RuntimePolicy, ModuleRegistry, required_entitlements on AbstractField / AbstractAction
3Admin UIMenu::register(), View::TEMPLATES, templates/admin/license.php, LicensePage, notices
4Server enforcementFormService::assert_definition(), AbstractRestController::deny_feature(), ActionRunner::run(), ElementorIntegration, GutenbergIntegration
5Feature matrixResolve docs/FEATURE_MATRIX_REVIEW.md, complete docs/FEATURE_ENFORCEMENT_AUDIT.md
6Test matrixPlan × feature × layer, plus the six bypass attempts
7HardeningSecurity, 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.

  1. Mutate React state to enable a locked palette item, then save
  2. Remove the disabled attribute on a locked control and submit
  3. Send PATCH /forms/{id} directly with premium definition JSON
  4. Hand-craft definition JSON containing a Stripe action on Free
  5. Write ad_form_license_state directly with a forged entitlement list
  6. Replay an expired but validly signed payload
  7. Copy another installation's payload into this site
  8. Add a hidden input naming a premium action type
  9. Call a premium REST route with a valid nonce but no entitlement
  10. 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.

Digitale Lösungen. Persönlich. Zukunftssicher.