# Credential and installation boundaries

`Waitlistize\` maps directly to `includes/` (PSR-4). Production autoloading is local and does not require Composer. WordPress provides HTTP, REST, options, cron and localization; no additional framework is introduced.

The credential/device state is one encrypted `_waitlistize_connection` option per blog, written with `autoload=false`. Sodium secretbox provides authenticated encryption with a random nonce. A separate trusted `WAITLISTIZE_TOKEN_KEY` is optional; otherwise WordPress auth/secure-auth salts derive the key, separated by blog id. Use at least 32 random bytes for an explicit key. There is no plaintext fallback. WordPress's Sodium compatibility implementation is accepted. Changing the key or salts invalidates the stored state. `_waitlistize_disabled` is also non-autoloaded and contains no secrets. These options are not registered as public REST settings.

Fresh database reads bypass request-local options/notoptions caches during lifecycle synchronization. A single encrypted option replaces the complete credential set atomically. Public results are explicitly constructed, never copied from token endpoint responses. Error messages contain no upstream response bodies; the plugin adds no payload/header/token logging. General WordPress HTTP debug hooks and third-party observability plugins can see server-side HTTP traffic: operators must redact Authorization, device_code, refresh_token and token responses in those systems too.

A fully compromised WordPress installation can read or use credentials and encryption keys. At-rest encryption does not change that boundary. Database backups remain sensitive.

## Refresh and disconnect concurrency

`GET_LOCK` uses a name derived from database name and blog table prefix. The MySQL/MariaDB session holds it over HTTP and state replacement. It is shared across PHP workers, released in finally, and automatically released when a dead database session closes. Same-process reentry is rejected. The plugin fails closed on database engines/proxies that do not support session-affine named locks. MySQL/MariaDB are the supported WordPress database runtime; SQLite adapters are not supported for this lock.

Before refreshing, a durable `refresh_in_flight` tombstone replaces the old pair. The old refresh token exists only in the active worker's memory while the request runs. A worker crash, timeout, 5xx, lost database lock or malformed success cannot cause another worker to replay the old token. An explicit 429 is pre-execution throttling under L1 and can restore the pair with a backoff. Other uncertain refresh outcomes discard local credentials and request new pairing; remove the orphaned connection in Waitlistize's admin if needed.

A separate disabled marker is written immediately when disconnect starts. After obtaining the same lifecycle mutex, local state is deleted before any remote request. Every other credential write checks the marker and database-lock ownership. An in-flight worker cannot restore usable credentials after disconnect. If expired, the access token can be refreshed in memory only for revocation; credentials are never saved again. Only 204 confirms revocation. Failures remain visibly unconfirmed. Lost/unknown credentials may require manual revocation at Waitlistize; no background credential retry queue remains after disconnect.

## Trusted origins

Only deployment constants can choose the API origin. The value must be an origin, with no credentials, path, query or fragment. HTTPS is required. Redirects are disabled for credential-bearing requests and TLS verification stays enabled. Browser approval URLs must have the same trusted origin and exact `/connect/wordpress` path; the complete URL must carry the matching user_code. Arbitrary admin inputs never become hosts.

For explicit local development only, HTTP is allowed if `WP_ENVIRONMENT_TYPE=local`, `WAITLISTIZE_ALLOW_INSECURE_LOCAL_API=true`, and the configured host is `localhost`, `127.0.0.1` or `[::1]`. This does not relax L1's requirement for an HTTPS `site_url`. Staging should use HTTPS normally.

## Clones and migrations

The state binds to the configured home/site URLs, WordPress absolute path, table prefix, environment type, auth salt, trusted API origin and optional deployment-specific `WAITLISTIZE_INSTANCE_KEY`. Changed bindings or failed decryption erase copied credentials before HTTP and persist `installation_changed`. A clone never revokes the original installation's credentials automatically. Each new pairing generates a new installation id, stable throughout that connection's refresh lifecycle.

Identical clones of the database, files, salts, paths and all configuration are indistinguishable from the original using local information. Deployment automation must set a unique `WAITLISTIZE_INSTANCE_KEY` outside the copied database before starting the clone. This is an explicit operational requirement, not a claim that an installation id proves domain ownership.

## Lifecycle

Activation creates no SaaS traffic. An existing approved connection may resume cron after reactivation. Deactivation removes cron on the selected site or all sites for network deactivation and retains encrypted state. Uninstall erases both options and cron per site in batches of 100, without remote calls. Disconnect first when remote revocation is wanted. Newly created Multisite sites have no connection or credential jobs.

## Account target in W1-R1

The slug is an identifier, never authentication. The fixed API origin resolves it; no slug becomes a hostname or path. The backend canonical response is retained separately from approved account data. Until the first identity check succeeds, an encrypted marker requires exact canonical slug equality as well as account/connection/scope binding, including after an outage or refresh. Only then is connected status allowed. Established connections remain bound to numeric account_id; renames do not change ownership. Missing slug fields on existing connected state do not trigger a reset, key change or fingerprint change.

New input is server-validated before HTTP. Existing targets cannot be edited in place. No account lookup runs on settings load or typing. A missing backend target stops pairing rather than permitting old account selection. Error messages never expose raw upstream data or infer account existence from invalid_request.
