# Plugin connect layer — response-envelope repair — request

**Goal:** Make the plugin's connect layer speak the backend's actual wire format, so sign-in, status, packages, checkout, portal and disconnect all work against `press-zone-core@main`.

**Context:**

The connect layer already exists and is wired (`includes/Connect/`, `admin/src/pages/onboarding.js`, `admin/src/pages/account.js`, REST controller registered in `includes/Core/Plugin.php:1241`). It does not work, for two reasons that no lint or build can catch:

1. **Every successful backend response is wrapped in a `data` key, and the plugin never unwraps it.**
   Backend `apps/api/src/http.ts:18-20`:
   ```ts
   export function ok(data: unknown, status = 200): Response { return json({ data }, status) }
   ```
   The plugin reads payload fields at the top level everywhere, so every success path fails.

2. **`POST /api/connect/disconnect` returns `204` with an empty body**, which the plugin's shared decoder classifies as an error, so `disconnect()` bails before `store->purge()` and the site can never release its credential locally.

The existing test `tests/unit/Connect/ConnectFlowStandaloneTest.php:155` stubs the exchange response *without* the envelope, which is why the defect passed review. The fixtures encode the wrong contract and must be corrected as part of this work.

**Wire contract (authoritative — from `press-zone-core@main`, do not re-derive):**

Success: `{"data": <payload>}`. Error: `{"error": {"code": "...", "message": "...", ...details}}`.

| Call | Success status | `data` payload |
|---|---|---|
| `POST {base}/oauth/token` | 200 | `{ api_key, site_id, account: { email, display_name }, subscription: { status, package } }` |
| `GET {base}/api/packages` | 200 | `{ packages: [...] }` (`price_minor` is a decimal **string**) |
| `GET {base}/api/connect/status` | 200 | `{ subscription: { status, package, renews_at }, wallet: { balance }, site: { id, url } }` |
| `POST {base}/api/connect/checkout-session` | 200 | `{ checkout_client_secret, publishable_key }` |
| `POST {base}/api/connect/portal-session` | 200 | `{ url }` |
| `POST {base}/api/connect/disconnect` | **204, empty body** | none |

Error codes the backend actually emits (the only ones worth branching on): `INVALID_CREDENTIAL` (401 — credential revoked), `SITE_LIMIT_REACHED`, `SUBSCRIPTION_PACKAGE_NOT_FOUND`, `STRIPE_CUSTOMER_NOT_FOUND`, `STRIPE_REQUEST_FAILED`, `BAD_REQUEST`, `FORBIDDEN`, `DOWNSTREAM_FAILED`.

**Files:**

- Modify `includes/Connect/ConnectService.php:246-270` (`response()`) — unwrap the success envelope; handle no-content responses.
- Modify `includes/Connect/ConnectRestController.php:181-199` (`packages()`) — this method calls `wp_remote_get` + `json_decode` directly and never reaches `response()`, so it needs its own unwrap.
- Modify `tests/unit/Connect/ConnectFlowStandaloneTest.php` — fixtures must carry the real envelope; add the coverage listed under Acceptance.

**Contract:**

`ConnectService::response( array|\WP_Error $response ): array|\WP_Error`

- Transport failure → `WP_Error('connect_network_error', …, ['status' => 502])` (unchanged).
- Status outside `200..299` → decode the body and return `WP_Error(sanitize_key($body['error']['code'] ?? 'connect_error'), sanitize_text_field($body['error']['message'] ?? …), ['status' => $status, 'details' => $error])` (unchanged behavior — the error path is already correct).
- Status `204`, or a success status with an empty body → return `array()`. A no-content success is a success.
- Success with a body that is not a JSON object, or is a JSON object with no `data` key, or whose `data` is not an array → `WP_Error('connect_invalid_response', …, ['status' => 502])`. Fail closed: never fall back to reading the raw body.
- Otherwise → return `$body['data']`.

`ConnectRestController::packages()`

- Read `$data['data']['packages']` and require it to be an array; anything else → the existing `WP_Error('packages_unavailable', …, ['status' => 502])`. Delete the `is_array( $data ) ? $data : array()` fallback — against the real envelope it silently caches the envelope as the package list, which is a worse failure than an error.
- Caching behavior, cache key and TTL are unchanged. Never cache an empty or malformed result.

**Behavior after the fix (no other logic changes):**

- `complete_connect()` sees `api_key` / `site_id` / `account` / `subscription` at the top level of the unwrapped payload and stores the credential.
- `status()` merges `subscription` / `wallet` / `site` into the snapshot at the top level, not nested under `data`.
- `disconnect()` receives `array()` from a `204`, so it proceeds to `store->purge()` + `delete_transient(self::CACHE)`. Purging locally must also still happen on a `401`, exactly as today.
- `checkout()` / `portal()` read `checkout_client_secret` / `publishable_key` / `url` from the unwrapped payload with no change to their own code.

**Out of scope — do not touch:**

- The authorize URL. `{base}/connect` is **correct**: the hosted page is `apps/web/src/routes/connect.tsx` and serving it on the same origin as `IPZ_API_BASE_URL` is a deployment prerequisite, not a plugin bug. Do not rewrite it to `/oauth/authorize`.
- `includes/Connect/CredentialStore.php` — audited, AES-256-GCM, fails closed. No changes.
- `admin/src/` — the SPA already consumes the plugin's own REST shape, which does not change.
- Any other `includes/` or `admin/` file, any migration, any dependency bump. No new PHPUnit infrastructure — `ConnectFlowStandaloneTest.php` is a standalone script and stays one.

**Acceptance:**

1. Extend `tests/unit/Connect/ConnectFlowStandaloneTest.php` so every queued success response carries the real `{"data": …}` envelope, and assert:
   - a `200` token exchange whose body is `{"data":{"api_key":…,"site_id":…,"account":{…},"subscription":{…}}}` stores the credential and returns `connected === true`;
   - a `200` status response returns `subscription`, `wallet` and `site` at the **top level** of the snapshot, and no `data` key survives;
   - a `200` success whose body has **no** `data` key yields `connect_invalid_response`, and the credential is left untouched;
   - a `204` empty-body disconnect purges the stored credential (`get_api_key() === null`);
   - a `401` disconnect still purges locally.
2. Run and report the real output of:
   - `php tests/unit/Connect/ConnectFlowStandaloneTest.php` — expected: all assertions pass, exit 0
   - `composer phpcs`
   - `composer phpstan`

Do not commit.
