# Ecosystem auth & purchase — international-press-zone implementation plan

## Objective and fixed contract

Rebuild `plugins/international-press-zone` around the connect contract in `/home/user/Projects/Press.zone/press-zone-core/docs/specs/2026-08-13-ecosystem-auth-design.md` §3, §5, §6, and §7. The backend sibling plan owns the other side of the frozen seam. The plugin must use social-login popup + PKCE, a backend-minted invisible per-site API key, Stripe embedded Checkout, status polling, and an Account page. It must remove the old license-key, onboarding-JWT, password login/register, PayPal onboarding, and duplicate OAuth paths.

Do not touch `translate-press-zone` or `multilingual-press-zone`, do not alter the frozen backend route/response shapes, and keep `IPZ_API_BASE_URL` (default `https://api.press.zone`) as the only backend base-URL seam. Full update delivery remains out of scope except changing update authentication from a request field to `Authorization: Bearer <api key>`.

Before implementation, follow `plugins/international-press-zone/.claude/agents/expert.md` and load its `wordpress-php-integration`, `settings-management`, `admin-panel-fullstack`, `frontend-javascript`, `frontend-styling-scss`, `api-integration`, `users-permissions`, `backend-integration`, `translation-engine`, `verification`, and `ipz-e2e` guidance. The repository-level `PROJECT-SPECIFICATION.md` required by the monorepo instructions is not present in this checkout; the ecosystem design is the authoritative architecture source for this task. Preserve unrelated dirty work. Do not expose partially wired routes/UI in an intermediate landing; if implementation is landed in slices, keep registration/navigation dormant until the complete flow is present.

## Current integration points that must be replaced

- `includes/Licensing/ActivationManager.php`, `LicenseClient.php`, and `LicenseValidator.php` own the three obsolete credential paths. Only `includes/Licensing/SiteIdentity.php` and `UpdateChecker.php` should remain in that directory.
- `includes/Admin/LicensingController.php` implements a second, manual OAuth page; `includes/API/LicenseRestController.php` and `includes/Admin/LicensingRestController.php` are duplicate license REST surfaces. `includes/Core/Plugin.php` has the API/legacy if/elseif registration and separately registers `Translation\OnboardingApi`.
- `includes/Translation/Settings.php` exposes both `get_license_key()` and `get_api_key()`. Several translation transports still call the former even though the API controllers mostly use the latter.
- `includes/Admin/MenuController.php`, `admin/src/main.js`, `includes/Performance/AdminDataPolicy.php`, and the admin unit/E2E contract tests all encode “Licensing” and the old onboarding endpoints. These are hidden integration surfaces, not optional cleanup.
- `admin/src/utils/api.js` always parses JSON on success, so it must be made safe for the required `204` disconnect response.
- `admin/package.json` has no Stripe browser dependency. Generated `admin/dist` assets are built for verification but are not hand-edited.
- Browser E2E is canonical only through `~/.claude/bin/e2e-remote` and `tests/e2e/remote-stack.sh`; the browser target must remain loopback WordPress at `http://127.0.0.1:8080` (equivalent to the request’s localhost target), in Chromium and Firefox, with no direct workstation Playwright invocation or remote WordPress/backend traffic.

## Implementation sequence

### 1. Add the encrypted connection store

Create `plugins/international-press-zone/includes/Connect/CredentialStore.php` in namespace `InternationalPressZone\Connect`, with `declare(strict_types=1)` and the ABSPATH guard. Implement the required public contract exactly:

- `save(api_key, site_id, account_meta)`
- `get_api_key(): ?string`
- `purge(): void`

Use exactly one non-autoloaded serialized option, `presszone_international_connect`. Store the encrypted API key plus the backend-minted `site_id` and sanitized account display metadata in a versioned structured payload. Use AES-256-GCM only, the key derived by `hash('sha256', wp_salt('auth'))`, a cryptographically random cipher-sized IV, and a GCM authentication tag. Encode binary fields safely for option serialization. Reject malformed/empty key data, invalid UUID/site identifiers, missing OpenSSL/GCM support, failed randomness, failed encryption, failed strict decoding, or failed authenticated decryption. On any read/decrypt/authentication failure, delete the option and return `null`; never attempt plaintext, base64-only, CBC, legacy, or default-key recovery. Ensure `save()` does not replace a good stored credential when encryption/persistence fails and does not log or expose secret material.

Add the minimum metadata accessor needed by `ConnectService` to merge stored `site_id`/account identity into status and reconnect requests, without adding a second option or exposing the API key to browser-facing responses. Unit-test option autoload behavior, round trip, random IV/non-deterministic ciphertext, tampered ciphertext/tag/IV, malformed payload, changed salt, unavailable crypto, and purge-on-failure.

### 2. Implement the connect domain service

Create `plugins/international-press-zone/includes/Connect/ConnectService.php` with the exact required methods:

- `begin_connect(): array`
- `complete_connect(code, state)`
- `disconnect(): void`
- `status(bool $refresh): array`
- `is_connected(): bool`

Inject/use `CredentialStore` and retain `Licensing\SiteIdentity` for strict local URL identity. Backend URL construction must always derive from `IPZ_API_BASE_URL`; validate it as HTTPS, allowing HTTP only for loopback/localhost test use. Use the WordPress HTTP API with explicit timeouts, JSON validation, and the backend `{error:{code,message,…}}` envelope. Never log request authorization headers, API keys, OAuth codes, PKCE verifiers, checkout secrets, or response bodies containing secrets.

`begin_connect()` must:

1. Fail closed on an invalid site URL.
2. Generate a high-entropy RFC 7636 verifier, S256 challenge, and state.
3. Store verifier, state, exact redirect URI, initiating user ID, local site URL, and the stored backend `site_id` (when present) in a 10-minute transient keyed so parallel users cannot consume each other’s flow.
4. Return `{authorize_url, state}` internally. Build the backend `/connect` URL with `client_id`, exact dedicated redirect URI, `state`, `code_challenge`, `code_challenge_method=S256`, `plugin=international`, and the site identity fields expected by the backend sibling contract. Continue using the existing OAuth client-id seam/default; do not introduce another base URL.

`complete_connect()` must sanitize code/state, load a current-user pending flow, compare state with `hash_equals`, reject missing/expired/mismatched/replayed flows, consume the transient before exchange, and POST server-to-server to `/oauth/token` with authorization-code grant, verifier, exact redirect URI/client ID, plugin/site context, and stored `site_id` when reconnecting. Accept only the pinned successful response containing `api_key`, `site_id`, `account`, and `subscription`; atomically save it via `CredentialStore`, clear status cache, and return a normalized status snapshot that contains no credential. Preserve backend `SITE_LIMIT_REACHED` and returned active-site summaries in a structured `WP_Error`.

`status()` must return a stable disconnected snapshot when no key exists, cache successful `/api/connect/status` results for five minutes, merge the stored account identity for “connected as,” and bypass the transient when `$refresh` is true. A backend HTTP 401/`INVALID_CREDENTIAL` must purge the credential and status cache and return disconnected, not stale connected state. Do not cache transport/5xx failures as disconnection.

`disconnect()` must POST `/api/connect/disconnect` with Bearer auth, purge local credential/cache after 2xx or an already-revoked 401, and retain local state on retryable network/5xx failure so the administrator can retry and the backend seat is not silently stranded. `is_connected()` is strictly based on successful authenticated credential retrieval, not old options.

Add focused PHP tests using stubbed WordPress HTTP/options/transients for PKCE syntax/challenge, state expiry/mismatch/replay, exact redirect URI, base URL seam, token response validation, seat-limit propagation, no partial save, status cache/bypass, 401 purge, disconnect outcomes, and proof that secrets never appear in returned snapshots.

### 3. Replace license REST and add the popup landing

Create `plugins/international-press-zone/includes/API/ConnectRestController.php`, but follow the task’s required namespace `InternationalPressZone\Connect`. Register exactly these routes under `international-press-zone/v1`:

- `POST /connect/begin` → `{authorize_url}`
- `POST /connect/complete` with sanitized `{code,state}` → status snapshot
- `POST /connect/checkout` with sanitized package slug → proxied `{checkout_client_secret,publishable_key}`
- `GET /connect/status` with boolean `refresh=1` bypass → status snapshot
- `POST /connect/portal` → `{url}`
- `POST /connect/disconnect` → empty HTTP 204
- `GET /connect/packages` → backend `GET /api/packages`, cached server-side for one hour

Every route must require `manage_options` and valid cookie REST authentication via `X-WP-Nonce`; test both absent/invalid nonce and lower-capability denial before side effects. Validate package slugs against the fetched backend catalog rather than a local tier map. Checkout, portal, status, and disconnect must use `CredentialStore::get_api_key()` for `Authorization: Bearer …`. Proxy only the pinned response fields and structured backend error codes (`SITE_LIMIT_REACHED`, `SUBSCRIPTION_EXISTS`, `PLUGIN_NOT_ENTITLED`, `INSUFFICIENT_CREDITS`, `INVALID_CREDENTIAL`); never return an API key or log a client secret. Validate the portal URL as HTTPS before returning it.

Register an authenticated `admin_post_presszone_international_connect_redirect` landing handler from this controller/service. Its redirect URI is generated with `admin_url('admin-post.php?action=presszone_international_connect_redirect')`. The handler accepts only sanitized `code` and `state`, renders no inline CSS and no secret diagnostics, and emits a minimal escaped page that calls `window.opener.postMessage({type:'pz-connect',code,state}, <exact WordPress admin origin>)` and then `window.close()`. Use JSON encoding for script data and a fixed target origin; provide an escaped close/fallback message if there is no opener. The opener, not the popup, performs `/connect/complete`.

Modify `includes/Core/Plugin.php` to register only `ConnectRestController` in place of the license if/elseif branch and `Translation\OnboardingApi`. Keep `API\OnboardingRestController` only for the post-purchase language-setup completion behavior if it remains used. Delete:

- `includes/API/LicenseRestController.php`
- `includes/Admin/LicensingRestController.php`
- `includes/Admin/LicensingController.php`
- `includes/Translation/OnboardingApi.php`

Update `admin/src/utils/api.js` and its unit tests so a successful 204 returns `null` without attempting JSON parsing while preserving structured WordPress REST error `code`, `status`, and `data` (including seat summaries).

### 4. Retire legacy credentials on activation and upgrade

In `plugins/international-press-zone/international-press-zone.php`, add one idempotent retirement routine and invoke it from both the activation hook and a version-gated normal upgrade path before persisting the new `ipz_version`. On a normal plugin upgrade, compare the previously stored version, run once, and only then update the version marker. The routine must:

- Delete `ipz_license_key` and `ipz_api_credential` (these literals may remain only in this deleter).
- Delete all `_transient_ipz_onboarding_jwt_*` and timeout rows, including JWTs belonging to users other than the current administrator, with prepared/escaped option-table matching and matching object-cache invalidation.
- Remove other retired license status/tier/site-count/log/validation/activation-attempt options/transients so no stale state can make the new plugin appear connected.
- Leave non-licensing `ipz_*` settings untouched and leave `presszone_international_connect` intact on later ordinary activations/upgrades unless this is the first retirement from the old credential family.

Extend activation/version-gate tests to prove direct activation and ordinary upgrade both clean the old data, unrelated options survive, the migration is idempotent, and failed plugin bootstrap does not incorrectly advance the version marker.

Add a clear cross-reference to `plugins/international-press-zone/docs/plans/2026-08-11-licensing-hardening.md`: LH-01 is superseded by `Connect\CredentialStore`; its CBC migration/plaintext compatibility must not be implemented. Do not rewrite unrelated hardening tasks.

### 5. Switch every backend consumer to the new API key

Delete `includes/Licensing/ActivationManager.php`, `LicenseClient.php`, and `LicenseValidator.php`. Keep `SiteIdentity.php`. Refactor `includes/Licensing/UpdateChecker.php` to depend directly on `Connect\CredentialStore` and its own narrowly scoped update transport: send Bearer API-key auth to the existing update check/verify routes, remove the `license_key` body field, retain HTTPS/package hash/signature verification and all other update behavior, and do not redesign update delivery.

In `includes/Translation/Settings.php`, remove `get_license_key()` and make `get_api_key()` return `CredentialStore::get_api_key()` after the existing fail-closed site URL check. Rename credential variables and methods throughout the translation path so there are no license-key semantics left, especially:

- `includes/Translation/ServiceRegistrar.php`
- `includes/Translation/CharacterEstimator.php`
- `includes/Translation/BulkActions.php`
- `includes/Translation/TranslationJobDispatcher.php`
- `includes/Translation/SiteRegistrar.php`
- `includes/Translation/TranslationService.php`
- their affected unit/integration stubs and assertions

Keep existing `Authorization: Bearer …`, `X-Plugin: international`, plugin-version, site URL, callback, retry, and payload behavior. Existing `includes/API/TranslateController.php`, `StringTranslateController.php`, `TranslateJobsController.php`, `Translation/JobSender.php`, and `Integration/TranslationAPI.php` already call `get_api_key()`; verify they resolve to the new store and retain headers. Where these transports parse backend failures, switch on the pinned codes: purge on `401 INVALID_CREDENTIAL`, surface reconnect/onboarding; present entitlement and credit exhaustion distinctly for `402 PLUGIN_NOT_ENTITLED` and `402 INSUFFICIENT_CREDITS`; retain `Retry-After` behavior for 429. Never include the credential in a request body, UI response, exception message, or log.

`SiteRegistrar` must prefer the backend-minted `site_id` held in the connection option wherever a site identifier is needed; it must not overwrite that identifier with URL-derived or legacy registration state. Do not reconcile unrelated non-licensing option names.

Update or replace all affected PHP tests. Remove obsolete tests dedicated to license validation/storage/manual OAuth/old onboarding, including old `LicensingControllerStateMismatchTest`, `LicenseClientApiBaseTest`, license storage/client validator tests, `OnboardingApiIdentityStandaloneTest`, and old onboarding route-registration assertions. Preserve and adapt still-relevant site identity, translation Bearer auth, package verification, and activation-bootstrap coverage to `CredentialStore`/`ConnectService`.

### 6. Replace the browser data-policy and navigation contracts

Modify `includes/Performance/AdminDataPolicy.php` and its PHP/JS contract tests:

- Replace `license:live`/`LicensingPage` and old license/PayPal endpoints with a non-persistent `connect:status`/`AccountPage` descriptor for `/connect/status` and mutations for portal/disconnect/complete.
- Replace `onboarding:plans` with `connect:packages` for `/connect/packages`; package reference data may use browser memory freshness, while credential/status/checkout/portal data must never persist in `localStorage` or `sessionStorage`.
- Remove license/payment tags and live fields that no longer exist; retain only connect/account/subscription/wallet/checkout state needed by the new pages.

Update `admin/src/main.js`, `includes/Admin/MenuController.php`, admin lazy-loading/routing tests, E2E route inventories, and universal-admin manifests from `licensing`/`#/licensing`/`ipz-licensing-root` to `account`/`#/account`/`ipz-account-root`. Rename the sidebar and WordPress submenu label to “Account.” Remove `MenuController`’s registration of the deleted native `LicensingController`. Keep `#/onboarding` for activation/language setup. Update visual/snapshot surface manifests rather than weakening or skipping them.

Add `@stripe/stripe-js` to `admin/package.json` and update `admin/package-lock.json` with npm. Load it only in the checkout path (dynamic page/chunk import) so the normal admin entry remains lean and `npm run build` emits no performance warnings.

### 7. Rewrite connect-based onboarding

Rewrite `admin/src/pages/onboarding.js` around only these states:

`WELCOME → CONNECTING → CHECKOUT (only when not active) → ACTIVATING → LANGUAGE_SETUP → COMPLETE`

Implement the following behavior:

- On entry, fetch packages from `/connect/packages`; render only backend catalog fields, with no local catalog, fallback price, tier constants, billing toggle, password/email forms, PayPal path, or license output. WELCOME shows package choices and a returning-customer “Sign in” action.
- Selecting a package records only its public slug in page state and begins connect. Sign-in begins connect without a package. To preserve popup user activation, synchronously pre-open a blank popup from the click handler, then call `/connect/begin` and navigate that popup to `authorize_url`. If `window.open` returns null, show a clear inline user-gesture retry button; do not auto-loop.
- Register one `message` listener while connecting. Accept only `event.origin === window.location.origin`, the expected popup as `event.source`, `data.type === 'pz-connect'`, and a state equal to the state parsed from the returned authorize URL. Ignore all other messages. POST code/state to `/connect/complete`; the API key never enters JS.
- On `SITE_LIMIT_REACHED`, display the returned active-site summaries as escaped text and link to `#/account` to disconnect a site. Other backend errors remain retryable and human-readable without dumping envelopes.
- If the returned subscription status is `active`, skip checkout and continue to language setup/complete. If it is not active and no package was chosen (returning sign-in to an unpaid account), return to WELCOME and require a package choice.
- For CHECKOUT, POST the selected package to `/connect/checkout`, initialize `@stripe/stripe-js`, call `initEmbeddedCheckout` using the proxied `checkout_client_secret` through the supported `fetchClientSecret` callback, and mount it into a dedicated accessible container. Keep `publishable_key` and client secret in memory only. Provide an explicit Back/Cancel action that destroys the checkout instance and returns to WELCOME with the package still selected; restore that state after an abandoned Stripe return without storing any secret.
- Checkout completion enters ACTIVATING. Poll `/connect/status?refresh=1` every three seconds for at most five minutes. Move on only when `subscription.status === 'active'`. After timeout, stop polling and show “still processing” with an explicit retry that starts a new bounded poll. A 401/disconnected snapshot returns to connect onboarding.
- Preserve the current language-setup behavior and `/onboarding/complete` call, then show COMPLETE. Clean up popup references, message listeners, Stripe instances, timers, and aborted requests in `destroy()` and on every state transition.
- Use existing components/design tokens, translated strings, semantic buttons, focus movement/live regions, visible focus, dark mode, responsive layout, and reduced motion. Use `textContent`/DOM builders for backend data. No inline CSS.

Rewrite `admin/src/styles/pages/_onboarding.scss` as needed. Add/update Vitest coverage for state transitions, origin/source/state rejection, popup blocked retry, active skip-checkout, package preservation, seat list escaping, Stripe mount/unmount, 3-second polling/5-minute timeout/retry, cleanup, and no browser storage of secrets.

### 8. Replace Licensing with Account

Delete `admin/src/pages/licensing.js` and create `admin/src/pages/account.js`. Delete/replace `admin/src/styles/pages/_licensing.scss` with `_account.scss` and update `admin/src/styles/main.scss`.

The Account page loads `/connect/status` and renders only connected-as email/display name, subscription plan/status, renewal date, wallet balance, and this site’s ID/URL as non-secret metadata. It must never render, mask, copy, inspect, or log an API key. Handle disconnected, active, past-due/grace, canceled, entitlement-missing, and credit-empty states accessibly.

- “Manage billing” must open a user-gesture tab safely, POST `/connect/portal`, validate/use the returned URL, set `noopener,noreferrer`, and close the placeholder tab on failure.
- “Disconnect this site” requires an accessible confirmation, POSTs `/connect/disconnect`, handles 204, clears live stores, and sends the user to `#/onboarding`/disconnected state. Do not imply that disconnect cancels billing.
- A remotely revoked key/status 401 must visibly drop the page to disconnected and offer reconnect; stale account data must disappear.

Replace `admin/tests/licensing-loading.test.js` with Account tests and update store, lazy-load, routing, skeleton, snappy, and visual contract tests. Delete old license activation/deactivation/backend-surface E2Es and UJ-008/UJ-009 license journeys; replace UJ-010’s retired checkout assumptions with the connect onboarding flow where journey coverage remains part of the maintained suite. Do not leave tests that contact `api.press.zone`.

### 9. Add the focused local mocked-backend E2E

Create `plugins/international-press-zone/tests/e2e/connect-onboarding.spec.js` (the `connect-onboarding` focused spec requested by acceptance) and a deterministic mock fixture under `tests/e2e/fixtures/` as needed. Extend `tests/e2e/remote-stack.sh` only enough to install/configure that mock in the isolated WordPress stack and set `IPZ_API_BASE_URL` to the same loopback WordPress origin for this run. Use WordPress HTTP preemption/test-control hooks for PHP server-to-server backend responses and a same-origin popup landing/authorize fixture, so neither PHP nor the browser contacts a real backend. Keep test-control routes local, authenticated where appropriate, and unavailable outside E2E.

Stub Stripe at the browser boundary without making an external request: install an init script that supplies the expected Stripe object/`initEmbeddedCheckout`/mount/unmount behavior before the library injects a network script, and assert that the embedded checkout is mounted with the proxied client secret. Do not weaken production Stripe code or introduce a production “test mode” branch.

Cover in both Chromium and Firefox with zero skips:

1. New customer: backend package → package selection → popup message with valid origin/state → complete exchange → encrypted store (assert only indirectly; no key in DOM/network response/log) → embedded checkout mount → completion → 3-second refreshed status poll → LANGUAGE_SETUP → COMPLETE.
2. Returning active customer: sign in → connect complete → skip CHECKOUT.
3. `SITE_LIMIT_REACHED`: escaped active-site list and Account-page resolution link.
4. Popup blocked: visible user-gesture retry opens the flow.
5. Status 401/revocation: credential purge and account/onboarding drop to disconnected.
6. Also assert wrong origin/source/state messages are ignored, checkout abandonment preserves the selected package, nonce/capability enforcement, no page/console/server errors, and no secret strings in rendered UI or browser storage.

Update old routing/snappy/visual E2Es that enumerate the renamed Account page. Keep Playwright artifacts in `tests/e2e/test-results`, HTML in `tests/e2e/playwright-report`, and JUnit in `tests/e2e/test-results/junit.xml`.

## Deletion and audit checklist

After the replacements, audit source, tests, package metadata, and non-document generated manifests. Outside the one migration deleter and documentation, there must be no occurrence of:

- `license_key`
- `LicenseValidator`
- `OnboardingApi`
- `ipz_license_key`

Also remove obsolete license routes, license-key labels/placeholders/masks, `LICENSE_PLANS`, tier feature constants, old OAuth constants/redirect page, onboarding JWT use, password registration/login, PayPal onboarding/upgrade code, and tests or snapshots that require real license/backend credentials. Documentation history can retain old terms; do not edit generated `admin/dist` by hand.

## Verification and completion evidence

Run deterministic tests as the relevant slices are completed, then run the full affected gates from `plugins/international-press-zone`:

1. PHP unit/standalone coverage, including the new connect tests and adapted translation/update tests, through `tests/run-unit-tests.sh` (and `composer test` for the configured PHPUnit suite).
2. `cd admin && npm test` for updated routing/store/onboarding/account/API tests.
3. `cd admin && npm run build` — exit 0 with no warnings. Judge by exit status and also resolve emitted warnings because the acceptance explicitly requires none.
4. `composer phpcs` — exit 0, zero warnings; do not hide new findings in a grown baseline.
5. `composer phpstan` — exit 0, zero warnings; regenerate obsolete baseline entries only when they are removals caused by deleted code.
6. Run the focused E2E through the canonical wrapper, not directly on the workstation:

   ```bash
   ~/.claude/bin/e2e-remote \
     --server "tests/e2e/remote-stack.sh" \
     --wait-port 8080 \
     --env WP_BASE_URL=http://127.0.0.1:8080 \
     -- /home/user/.local/share/mise/installs/pnpm/11.5.2/pnpm \
       --dir tests/e2e exec playwright test --config=playwright.config.js \
       connect-onboarding.spec.js --project=chromium --project=firefox
   ```

   Record exact discovered/executed/skipped counts, clean runtime output, artifacts, unique run ID, rewrite/REST readiness, and zero surviving labeled Podman resources. The requested logical command is `npx playwright test connect-onboarding`, but project policy requires this wrapper/equivalent focused spec invocation.
7. Run focused existing translation/update/site identity tests and the affected routing/universal-admin visual verification loop after SCSS/DOM changes; capture before/after screenshots in Playwright artifacts and resolve console/page errors.
8. Run mechanical greps over the plugin excluding `docs/` and allowing only the migration-deleter line for the four forbidden references above. Also grep for old `/license`, `/onboarding/register|login|paypal`, `LICENSE_PLANS`, and license-key UI strings. All unexpected matches are blockers.

Acceptance is complete only when the plugin activates cleanly on a fresh and upgraded local stack, all seven connect REST routes have the pinned shapes and authorization, the five named E2E journeys pass in both browsers, status 401 demonstrably purges the credential, no secret is browser-visible or logged, and build/PHPCS/PHPStan are clean.
