# Real Production Onboarding — Design

> **SUPERSEDED (2026-08-19):** This PayPal/license-key design is historical only. Current production architecture is Connect/social auth + Stripe checkout + encrypted backend-issued per-site API credentials, using the Cloudflare Worker `/api/*` contract. Do not implement this document.


Audience: AI coding agents first.

Replaces the abandoned mock-checkout demo concept. Keeps the one-click UX shape from the preserved snapshot spec (`refs/wip-snapshot/current-20260811/worktree-license-only-api-configuration:plugins/international-press-zone/CLIENT-PRESENTATION-JOURNEY-SPEC.md`): email+password form, `Register & Start Translating`, ordered state machine with deterministic progress text, idempotency key, server-side-only credentials, atomic encrypted persistence. Discards everything mock/dev-only: fixture backend, deterministic Hebrew fixture, dev shortcut, non-production checkout route.

## Non-negotiable invariants

- Payment processor is PayPal ONLY. Morning (Green Invoice) does invoicing only, NEVER payments.
- Verification uses PayPal SANDBOX only. NEVER live keys. NEVER production smoke transactions. NEVER deploy.
- Browser MUST NEVER receive: license key, backend API credential, account JWT/refresh token.
- Secrets MUST be absent from: localized JS data, HTML, cookies, `localStorage`, browser-visible REST response bodies, console output.
- License key + managed API credential persist ONLY server-side, encrypted (AES-256-GCM authenticated encryption per the licensing-hardening key handling), written atomically before activation is reported.
- Every money/auth mutation is idempotent. Checkout retries reuse the SAME idempotency key and MUST NOT create a second PayPal subscription, rotate credentials, or require credential re-entry.
- Public error messages are fixed safe strings; backend error detail stays in server logs.
- Mock checkout (backend `/v1/onboarding/checkout` + `/v1/onboarding/upgrade`, `requireNonProduction`) is dev-only legacy. Plugin production path MUST NOT use it.

## Target user flow

Entry: WP admin logged in, plugin active, no license, no managed credential. `GET /onboarding/status` → `{needs_onboarding: true, needs_credential_repair: false}`.

Form: `Email Address`, `Password`, primary `Register & Start Translating`, secondary link `I already have an account` (login variant, same state machine from step 2).

One click runs this ordered state machine. Button disabled while running; progress text deterministic:

1. `Creating account…` — plugin REST `POST /onboarding/register` → backend `POST /v1/auth/register`. JWT stored server-side only (existing transient `ipz_onboarding_jwt_<user_id>`, 14 min TTL).
2. `Starting secure checkout…` — SPA creates/reuses UUIDv4 idempotency key in `sessionStorage`; plugin REST `POST /onboarding/paypal-checkout` `{tier_slug, billing_cycle, idempotency_key}` → backend `POST /v1/subscriptions/checkout` with `Idempotency-Key` header → `{approval_url, checkout_session_id}`. Browser receives approval URL + session id only (non-secret).
3. `Waiting for PayPal approval…` — SPA opens `approval_url`; user approves at PayPal. SPA polls plugin REST `GET /onboarding/paypal-session/{id}`.
4. `Activating your license…` — on backend session `retrieved`, plugin server-side receives `license_key` + `api_key` ONCE, persists both encrypted atomically via `ActivationManager`, sets license metadata options, deletes JWT transient. Browser response: `{status: 'active', tier, expires_at}` — no secrets.
5. `Preparing translation…` — SPA applies configuration defaults, clears idempotency key, navigates `#/translations`.

Retry after interruption resumes safely: same idempotency key → same checkout session; poll is repeat-safe; no duplicate subscription.

Error behavior (fixed public strings):
- Registration validation error → stay on form, show backend-safe message.
- Retryable checkout failure → auto-retry once with SAME idempotency key.
- Permanent checkout failure → stay on form, `Activation failed. Try again.`
- Credential persistence failure → never report active, never redirect; status flips `needs_credential_repair: true`; repair path below.

## Backend changes (press-zone-backend/api)

- B1 — Fix checkout mount mismatch. Router in `src/routes/account.ts` declares `/subscriptions/checkout` internally while mounted at `/v1/account` AND `/v1/subscriptions`, so the documented `/v1/subscriptions/checkout` only resolves as `/v1/subscriptions/subscriptions/checkout`. Make `/v1/subscriptions/checkout` + `/v1/subscriptions/checkout-sessions/:id` resolve; keep `/v1/account/...` working.
- B2 — Mint managed API credential on the real path. `GET checkout-sessions/:id` `retrieved` branch currently mints license only (docblock claims ApiKey but code does not). Add `createApiKey` in the SAME transaction; response includes `api_key` exactly once (existing single-use claim via `updateMany` status guard stays).
- B3 — `Idempotency-Key` on `POST /v1/subscriptions/checkout`. Unique nullable column on `CheckoutSession` (Prisma migration). Same key + same user + live session → return the existing session (`approval_url`, `checkout_session_id`), NEVER a second PayPal subscription.
- B4 — Webhook replay-safety fix in `src/utils/webhookIdempotency.ts`: event row is inserted BEFORE the handler runs, so a handler throw marks the event processed and redelivery is swallowed. Record success only after handler completes; redelivery of a failed event MUST reprocess.
- B5 — Credential repair seam: idempotent license-authenticated route (`POST /v1/international/license/credential`) that re-provisions the managed API key for an activated license (revoke-and-replace, never additive accumulation). Used when the plugin persisted the license but lost/never stored the credential.

## Plugin changes (plugins/international-press-zone)

- P1 — Build on the licensing-hardening key handling (branch `wt/licensing-hardening`, commit `ec3c95a0b`): AES-256-GCM, no default key, fail-closed when `AUTH_KEY` missing. Master's copy was clobbered by the OAuth-PKCE rebase (`2d7b31a1e`); gate test `tests/unit/Licensing/LicenseStorageKeyStandaloneTest.php` is red on master. Rule: fetch/rebase before touching `includes/Licensing/`; if the fix has landed on master use it as-is; otherwise port the ActivationManager key handling (merged with the PKCE additions) so the gate is green.
- P2 — Server-side activation in `includes/Translation/OnboardingApi.php`: on paypal-session `retrieved`, persist license key (`ActivationManager`) + managed API credential (`store_api_credential`) atomically BEFORE responding; on persistence failure return error, never mark active.
- P3 — Stop leaking secrets to the browser: remove `license_key` from onboarding REST responses (checkout response and the paypal-session `retrieved` payload merge); responses carry status/tier/expiry only.
- P4 — Remove mock-checkout plugin routes: `/onboarding/checkout` and `/onboarding/upgrade` proxies to backend `requireNonProduction` mock routes. Production path is PayPal only. Update `docs/user_journeys/UJ-010-provision-subscription.md` + `tests/e2e/journeys/UJ-010-*` to the real path.
- P5 — `/onboarding/status` returns `{needs_onboarding, needs_credential_repair, license_status}` from local state: license absent → onboarding; license present + credential absent/expired → repair. Plugin repair endpoint calls B5 server-to-server.
- P6 — SPA `admin/src/pages/onboarding.js`: collapse WELCOME/TIER_SELECT into the single register form (starter/monthly default), implement the state machine + progress text above, idempotency key in `sessionStorage` (cleared only after durable activation), PayPal approval + poll handling (reuse existing PAYPAL_WAITING poll), completion → defaults → `#/translations`. Reuse existing components (`FormField`, `Button`, `ProgressBar`, `Notice`, `Toast`); no new component system; no inline CSS.
- P7 — Plugin `/onboarding/paypal-checkout` accepts `idempotency_key` (UUIDv4, sanitized) and forwards it as the `Idempotency-Key` header.

## Out of scope

- Live PayPal keys, production transactions, any deployment.
- Upgrade/downgrade flows beyond removing the mock upgrade proxy.
- Abandoned Translate/Multilingual plugin trees — NEVER touch.
- E2E mock fixture files from the snapshot spec (`mock_backend.py`, `run-e2e.mjs`) — do not create.

## Gates

- Plugin: `tools/factory-gate.sh all` (php-syntax, phpunit + standalone runner incl. `LicenseStorageKeyStandaloneTest`, phpcs, phpstan, admin lint/build).
- Admin build: `npm --prefix admin run build` (UI changed).
- Backend: Jest (`npm test`), `npm run build` (prisma generate + tsc), Prisma migration applies cleanly.
- Independent adversarial security review on the auth+payment diff: IDOR, credential leakage, idempotency, webhook replay. Fix findings before landing.
- End-to-end: PayPal sandbox + local Podman WordPress ONLY (backend via podman-compose with sandbox `.env`).
