# Agency Pulse Backup — Standalone Server Extraction Plan

> **Status:** Planning — Phase 2 (extract after Phase 1 is live and stable)
> **Last updated:** 2026-03-30
> **Depends on:** `BACKUP_SERVER_IMPLEMENTATION_PLAN.md` (Phase 1 must be complete first)
> **Contract document:** `BACKUP_SERVER_CONTRACT.md`

This document describes how to extract the backup system from the main Agency Pulse Laravel
instance into a standalone service that can be switched live in place without disturbing
operations. The main app continues to handle authentication and entitlement. Everything else
moves to the standalone server.

---

## Architectural Overview

```
WordPress Plugin
       │  JWT Bearer (unchanged)
       ▼
Main App (agency-pulse.com)
  auth.plugin middleware — validates JWT
  BackupGatewayController — issues backup session token
       │  Backup Session Token (short-lived signed JWT)
       ▼
Standalone Backup Server (backup.agency-pulse.com)
  Validates backup session token
  Owns all backup records, revisions, operations
  Owns backup UI
  Owns queue workers
  Signs and delivers outbound operations to WP
```

The WP plugin never knows a separate server exists — it continues to POST to
`/api/plugin/backup/*` on the main app. The main app validates the JWT and proxies
the request to the standalone server, attaching the backup session token. From the WP
plugin's perspective, the API contract is completely unchanged.

---

## The Four Coupling Points and How Each Is Resolved

### 1. JWT Validation (WP → Main App)

**Problem:** JWT validation depends on `PLUGIN_JWT_SECRET` and per-user `plugin_key`
stored in the main app's `users` table.

**Resolution:** JWT validation stays on the main app permanently. It is never moved to
the standalone server. The main app validates the JWT, identifies the user, and then
issues a **Backup Session Token** to the standalone server. The standalone server never
sees a raw WP JWT.

### 2. Purchase Entitlement Check

**Problem:** `check.plugin.purchase:apwp-ab` queries the main app's `orders` and
`gifted_products` tables.

**Resolution:** Entitlement check stays on the main app permanently, alongside JWT
validation. Both checks happen in `BackupGatewayController` before the backup session
token is issued. The standalone server trusts the token — if it is present and valid,
entitlement is confirmed.

### 3. User Site Domain

**Problem:** `plugin_pending_domain` is on the main app's `users` table and is needed
by `OutboundOperationService` to know where to deliver operations.

**Resolution:** The site domain is embedded in the backup session token at issuance time.
The standalone server reads it from the token and caches it on its own user record after
first authentication. It does not call back to the main app for it.

### 4. User Settings (Concurrency, Delay)

**Problem:** `backup_restore_concurrency` and `backup_restore_delay_ms` are planned on
the main app's `users` table.

**Resolution per Phase 1 constraint:** These columns are added to the main app's `users`
table for Phase 1 as planned, but are **read and written exclusively through the backup
system's own service layer** — never referenced directly by any other part of the main
app. When extraction happens, the columns are mirrored to the standalone server's user
store and the main app columns are dropped. See migration strategy below.

---

## Backup Session Token

A short-lived signed JWT issued by the main app and validated by the standalone server.
Both servers share a single symmetric secret: `BACKUP_GATEWAY_SECRET` in `.env` on both
sides.

**Token payload:**

```json
{
  "iss": "agency-pulse-main",
  "aud": "agency-pulse-backup",
  "sub": 123,
  "site_domain": "client-site.com",
  "entitlement": "apwp-ab",
  "iat": 1743200000,
  "exp": 1743200300
}
```

- **Expiry:** 5 minutes. Sufficient for a single API request or UI page load.
- **Signing:** HS256 with `BACKUP_GATEWAY_SECRET`. Both servers have this secret.
- **The standalone server validates:** `iss`, `aud`, `exp`, and signature. Nothing else.
- **For UI access:** token expiry is extended to 60 minutes and a refresh mechanism is
  provided (standalone server calls main app's `/api/internal/backup-token/refresh`
  with a valid unexpired token to get a new one, no user interaction required).

---

## Main App Changes for Extraction

### New: `BackupGatewayController`

Replaces the direct `BackupController` on the main app routes. Sits behind the existing
`auth.plugin` + `check.plugin.purchase:apwp-ab` middleware — no auth change for WP.

**For inbound push requests (WP → Server):**

`BackupGatewayController` issues a backup session token and proxies the full request body
to the standalone server using Laravel's `Http::withToken($token)->post(...)`. Returns the
standalone server's response directly to the WP plugin. The WP plugin sees no difference.

**For UI access (browser → Main App → Standalone):**

When the authenticated user navigates to `/account/backup`, the main app issues a
60-minute backup session token and redirects to `https://backup.agency-pulse.com/auth?token={token}`.
The standalone server validates the token, establishes its own session, and serves the UI.
The user's browser lands directly on the standalone server for all subsequent UI interactions.

### New: `GET /api/internal/backup-token/refresh`

Internal endpoint on the main app. Accepts a valid (non-expired) backup session token,
re-validates it, and returns a new token with a fresh expiry. Used by the standalone
server's UI session refresh logic. Protected by `BACKUP_GATEWAY_SECRET` — not accessible
to WP plugins or end users.

### Route change on main app (extraction day)

Before extraction: routes point to `BackupController` directly.
After extraction: routes point to `BackupGatewayController` which proxies.

This is a one-line change per route in `routes/plugin.php`. No WP plugin changes.
No contract changes. No downtime.

---

## Standalone Server Architecture

A separate Laravel application. Minimal surface area — it only does backup.

### Its own database

Separate MySQL/Postgres instance. Contains:
- `backup_users` — minimal user store: `id`, `main_app_user_id`, `site_domain`,
  `backup_restore_concurrency`, `backup_restore_delay_ms`, `last_seen_at`
- `backup_records` — identical schema to Phase 1
- `backup_revisions` — identical schema to Phase 1
- `backup_operation_log` — identical schema to Phase 1

`backup_users` is populated on first authentication from the backup session token.
`site_domain`, `concurrency`, and `delay` are updated from the token on each
authentication so they stay in sync with the main app without a separate sync job.

### Its own queue workers

`backup-restore` queue runs on the standalone server only. No queue workers for backup
on the main app after extraction.

### Its own UI

All four backup UI pages served from `backup.agency-pulse.com`. No backup UI code
remains on the main app after extraction — the `/account/backup` route on the main app
becomes a redirect that issues the session token and bounces the browser.

### Ed25519 signing keys (per-user, not a global env var)

There is **no global server-level signing key**. The backup system uses the identical
per-user Ed25519 keypair mechanism as mission email webhooks:

- Each user's **encrypted private key** is stored in `users.encrypted_private_key`
  (encrypted via Laravel `Crypt::encryptString()`).
- The matching base64-encoded public key is stored in `users.plugin_key`.
- `WpSigner::postForUser()` decrypts the private key and signs `{timestamp}.{json_body}`
  via `openssl_sign()` (Ed25519 — digest parameter is ignored by OpenSSL for Ed25519).
- WP Pro Connector's `AgencyPulseWP_Verifier` already handles this format without changes.

**What this means for standalone extraction:**

There is no signing key to "move" — the keys live on users, not in `.env`. The standalone
server's `OutboundOperationService` needs access to each user's `encrypted_private_key`
and `plugin_key`. This is satisfied by including both fields in the user data synced
during extraction (see Data Migration Strategy, Step 2). On the standalone server, the
user record holds the same `encrypted_private_key` and `plugin_key` values as the main
app, so `WpSigner::postForUser()` works identically without any WP-side changes.

The only env-level dependency for signing is `APP_KEY` (used by `Crypt::encryptString()`
to encrypt/decrypt the private key). The standalone server must use the **same `APP_KEY`**
as the main app so it can decrypt the private keys it inherits.

---

## Data Migration Strategy (Zero Downtime)

The goal is to move live data from the main app's database to the standalone server's
database without any gap in backup coverage and without requiring WP plugins to
re-register or resend data.

### Step 1 — Shadow write (pre-cutover)

Before extraction go-live, run both the main app's `BackupController` and the
`BackupGatewayController` simultaneously. `BackupGatewayController` is deployed but
inactive (feature-flagged off). The main app continues to handle all requests directly
and write to its own database as normal.

### Step 2 — Bulk export

Run a one-time artisan command (`backup:export-to-standalone`) that:
1. Reads all `backup_records`, `backup_revisions`, and `backup_operation_log` rows from
   the main app's database.
2. POSTs them in batches to an authenticated import endpoint on the standalone server
   (`POST /internal/import/records`, `POST /internal/import/revisions`).
3. The standalone server writes them verbatim, preserving all `remote_id` values, revision
   numbers, and timestamps exactly. No `remote_id` values change — WP plugins continue
   working immediately after cutover.
4. Records the export completion timestamp.

### Step 3 — Delta sync

After the bulk export, a second command (`backup:sync-delta`) runs continuously (or on
a short cron) and syncs any new records/revisions written to the main app's database
since the export timestamp. This closes the window between bulk export and cutover.

### Step 4 — Cutover

1. Enable the `BackupGatewayController` feature flag on the main app (routes now proxy
   to standalone).
2. Disable direct `BackupController` routes on the main app.
3. Run `backup:sync-delta` one final time to catch any writes that landed between the
   last delta sync and the flag flip.
4. The standalone server is now live. All new writes go directly to it.

No WP plugin configuration changes. No `remote_id` changes. No revision counter resets.
Backup coverage is continuous — the delta sync ensures no records are lost in the
transition window.

### Step 5 — Cleanup (post-cutover, deferred)

After confirming the standalone server is stable (recommend 2 weeks):
1. Drop `backup_records`, `backup_revisions`, `backup_operation_log` from the main app's
   database.
2. Drop `backup_restore_concurrency` and `backup_restore_delay_ms` from the main app's
   `users` table.
3. Remove `BackupController`, `BackupService`, `OutboundOperationService`,
   `SendRestoreOperationJob`, `RestoreBatchJob` from the main app codebase.
4. Remove backup UI views and routes from the main app.

---

## What Never Moves (Stays on Main App Forever)

| Component | Reason |
|---|---|
| `auth.plugin` middleware | Owns the JWT secret and user keypairs |
| `check.plugin.purchase:apwp-ab` | Owns orders and entitlement tables |
| `BackupGatewayController` | Thin proxy — no business logic |
| `/api/internal/backup-token/refresh` | Issues tokens — requires user table access |
| `BACKUP_GATEWAY_SECRET` | Shared with standalone but managed here |
| `/account/backup` redirect route | Browser entry point — issues session token |

---

## Phase 1 Construction Constraints

The following constraints must be followed during Phase 1 implementation to ensure
extraction can happen without code rewrites:

1. **All backup business logic lives in `BackupService` and `OutboundOperationService`.**
   No backup logic in controllers. Controllers are thin — validate, call service, return
   response. This makes the service layer portable as-is.

2. **Controllers never reference `Auth::user()` or `$request->user()` directly for
   backup logic.** They receive a resolved `User $user` parameter passed from middleware.
   The gateway proxy passes the user context via the backup session token instead of
   Auth — the service layer never knows the difference.

3. **`user_id` is the only coupling to the main app's `users` table in backup tables.**
   No foreign key constraint is enforced at the database level on `backup_records.user_id`
   (declared as a plain integer column, not a FK). This allows the standalone server to
   use its own `backup_users.main_app_user_id` as the equivalent without schema changes.

4. **`backup_restore_concurrency` and `backup_restore_delay_ms` are read exclusively
   through a `BackupUserSettingsService`** — a single class with two methods:
   `getConcurrency(int $userId): int` and `getDelay(int $userId): int`. In Phase 1 this
   reads from `users`. In the standalone server it reads from `backup_users`. The job
   code never reads these columns directly.

5. **The `backup-restore` queue name is the only queue reference in backup job code.**
   Queue connection config (`BACKUP_QUEUE_CONNECTION`) is read from config, not hardcoded.
   This allows the standalone server to use a different Redis instance without touching
   job code.

6. **All backup routes are grouped under `/api/plugin/backup` with no routes from other
   groups nested inside.** This makes the proxy transparent — `BackupGatewayController`
   forwards `{method} /api/plugin/backup/{everything}` to the standalone server unchanged.

7. **No backup data is written outside the three backup tables.** Nothing is written to
   `users`, `orders`, or any other main app table as a side effect of a backup operation.
   The only exception is reading `plugin_pending_domain` from `users` — read-only, never
   written.

---

## Extraction Checklist (Future)

- [ ] Standalone Laravel app scaffolded at `backup.agency-pulse.com`
- [ ] `BACKUP_GATEWAY_SECRET` added to both `.env` files
- [ ] `BackupGatewayController` implemented on main app (feature-flagged off)
- [ ] `/api/internal/backup-token/refresh` implemented on main app
- [ ] Standalone server validates backup session tokens
- [ ] Standalone server `backup_users` table and population logic implemented
- [ ] `backup:export-to-standalone` artisan command implemented
- [ ] `backup:sync-delta` command implemented
- [ ] Standalone server UI deployed and tested against imported data
- [ ] Cutover executed (feature flag flipped)
- [ ] Delta sync run one final time
- [ ] Two-week stability monitoring period
- [ ] Phase 1 backup code and tables removed from main app
