# Agency Pulse Backup — Server Implementation Plan

> **Status:** Planning
> **Last updated:** 2026-03-30
> **Contract document:** `BACKUP_SERVER_CONTRACT.md`
> **WP plugin document:** `BACKUP_WP_PLUGIN_PLAN.md`
> **Extraction plan:** `BACKUP_STANDALONE_EXTRACTION_PLAN.md`
>
> **Important:** This plan is Phase 1 — the backup system built into the main app.
> It must be constructed to allow extraction to a standalone server (Phase 2) as a
> live cutover with no downtime and no WP plugin changes. All construction constraints
> required for that extraction are listed at the bottom of this document.

---

## Phase 1 — Database & Models ✅ COMPLETE

### Migration 1: `backup_records`

One row per entity per WP site (user). Holds the stable identity the server assigns a `remote_id` to.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK → users | Identifies the WP site |
| `entity_type` | enum: invoice, client, product, order, global_config | |
| `remote_id` | string(36) | Server-assigned UUID, returned to WP on first push |
| `entity_wp_id` | int nullable | WP `post_id` where applicable; null for global_config |
| `latest_revision` | int default 0 | Incremented on each accepted push |
| `latest_payload_hash` | string(64) | SHA-256 of last accepted payload — used to skip duplicate pushes |
| `archived` | bool default false | Set true by PATCH archive endpoint or restore callback |
| `archived_at` | timestamp nullable | |
| `created_at`, `updated_at` | | |

Unique index on `(user_id, remote_id)`.

### Migration 2: `backup_revisions`

Append-only. One row per accepted push.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `backup_record_id` | FK → backup_records | |
| `revision` | int | Matches `backup_records.latest_revision` at time of write |
| `payload` | JSON | Full payload body as received |
| `payload_hash` | string(64) | SHA-256 of payload body |
| `triggered_by` | string | From `backup_meta.triggered_by` |
| `backed_up_at` | int | Unix timestamp from `backup_meta.backed_up_at` |
| `created_at` | timestamp | No `updated_at` — append-only |

### Migration 3: `backup_operation_log`

Audit trail for every outbound operation sent to WP and every callback received.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK → users | |
| `backup_record_id` | FK nullable | null for batch operations |
| `operation` | string | restore, archive, restore_batch, etc. |
| `entity_type` | string | |
| `direction` | enum: outbound, callback | |
| `pattern` | enum: a, b | Which delivery pattern was used (outbound only) |
| `payload` | JSON | Operation payload sent or callback received |
| `result` | JSON nullable | WP's synchronous response or null if async |
| `success` | bool nullable | |
| `conflict` | bool default false | |
| `conflict_reason` | string nullable | |
| `expiry` | int | Unix timestamp from operation payload |
| `created_at` | timestamp | |

### Models

- `BackupRecord` — `belongsTo(User)`, `hasMany(BackupRevision)`, `hasMany(BackupOperationLog)`
- `BackupRevision` — `belongsTo(BackupRecord)`, `$timestamps = false` with manual `created_at`
- `BackupOperationLog` — `belongsTo(BackupRecord)`, `$timestamps = false` with manual `created_at`

> **Extraction constraint:** `backup_records.user_id` is declared as a plain `unsignedBigInteger`
> column with **no foreign key constraint** at the database level. This allows the standalone
> server to use its own user store with the same `user_id` values without schema changes.

---

## Phase 2 — Inbound Push (WP → Server) ✅ COMPLETE

### `BackupPushRequest` FormRequest

Validates fields common to all entity types. Uses a `rules()` method that dispatches to per-entity rule sets based on `entity_type`. Override `failedValidation` to always return JSON 422 (same pattern as `SignInvoiceRequest`).

Common rules: `entity_type` in allowed enum, `backup_meta.triggered_by` string, `backup_meta.backed_up_at` integer, `backup_meta.payload_hash` string 64 chars, `backup_meta.remote_id` nullable string.

### `BackupArchiveRequest` FormRequest

Rules: `archived` boolean required, `archived_at` integer required.

### `BackupUserSettingsService`

A dedicated single-responsibility service for reading per-user concurrency and delay
settings. **All job and service code reads these values exclusively through this class —
never directly from the `User` model.** In Phase 1 it reads from the `users` table. When
the standalone server is built it provides its own implementation reading from
`backup_users` with no changes to the job or service code that consumes it.

- `getConcurrency(int $userId): int`
- `getDelay(int $userId): int`

### `BackupService`

**`upsert(User $user, array $data): array`**

1. Compute SHA-256 of incoming payload body.
2. If `backup_meta.remote_id` is present — find existing `BackupRecord` by `(user_id, remote_id)`. If not found, return 422 (unknown remote_id).
3. If no `remote_id` — create new `BackupRecord`, generate UUID for `remote_id`.
4. **Hash dedup check**: if `payload_hash` matches `latest_payload_hash` on the record, skip revision write and return existing `remote_id` + `revision` immediately (idempotent, no duplicate revision).
5. Increment `latest_revision`, write `BackupRevision`, update `backup_records.latest_payload_hash`.
6. Return `['remote_id' => ..., 'revision' => ...]`.

**`archive(User $user, string $entityType, string $remoteId, array $data): void`**

Find record by `(user_id, remote_id)`, set `archived = true`, `archived_at`.

### `BackupController`

- `push(Request $request, string $entityType)` — validates entity type is in allowed list, delegates to `BackupPushRequest`, calls `BackupService::upsert()`, returns `{success: true, remote_id, revision}`.
- `archive(Request $request, string $entityType, string $remoteId)` — calls `BackupService::archive()`, returns `{success: true}`.
- `operationResult(Request $request)` — receives WP callback after an operation completes. JWT-authenticated. Finds operation log entry, records result, updates `archived` flag on record if operation was `restore` or `archive`.

> **Extraction constraint:** Controllers receive a resolved `int $userId` (not `Auth::user()`)
> passed from a middleware resolver. Business logic never calls `Auth::user()` directly.
> This allows the gateway proxy on the standalone server to inject the user ID from the
> backup session token rather than from Laravel's auth system.

### Routes

Added to `routes/plugin.php` under a new purchase check group:

```php
Route::middleware(['auth.plugin', 'check.plugin.purchase:apwp-ab'])->group(function () {
    // Declare literal paths before wildcards to prevent swallowing
    Route::post('/backup/result',                       [BackupController::class, 'operationResult']);
    Route::post('/backup/batch-result',                 [BackupController::class, 'operationResult']);
    Route::post('/backup/{entity_type}',                [BackupController::class, 'push']);
    Route::patch('/backup/{entity_type}/{remote_id}',   [BackupController::class, 'archive']);
});
```

---

## Phase 3 — Outbound Operation Service (Server → WP) ✅ COMPLETE

### Signing Config

No new signing key, keypair, or config is needed. The backup system uses the **identical
signing mechanism as mission email webhooks** — the existing per-user **Ed25519 keypair**
already registered with the WP Pro Connector.

**How the keypair works:**
- On first plugin authentication, `User::generateKeypair()` generates an Ed25519 keypair
  using `openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_ED25519])`.
- The Ed25519 private key PEM is stored encrypted on the `users` record via
  `Crypt::encryptString()` in `encrypted_private_key`.
- The Ed25519 public key PEM is stored base64-encoded in `plugin_key` and is already
  known to the WP Pro Connector — it is the same key used to verify mission email
  webhook signatures. No WP-side changes are needed.

**How signing works:**
- `WpSigner::postForUser($user, $url, $payload)` decrypts the user's Ed25519 private key
  via `$user->getDecryptedPrivateKey()`, signs `{timestamp}.{json_body}` using
  `openssl_sign()` (Ed25519 via OpenSSL — the digest parameter is ignored by Ed25519;
  OpenSSL handles it natively), and attaches `X-Signature` (base64), `X-Timestamp`,
  and `X-Key-Id` headers.
- The WP Pro Connector's `AgencyPulseWP_Verifier` class already verifies this exact
  header structure and signature format on inbound requests. It will accept backup
  operation payloads signed this way **without any modifications**.

`OutboundOperationService::sendNow()` calls `WpSigner::postForUser($user, $url, $payload)`
directly, targeting `https://{user->plugin_pending_domain}/wp-json/apab/v1/operation`.
No new env vars. No new keys. No new signing logic. Identical to mission webhooks.

### Queued Job Delivery (Pattern A)

All Pattern A outbound operations are dispatched as queued jobs, not executed synchronously. This decouples the admin action from the HTTP request to WP, allows rate-limiting and concurrency control, and provides automatic retry on failure.

**`SendRestoreOperationJob`** — implements `ShouldQueue`

- Holds `int $userId`, `array $operation`, `string $logId` (the pre-written `BackupOperationLog` id).
- `handle()`: resolves `OutboundOperationService`, builds and signs the payload, POSTs to `https://{user->plugin_pending_domain}/wp-json/apab/v1/operation`, records the WP response back onto the `BackupOperationLog` row.
- On failure: marks the log row as `success = false`, stores the exception message in `result`. Uses Laravel's built-in `failed()` hook.
- Retries: max 3 attempts with exponential backoff. If WP returns `payload_expired` the job does not retry — it marks the log as expired and surfaces the outcome for admin review.
- The job is dispatched onto a dedicated queue named **`backup-restore`** so it can be isolated from other queues and given its own worker config.

**Concurrency Limiting**

The concurrency cap is **per-user** (per WP site), not global. Each user configures their own limit through the backup settings UI (see Phase 3a below). The job reads the authenticated user's `backup_restore_concurrency` setting at execution time and uses it as the throttle allow value.

The job uses a Redis throttle keyed per user: `Redis::throttle("backup-restore:{$userId}")->allow($user->backup_restore_concurrency)->everySeconds(1)`. This means two different users' restore jobs never compete with each other — each site's outbound request rate is governed entirely by that site owner's own setting.

**`RestoreBatchJob`** — implements `ShouldQueue`

For `restore_batch` operations. Same queue (`backup-restore`), same concurrency limit. Dispatches one job per page of the batch. Each job POSTs its page's items to WP and records per-item results in the operation log. The next page is not dispatched until the current page's job completes successfully, preventing overlapping batch pages from hitting WP simultaneously.

### `OutboundOperationService`

**`dispatch(User $user, array $operation): void`** (Pattern A — queued)

1. Write a `BackupOperationLog` row immediately with `direction = outbound`, `pattern = a`, `payload`, `success = null` (pending).
2. Dispatch `SendRestoreOperationJob` with the log row ID onto the `backup-restore` queue.
3. Return immediately — the admin action is acknowledged, delivery happens asynchronously.

**`sendNow(User $user, array $operation): array`** (Pattern A — synchronous, internal use only)

Direct HTTP delivery without queuing. Used only by `SendRestoreOperationJob::handle()` itself. Signs the payload and POSTs to WP. Returns WP's JSON response.

**`buildSignedUrl(User $user, array $operation): string`** (Pattern B)

Public method exposing Pattern B URL generation for the admin console without queuing or triggering delivery. Encodes the payload body as base64, then constructs: `https://{site}/wp-json/apab/v1/operation?_payload={b64}&_timestamp={ts}&_signature={b64sig}`. WP's endpoint must accept query-param delivery alongside header delivery. Short expiry (5 min default).

### Signing (shared by both patterns)

Signing uses the existing `WpSigner` service — the same mechanism as mission webhooks.
`WpSigner::postForUser($user, $url, $payload)` decrypts the user's **Ed25519** private key
via `getDecryptedPrivateKey()`, signs `{timestamp}.{json_body}` with `openssl_sign()`
(Ed25519 via OpenSSL — no digest parameter needed; OpenSSL handles it natively), and
attaches `X-Signature`, `X-Timestamp`, and `X-Key-Id` headers. No new signing code needed.

For Pattern B (signed URL), `OutboundOperationService::buildSignedUrl()` replicates the
same `openssl_sign()` call but assembles the signature components as query parameters
(`_payload`, `_timestamp`, `_signature`, `_key_id`) instead of headers. `WpSigner` is not
called directly for Pattern B because it triggers an HTTP POST internally.

> **Note on expiry and queuing:** Pattern A payloads are signed inside
> `OutboundOperationService::sendNow()`, which is called from inside
> `SendRestoreOperationJob::handle()` at execution time, not at dispatch time. This
> ensures `X-Timestamp` is always fresh when the HTTP request reaches WP regardless of
> how long the job waited in the queue.

### Implementation decisions (Phase 3)

**Throttle vs. funnel:** `Redis::throttle("backup-restore:{$userId}")->allow($concurrency)->everySeconds(1)`
is a *rate limit* (max N per second), not a true concurrency lock. For the intended use
case — pacing outbound requests to a shared-hosting WP site — rate limiting is the right
tool: it prevents bursting while still allowing the configured number through each second.
`Redis::funnel()` would provide true concurrency limiting (max N simultaneous), but would
require WP responses to arrive before the next job is allowed to start, introducing
unnecessary coupling to WP response time.

**Payload serialisation in RestoreBatchJob:** The full `$items` list for the batch is stored
in `BackupOperationLog.payload` (set at `dispatchBatch()` time). Each `RestoreBatchJob`
page instance only holds its own page's item slice. When it dispatches the next page it
reads the full list from the batch log's payload. This avoids duplicating the full items
list in every serialised job while keeping the next-page dispatch self-contained.

**Batch cancellation:** Setting `BackupOperationLog.success = false` on the batch log row
before a page job runs acts as the cancel signal. The job checks this flag at the start
of `handle()` and returns without delivering or dispatching further pages. In-flight jobs
(already inside `deliverPage()`) are not interrupted — cancellation takes effect on the
next page boundary.

**`OutboundOperationService` method visibility:** `sendNow()` is intentionally `public` so
that `SendRestoreOperationJob` and `RestoreBatchJob` can both call it via the service
container without needing separate delivery paths. It is not called directly by controllers
or UI actions — all controller-initiated deliveries go through `dispatch()` or `dispatchBatch()`.

**Services config:** `config/services.php` now includes a `backup` key with
`queue_connection` (env: `BACKUP_QUEUE_CONNECTION`, default `redis`) and
`wp_operation_endpoint` (env: `BACKUP_WP_ENDPOINT`, default `/wp-json/apab/v1/operation`).
The endpoint is configurable so the standalone server can point at a different WP REST
namespace without touching job code.

---

## Phase 3a — Per-User Restore Concurrency Settings ✅ COMPLETE

### Migration: add concurrency fields to `users`

| Column | Type | Notes |
|---|---|---|
| `backup_restore_concurrency` | tinyint unsigned, default 2 | Max simultaneous outbound restore requests to this user's WP site |
| `backup_restore_delay_ms` | smallint unsigned, default 0 | Optional fixed delay in milliseconds between consecutive restore jobs for this user. 0 = no delay. |

Default of 2 is conservative — enough to make reasonable progress on a bulk restore without queuing up a backlog of requests against a shared-hosting WP site.

### User Settings UI

A **Backup Settings** card is added to the user's account/dashboard area (authenticated, not admin-only — each user manages their own site's settings). It contains:

| Field | Input | Range | Default | Description |
|---|---|---|---|---|
| **Restore Concurrency** | Number input | 1–10 | 2 | Maximum number of restore requests sent to your WP site at the same time. Lower values reduce load on your server; higher values complete bulk restores faster. |
| **Delay Between Requests (ms)** | Number input | 0–5000 | 0 | Optional pause between consecutive restore requests. Use this if your WP host rate-limits incoming requests. |

The form POSTs to a new authenticated route (e.g. `PATCH /account/backup-settings`) which updates `backup_restore_concurrency` and `backup_restore_delay_ms` on the user record. Standard Laravel validation: `concurrency` integer between 1 and 10, `delay_ms` integer between 0 and 5000.

### Job Integration

`SendRestoreOperationJob::handle()` reads both values from the user record at execution time:

- Uses `backup_restore_concurrency` as the `->allow()` value in the per-user Redis throttle key `backup-restore:{userId}`.
- If `backup_restore_delay_ms > 0`, sleeps for that duration after a successful WP HTTP response before releasing the throttle lock, creating a minimum gap between requests.

This means a user can adjust their settings mid-restore and the change takes effect on the next job execution without restarting any workers.

---

## Phase 3b — User-Facing Backup UI ✅ COMPLETE

### Routes

All routes under `/account/backup`, authenticated (`auth` middleware), no admin requirement.

```
GET  /account/backup                          → Backup Dashboard
GET  /account/backup/records                  → Records list (paginated, filterable)
GET  /account/backup/records/{remote_id}      → Record detail + revision history
GET  /account/backup/operations               → Operations log (paginated, filterable)
GET  /account/backup/operations/{id}/progress → JSON progress endpoint (polled by UI)
PATCH /account/backup/settings                → Save concurrency + delay settings
POST /account/backup/records/{remote_id}/restore           → Dispatch single restore job
POST /account/backup/records/{remote_id}/revisions/{rev}/restore → Dispatch restore for specific revision
POST /account/backup/operations/{id}/cancel   → Flag queued batch jobs to skip
POST /account/backup/operations/{id}/retry    → Re-dispatch a failed or expired operation
```

---

### Page 1 — Backup Dashboard (`/account/backup`)

Landing page. At-a-glance health picture.

**Summary stat cards (top row):**
- Total backup records with entity type breakdown (invoices / clients / products / orders / configs)
- Last backup received — timestamp + entity type
- Pending operations — jobs queued or in-flight
- Failed operations — count with link to operations log; shown in red when non-zero

**Pro Connector status bar:**
Green/red indicator. If red, shows a prompt linking to the connector settings page.

**Recent activity feed:**
Last 10 backup pushes received — entity type, `triggered_by`, timestamp, revision number.

**Settings card (inline — no separate settings page):**
Two fields: Restore Concurrency (1–10, default 2) and Delay Between Requests in ms (0–5000, default 0). Saved via `PATCH /account/backup/settings`. Descriptive help text: "Lower concurrency reduces load on your WordPress server. Use the delay field if your host rate-limits incoming requests."

---

### Page 2 — Backup Records (`/account/backup/records`)

Paginated table of all `backup_records` for this user. 25 records per page.

**Filters:**
- Entity type (all / invoice / client / product / order / global_config)
- Status (all / active / archived)
- Last backed up date range
- Search by WP post ID or remote_id

**Table columns:**

| Column | Notes |
|---|---|
| Entity Type | Colour-coded badge |
| WP ID | `entity_wp_id` — reference only |
| Remote ID | Truncated UUID with clipboard copy button |
| Revision | Latest revision number |
| Last Backed Up | Relative time; full timestamp on hover |
| Status | Active / Archived badge |
| Actions | Revisions (→ detail page), Restore (confirmation modal), Archive |

**Restore confirmation modal:**
Shows entity type, remote_id, and a reminder of the current concurrency/delay settings. Dispatches a `SendRestoreOperationJob` on confirm and redirects to the Operations page filtered to that operation's ID.

---

### Page 3 — Record Detail (`/account/backup/records/{remote_id}`)

**Record header:**
Entity type, remote_id, WP post ID, status (active/archived), total revision count.

**Revisions table:**

| Column | Notes |
|---|---|
| Revision # | |
| Triggered By | `backup_meta.triggered_by` value |
| Backed Up At | Timestamp |
| Payload Hash | Truncated SHA-256 with copy button |
| Actions | "Restore this revision" button |

Clicking "Restore this revision" dispatches `SendRestoreOperationJob` for that revision's payload and redirects to the Operations log filtered to that operation.

---

### Page 4 — Operations Log (`/account/backup/operations`)

Status and progress for all outbound operations.

**Filters:**
- Operation type (restore / archive / restore_batch)
- Status (pending / in-progress / complete / failed / expired)
- Date range

**Table columns:**

| Column | Notes |
|---|---|
| Operation | restore / archive / restore_batch badge |
| Entity Type | |
| Remote ID | Truncated for single ops; "batch" label for restore_batch |
| Pattern | A (queued direct) / B (signed URL) |
| Status | Pending / In Progress / Complete / Failed / Expired badge |
| Dispatched | Job dispatch timestamp |
| Completed | Completion timestamp or — |
| Result | Success / Conflict / Error summary |
| Actions | Retry (failed/expired), View detail (all) |

**Bulk restore progress bar:**
When a `restore_batch` operation is in progress, a prominent card appears at the top of the page showing:
- `X of Y items complete` with percentage progress bar
- Sub-counts: ✓ Success / ⚠ Conflict / ✗ Failed
- Estimated completion based on current throughput and configured concurrency
- Auto-refreshes every 3 seconds by polling `GET /account/backup/operations/{id}/progress` (returns JSON — no websockets needed)
- "Cancel remaining" button flags queued batch jobs to skip; does not interrupt in-flight jobs

**Single operation detail (expandable row or slide-out panel):**
- Full WP response JSON
- Conflict reason and existing local_id if applicable
- Link to the backup record
- Retry / Cancel buttons where applicable

---

### Navigation

Add a **Backup** item to the user account navigation alongside Downloads and Billing. Show a red dot badge on the nav item when there are failed or expired operations requiring attention.

### Implementation decisions (Phase 3b)

**Nav badge query:** The failed-operations count is computed inline in `navigation.blade.php` with a small `@php` query scoped inside `@auth`. This runs on every authenticated page load. Acceptable for Phase 1 given the low row count per user; if it becomes a concern in Phase 2 it can be moved to a view composer or cached.

**`BackupAccountController` uses `Auth::user()` directly** (no extraction constraint). The account UI controller is not proxied to the standalone server — in Phase 2 the `/account/backup` route becomes a redirect that issues a session token and bounces the browser to `backup.agency-pulse.com`. The extraction constraint (use `Auth::id()` only) applies only to `BackupController` (the API routes at `/api/plugin/backup/*`).

**Batch progress polling** uses vanilla `fetch()` with `setInterval(3000)` — no websockets, no pusher, no additional package dependency. The polling stops automatically when `is_complete` is returned.

**Batch cancellation** sets `BackupOperationLog.success = false` on the parent batch log row. `RestoreBatchJob` checks this flag at the start of each page's `handle()` call, so cancellation takes effect at the next page boundary (not mid-page).

**`retryOperation` re-dispatches from the original log's `payload`** without creating a new top-level record for the original. This is intentional: a retry is a new `BackupOperationLog` row dispatched with the same operation payload, visible as its own row in the operations log.

---

## Phase 4 — Admin Console Hooks ✅ COMPLETE

Internal server concerns not defined by the contract, needed to drive the outbound service from a management UI. Deferred until the management console is defined.

- `BackupService::getRevisions(int $userId, string $remoteId)` — returns all revisions newest-first.
- `BackupService::getLatestPayload(int $userId, string $remoteId)` — returns latest revision payload array, or null if none exists.
- `BackupAdminController` (behind `auth` + `is_admin`) — records list, revision history, operations log, Pattern A restore/archive dispatch, Pattern B signed URL generation (JSON response, copyable in browser).

### Routes (admin prefix `/admin/backup/users/{user}`)

| Method | Path | Name | Action |
|--------|------|------|--------|
| GET | `/records` | `admin.backup.records` | Records list (paginated, filterable) |
| GET | `/records/{remoteId}` | `admin.backup.revisions` | Revision history |
| GET | `/operations` | `admin.backup.operations` | Operations log |
| POST | `/records/{remoteId}/restore` | `admin.backup.restore` | Dispatch restore (latest rev) |
| POST | `/records/{remoteId}/revisions/{n}/restore` | `admin.backup.restore.revision` | Dispatch restore (specific rev) |
| POST | `/records/{remoteId}/archive` | `admin.backup.archive` | Dispatch archive |
| POST | `/records/{remoteId}/signed-url` | `admin.backup.signed-url` | Generate Pattern B signed URL (JSON) |

### Implementation decisions (Phase 4)

**Signed URL as JSON response:** `signedUrl()` returns JSON rather than redirecting so the admin UI can display it in a modal copy-box without a full page reload. The view makes a `fetch()` POST and opens a Bootstrap modal. This avoids exposing the signed URL in the browser's address bar or HTTP referrer headers.

**TTL clamped 60s–3600s:** The admin can pass `ttl_seconds` in the request body. The value is clamped server-side to between 60 seconds and 1 hour regardless of what is submitted.

**Admin views extend `appshell::layouts.private`** (Bootstrap 4) matching the rest of the admin panel, while the user-facing backup views use `x-app-layout` (Tailwind). This is intentional — the admin panel has its own layout system separate from the customer-facing app.

---

## Phase 5 — SKU & Entitlement ✅ COMPLETE

Add `apwp-ab` (Agency Pulse Auto Backup) as a product SKU in the database (product and sku created - done). No code change needed for the entitlement check — `check.plugin.purchase:apwp-ab` uses the existing `PluginAccessService`. Add `apwp-ab` to the downloads page in `DownloadController::$productSkus`.

---

## Implementation Order

| Step | Phase | Dependency |
|---|---|---|
| 1 | Migrations + Models (backup_records, backup_revisions, backup_operation_log) | None |
| 2 | Push endpoints + `BackupService` | Step 1 |
| 3 | `operationResult` callback receiver | Step 1 (can be stubbed initially) |
| 4 ✅ | `OutboundOperationService` + Ed25519 signing (`sendNow` + `buildSignedUrl`) | Step 1 |
| 5 ✅ | users migration: `backup_restore_concurrency` + `backup_restore_delay_ms` | None |
| 6 ✅ | `SendRestoreOperationJob` + `backup-restore` queue + per-user Redis throttle | Steps 4, 5 |
| 7 ✅ | `RestoreBatchJob` | Step 6 |
| 8 ✅ | Backup Dashboard + Settings card (`/account/backup`) | Steps 1, 5 |
| 9 ✅ | Backup Records list + Record detail pages | Steps 1, 6 |
| 10 ✅ | Operations Log + progress polling endpoint | Steps 6, 7 |
| 11 ✅ | Nav badge (failed operations indicator) | Step 10 |
| 12 ✅ | SKU + entitlement wiring | Any time |
| 13 ✅ | Admin console hooks | Steps 1–10 complete |

### Queue Worker Config Note

The `backup-restore` queue must be run with `--queue=backup-restore` on a dedicated worker. The concurrency cap is enforced at the job level via the per-user Redis throttle, so the worker process count does not need to match any single user's concurrency setting — multiple workers can pull from the queue and the per-user throttle will serialise each site's requests to its configured cap automatically.

The queue connection is read from `config('services.backup.queue_connection')` (env: `BACKUP_QUEUE_CONNECTION`, default `redis`). Never hardcoded. The standalone server can point this at a different Redis instance without touching job code.

---

## Phase 1 Construction Constraints for Extraction

The following rules must be followed throughout Phase 1 to ensure a live cutover to the
standalone server (Phase 2) can happen without code rewrites, WP plugin changes, or downtime.
See `BACKUP_STANDALONE_EXTRACTION_PLAN.md` for the full extraction plan.

1. **All backup business logic lives in `BackupService` and `OutboundOperationService`.**
   Controllers are thin — validate input, call service, return response. No business logic
   in controllers or routes.

2. **Controllers never call `Auth::user()` directly.** They receive a resolved `int $userId`
   passed from middleware. This allows the standalone server's gateway to inject the user ID
   from a backup session token rather than from Laravel's auth system, with no changes to
   controller or service code.

3. **`backup_records.user_id` has no database-level foreign key constraint.** Declared as
   a plain `unsignedBigInteger`. The standalone server uses its own user store with the same
   `user_id` values and cannot satisfy a FK to the main app's `users` table.

4. **All user setting reads go through `BackupUserSettingsService`.** Jobs and services
   never read `backup_restore_concurrency` or `backup_restore_delay_ms` directly from the
   `User` model. In Phase 2 this class is re-implemented against the standalone `backup_users`
   table with no changes to consumers.

5. **Queue connection is config-driven via `BACKUP_QUEUE_CONNECTION`.** Never hardcoded.

6. **All backup routes are grouped under `/api/plugin/backup` with no other routes nested
   inside.** The gateway proxy forwards `{method} /api/plugin/backup/{everything}` verbatim.

7. **No backup operation writes to any table outside the three backup tables.** Side effects
   on `users` or any other main app table are forbidden. Reading `plugin_pending_domain`
   from `users` is permitted (read-only).
