Skip to content

Installations, staging and environments

Identity, environment signals, and why the plugin decides none of it.

Installation limits

Per the product matrix:

PlanProduction sitesStaging / development
Free1none
Pro11 associated
Pro+31 per production site
Businessunlimitedassociated

The plugin does not enforce any of this. It reports what it sees and the API decides whether an activation is allowed. There is no local activation counter, no local site list and no local limit check. A license_limit_reached response is surfaced to the administrator with guidance to remove an installation in the dashboard.

Installation identity

installation_uuid, a UUID v4 from Support\Uuid, stored in ad_form_license_identity.

PropertyBehaviour
GeneratedOnce, seeded in Activator::activate_site()
RegeneratedNever on a normal request
ScopePer site, including per blog on multisite
RepairGenerated lazily if the option is missing, for installs that predate licensing
ValidationA malformed stored value is replaced rather than trusted

The UUID is an identifier, not a secret. It is safe in logs and support tickets.

The credential

After activation the platform issues an installation_token. From then on the license key is never transmitted again. The token is confidential:

  • never rendered into HTML
  • never placed in window.adFormBuilder or any other JavaScript
  • never returned by a REST endpoint
  • never logged, not even partially — only as ****, or as a 12-character fingerprint for correlating log lines
  • stripped by LicenseLogger before it can reach Logger::redact()

InstallationIdentity::forget_credentials() drops the token, installation id, license id and masked key but keeps the UUID, so a reconnect maps to the same installation instead of consuming another activation slot.

Lifecycle scenarios

ScenarioPlugin behaviourWho decides
First activationGenerate the UUID, activate, store the tokenAPI
Normal requestRead only
Plugin deactivatedNothing. Cron event cleared, license state kept
Plugin reactivatedExisting UUID reused, entitlements restored from cache
Plugin deleted, data keptLocal options remain
Plugin deleted, data deletion enabledLocal options removed, API not contactedCustomer, in the dashboard
http to httpsReport the new URL. No local comparisonAPI
www to non-wwwReport the new URLAPI
Domain migrationSame UUID, new URL, reportedAPI
Backup restoreUUID returns with the backup, which is correct
Site cloneThe clone carries the same UUID, reported alongside its own URLAPI
Staging refresh from productionSame as a cloneAPI

Site URL changes are never compared locally

There is no string comparison of site_url anywhere in the licensing code. site_url and home_url are reported verbatim on every activation and validation, and reconciliation is the API's problem.

That is deliberate. Naive URL equality is the classic way licensing breaks a working production site: an SSL migration, a CDN, a www redirect or a multisite domain mapping all change the string without changing the installation. Recorded as risk R-11 in docs/EXISTING_ARCHITECTURE_AUDIT.md. Asserted in EnvironmentDetectorTest::test_the_scheme_is_not_part_of_the_signal.

Environment detection

EnvironmentDetector reports signals. It never returns a verdict the API is expected to accept.

Signals collected

SignalSource
declared_typewp_get_environment_type()
resolved_typethe plugin's best local guess
hosthost component of site_url
host_matches_locallocalhost, 127.0.0.1, .local, .test, .localhost, .invalid, .example
host_matches_stagelabel-boundary match on staging, stage, dev, development, test, testing, preview, sandbox, demo, qa, uat
managed_host_matchknown managed-host staging domains
wp_debugWP_DEBUG
script_debugSCRIPT_DEBUG
env_constant_setwhether WP_ENVIRONMENT_TYPE is defined
is_multisite, blog_id, network_home_urlnetwork context

Matching is label-bounded

A staging marker only counts at a hostname label boundary, so development.example.com, shop-staging.example.com and dev-shop.example.com match, but bestdevices.com does not. Substring matching would misclassify real production sites, which is worse than missing a staging site — the API has better evidence anyway.

.test and .local are reserved local TLDs and are reported as local. That caught a wrong test fixture during development, which is the behaviour working as intended.

Declared type does not override a staging host

If wp_get_environment_type() says production but the host says staging, the plugin reports staging. A site operator claiming production must not be able to silence the host signal, since they control the PHP that returns it.

The reverse is honoured: a declared staging, development or local is believed even on a production-looking host, because that direction can only reduce what the customer is entitled to.

Why the plugin cannot be trusted about its own environment

The site operator controls PHP. wp_get_environment_type() is a function they can define the return value of. Every signal above is therefore a hint.

The API is expected to make the real decision using evidence the plugin cannot forge:

  • registrable domain rather than full hostname
  • the link to a known production installation of the same license
  • activation history and timing
  • domain patterns across the licensee's installations
  • anomalies, such as many "staging" activations on unrelated registrable domains

Recorded in docs/SECURITY_LICENSING.md as part of the trust boundary.

Multisite

The plugin is uniformly per-site. Core\WpOptions wraps get_option exclusively; there is no *_site_option call anywhere in src/. Activator installs the schema per blog and hooks wp_initialize_site; Uninstaller purges per blog.

AspectPolicy
Network activationSupported. Each blog activates independently
Site-specific activationSupported, identical code path
License scopePer blog
Installation UUID scopePer blog
Token scopePer blog
Network admin screenNot implemented. Additive later, not a scope change

Each blog reports is_multisite, blog_id and network_home_url, so the API can recognise that twenty installations belong to one network and bill them as one customer if the product decides to. That decision belongs on the platform, not in the plugin.

Rejected alternative: a network-wide license 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. Recorded as risk R-10 in the audit.

Data transmitted

Exactly these fields, asserted by RestGateTest:

product, api_version, installation_uuid, site_url, home_url, environment, wordpress_version, php_version, plugin_version, environment_signals, and either license_key (activation only) or installation_token (validation and deactivation).

Never transmitted: form definitions, form titles, field labels, field keys, entry data, entry values, submission counts per form, visitor IP addresses, visitor email addresses, customer records, or anything from ad_form_submissions, ad_form_submission_meta or ad_form_payments.

The boundary is structural: nothing in the Licensing namespace accepts a Submission, and the single class that reads a Form is FormEntitlementValidator, which inspects a definition's structure to determine required entitlements and transmits none of it.

Digitale Lösungen. Persönlich. Zukunftssicher.