# apwp-ao Multi-Domain License Auth Plan

**Status:** ALL PHASES COMPLETE (1–13 + Phase 9). Laravel implementation finished. WP plugin implementation complete.
**Date:** 2026-06-22 (updated 2026-06-23)
**Companion doc:** `agency-pulse-admin-menu/build-docs/APWP_AO_DOMAIN_SLOT_AUTH.md`

---

## Overview

The `apwp-ao` (Admin Organizer) plugin requires a dedicated authentication key that is entirely separate from the Pro Connector (`plugin_key`). Agencies deploy apwp-ao on client sites; putting their Pro Connector key on a client site would expose full agency account access. The apwp-ao key grants access to apwp-ao premium endpoints only and is safe to distribute to client environments.

Once an apwp-ao key is obtained, the plugin uses a **domain slot system**: each purchased license unit (or active gifted subscription) allows one simultaneously active domain. A gifted apwp-ao subscription is treated identically to a purchased qty-1 license — the user sees and manages it exactly the same way. The agency enters the same apwp-ao key on every client site; each site claims its own slot via an email confirmation flow.

---

## What Is Already Implemented Correctly

| Component | File | Status |
|---|---|---|
| `plugin_ao_domains` table | `database/migrations/2026_06_22_000001_create_plugin_ao_domains_table.php` | Complete |
| `PluginAoDomain` model + User relation | `app/Models/PluginAoDomain.php` | Complete |
| Slot count service | `PluginAccessService::aoSlotAvailability()` / `aoAllowedSlots()` / `aoUsedSlots()` / `purchasedLicenseQty()` | Complete |
| Slot multiplier | `aoAllowedSlots()` — 5 slots per purchased OR gifted license unit | Complete |
| Multi-domain token issue | `PluginAuthController::issueAoMultiDomain()` | Complete — uses `plugin_key` as secret base (Phase 9 will update to `ao_plugin_key`) |
| Multi-domain token refresh | `PluginAuthController::refreshAoMultiDomain()` | Complete — uses `plugin_key` as secret base (Phase 9 will update) |
| Email confirmation handler | `PluginAuthorizationController::handleAoAuthorize()` | Complete — domain-on-fill still missing (Phase 10) |
| Email confirmation mail URL branching | `PluginAuthorizationMail` `$sku` param | Complete |
| `/plugin/ao-authorize` route | `routes/web.php` | Complete |
| Slot release endpoint | `PluginAoDomainController::release()` | Complete |
| `/account/plugin-domains/{id}` DELETE route | `routes/web.php` | Complete |
| Legacy single-domain migration | `issueAoMultiDomain()` Step B | Complete |
| Release cooldown (30 days) | `issueAoMultiDomain()` Step D | Complete |
| Downloads page — Connected Sites section | `resources/views/account/downloads.blade.php` | Complete — key display still missing (Phase 11) |
| Gift/purchase parity in downloads view | `DownloadController` uses `aoAllowedSlots()` | Complete |
| Admin force-release route + handler | `appshell.people.ao_domain.release` / `PeopleController::forceReleaseAoSlot()` | Complete |
| Admin people view — slot table | `resources/views/vendor/vanilo/people/show.blade.php` | Complete — `ao_plugin_key` display still missing (Phase 12) |

---

## What Still Needs to Be Built

### Phase 8 — ao_plugin_key: Column, Generation, and Backward-Compatible Lookup (critical — blocks agency deployment)

Add `ao_plugin_key` and `ao_plugin_key_hash` to the `users` table. Update `PluginAuthController::issue()` to resolve the user by `ao_plugin_key_hash` when `plugin_sku=apwp-ao`, with a fallback to `plugin_key_hash` during the transition period for sites still using the old key. Generate keys for all existing apwp-ao users.

### Phase 9 — Derive licenseSecret from ao_plugin_key (critical — makes key scoping take full effect)

Update `issueAoMultiDomain()`, `issueAoJwt()`, and `refreshAoMultiDomain()` to derive `licenseSecret` and the JWT `pk` claim from `ao_plugin_key` instead of `plugin_key`. This changes the domain hash derivation, JWT signing, and refresh validation for all apwp-ao tokens.

**Breaking change for existing slots:** Domain hashes stored in `plugin_ao_domains` were computed using `plugin_key`. After Phase 9, new token requests compute them using `ao_plugin_key` — no match. Existing sites (including the known test site) must reconnect after receiving the new `ao_plugin_key` from the account dashboard. Old slot rows are left as audit trail; the reconnection creates new slot rows with the correct hash derivation.

### Phase 10 — Domain Label on Slot Fill

Copy `pending_domain` into `domain_label` in `handleAoAuthorize()` before clearing it. This is required so the account dashboard can show the agency which client domains occupy which slots.

### Phase 11 — Account Dashboard Key Display

Show the `ao_plugin_key` in the account downloads page (apwp-ao section) so the agency can copy it to install on client sites. Include a masked-by-default display with Show/Hide toggle and a Copy button.

### Phase 12 — Admin Panel Key Display and Regenerate

Add `ao_plugin_key` display to the admin People detail view with a Copy button and a Regenerate action. Regeneration generates a new key, invalidates the old one immediately, and all connected client sites receive a 401 on their next token refresh (requiring them to re-enter the new key).

### Phase 13 — Backfill Command and Key Generation Triggers

One-time Artisan command to generate `ao_plugin_key` for all existing users who have apwp-ao access but no key yet. Ongoing: fire key generation whenever apwp-ao access is first granted (order completion or gifted product creation).

---

## Database Changes Required

### Migration: `ao_plugin_key` fields on `users`

```php
Schema::table('users', function (Blueprint $table) {
    $table->text('ao_plugin_key')->nullable()->after('plugin_key');
    $table->string('ao_plugin_key_hash', 64)->nullable()->unique()->after('ao_plugin_key');
});
```

- **`ao_plugin_key`** — 32-byte random hex (64 chars). Shown to user in account dashboard for copy-paste.
- **`ao_plugin_key_hash`** — `hash('sha256', $ao_plugin_key)`. Unique, indexed. Used for O(1) lookup on every token request.

No changes to `plugin_ao_domains` — the table schema is complete and correct.

---

## Code Changes Required

### 1. `PluginAuthController::issue()` — route apwp-ao to separate key lookup

```php
if ($request->input('plugin_sku') === 'apwp-ao') {
    $keyHash = hash('sha256', trim($request->input('plugin_key')));
    $user    = User::where('ao_plugin_key_hash', $keyHash)->first();

    if (! $user) {
        return response()->json(['message' => 'Invalid plugin key', 'error' => 'Invalid plugin key'], 401);
    }

    // Same revoke / cooldown checks as existing path ...

    return $this->issueAoMultiDomain($request, $user);
}

// Pro Connector path — unchanged below
```

### 2. `PluginAuthController` — `ao_plugin_key` as license secret base

Every place `$licenseSecret` is derived currently uses `$user->plugin_key`. For the apwp-ao path it must use `$user->ao_plugin_key` so the JWT signature and domain hashes are independent of the Pro Connector key.

**`issueAoMultiDomain()`** — domain hash derivation:
```php
$licenseSecret  = hash_hmac('sha256', $user->ao_plugin_key, $this->jwtSecret());
$normalizedHost = $this->normalizeDomainHost($pluginDomain ?? '');
$domainHash     = hash_hmac('sha256', $normalizedHost, $licenseSecret);
```

**`issueAoJwt()`** — JWT signing and `pk` claim:
```php
$licenseSecret = hash_hmac('sha256', $user->ao_plugin_key, $this->jwtSecret());
$payload['pk'] = hash_hmac('sha256', $user->ao_plugin_key, $this->jwtSecret());
$jwt           = $this->encodeJwtWithSecret($payload, $licenseSecret);
```

**`refreshAoMultiDomain()`** — signature verification and new JWT:
```php
$licenseSecret = hash_hmac('sha256', $user->ao_plugin_key, $this->jwtSecret());
$expected      = hash_hmac('sha256', $user->ao_plugin_key, $this->jwtSecret());
if (! hash_equals($expected, $payload['pk'])) { /* 401 */ }
if (! $this->verifyJwtWithSecret($token, $licenseSecret)) { /* 401 */ }
// new JWT payload also uses ao_plugin_key-derived pk
```

### 3. `PluginAuthorizationController::handleAoAuthorize()` — domain stored on fill

```php
// Before clearing pending_domain, preserve it for dashboard display
if (empty($slot->domain_label) && ! empty($slot->pending_domain)) {
    $slot->domain_label = $slot->pending_domain;
}

$slot->is_active             = true;
$slot->authorized_at         = Carbon::now();
$slot->auth_token            = null;
$slot->auth_token_expires_at = null;
$slot->pending_domain        = null; // cleared; pending_ip retained for audit
$slot->save();
```

### 4. `DownloadController::index()` — gift parity + key variable

```php
// Replace purchasedLicenseCount() with the unified allowed-slot count
$aoAllowedSlots = PluginAccessService::aoAllowedSlots($user);  // purchased qty + active gifts

$aoActiveSlots = $user->pluginAoDomains()
    ->where('is_active', true)
    ->orderBy('authorized_at')
    ->get(['id', 'domain_label', 'authorized_at', 'last_connected_at']);

$aoPluginKey = $user->ao_plugin_key; // null until key system is built

return view('account.downloads', compact(
    'products', 'aoProduct', 'aoAllowedSlots', 'aoActiveSlots', 'aoPluginKey'
));
```

Remove `$aoLicenseCount` — it is no longer passed to the view. `$aoAllowedSlots` is the single number used everywhere.

### 5. Key generation trigger points

Generate `ao_plugin_key` (if null) whenever apwp-ao access is first granted:
- Order marked completed with a valid apwp-ao payment
- Gifted product record created for `sku=apwp-ao`
- Backfill command for existing users

```php
// Reusable helper (User model or service)
public static function ensureAoPluginKey(User $user): void
{
    if (! empty($user->ao_plugin_key)) {
        return;
    }
    $key = bin2hex(random_bytes(32));
    $user->ao_plugin_key      = $key;
    $user->ao_plugin_key_hash = hash('sha256', $key);
    $user->save();
}
```

### 6. Backfill command: `BackfillAoPluginKeys`

One-time Artisan command. Finds all users who have apwp-ao access (via order or gift) but no `ao_plugin_key` and generates one for each.

---

## Account Downloads Page — apwp-ao Section (Full Design)

This section documents exactly what the `account/downloads.blade.php` apwp-ao card should contain. The gate for all premium content is `$aoAllowedSlots > 0` — this is true for purchased and gifted users identically.

### Card header (always visible)

```
Admin Menu Organizer                                    Free
apwp-ao
Reorganize and customize your WordPress admin menu...
                                      [Download]  [Purchase License]
```

"Free" badge always shows. "Purchase License" button always shows (regardless of whether user already has slots) since more slots can be purchased.

### Plugin Key section (only when `$aoAllowedSlots > 0`)

```
Your apwp-ao Plugin Key
─────────────────────────────────────────────────────
  ████████████████████████████████████   [Show]  [Copy]

  Use this key in the "License & Connection" panel on each WordPress
  site where apwp-ao is installed.
```

- Key is masked by default (show the full 64-char hex as bullet characters `••••••••••••`)
- **Show / Hide** toggle reveals or re-masks the key in place
- **Copy** button copies the raw key to clipboard, briefly changes label to "Copied!"
- No Regenerate button in v1 — if the user needs to rotate their key they contact support or use the admin panel

If `$aoPluginKey` is null (key not yet generated — transitional state before backfill runs):
```
  Your plugin key is being generated. Please refresh the page in a moment.
```

### Connected Sites section (only when `$aoAllowedSlots > 0`)

```
Connected Sites   (2 of 3 slots used)
─────────────────────────────────────────────────────
  clientsite.com         Connected: Jan 5 2026   Last active: 2 hours ago   [Release]
  anotherclient.com      Connected: Mar 12 2026  Last active: 4 days ago    [Release]
─────────────────────────────────────────────────────
  1 slot available
```

- "X of Y slots used" — X = `$aoActiveSlots->count()`, Y = `$aoAllowedSlots`
- Each row shows:
  - **Domain** — `domain_label` (populated from `pending_domain` at confirmation time). Falls back to "Site #N" if null (legacy slots confirmed before the domain-on-fill fix).
  - **Connected** — `authorized_at` formatted as date (e.g. "Jan 5 2026")
  - **Last active** — `last_connected_at` as human diff (e.g. "2 hours ago") or "Never" if null
  - **Release** button — fires `DELETE /account/plugin-domains/{id}`

When no slots are active:
```
  No sites connected yet.
  Install the plugin on a WordPress site, enter your plugin key, and click "Connect This Site".
```

### Release confirmation

On Release click, confirm dialog:
> "Release clientsite.com? The site will lose premium access immediately. You can reconnect it after the 30-day cooldown period."

Include the domain name in the confirm text (from `domain_label`) so the user knows exactly which site they are releasing. After successful release the page reloads.

### Slot count label — gift vs purchase display

No distinction is shown between purchased slots and gifted slots. The user sees only "Y slots available" where Y = `$aoAllowedSlots`. The breakdown (N purchased + M gifted) is an internal accounting detail and is not surfaced to the user.

---

## Admin Panel — apwp-ao Section (Full Design)

The admin `people/show` view already renders a slot table. The following additions are needed:

### Plugin key display (missing)

Add above the slot table in the admin people view:

```
apwp-ao Plugin Key
  [key masked]   [Copy]   [Regenerate]
```

Admin regeneration flow:
1. Admin clicks Regenerate
2. Confirm: "Regenerate this user's apwp-ao key? All connected sites will lose access on their next token refresh and will need to reconnect using the new key."
3. On confirm: generate new key, update `ao_plugin_key` and `ao_plugin_key_hash`, show new key

Admin-side regeneration does NOT invalidate existing `plugin_ao_domains` slot records — the domain hashes are stored permanently for audit. Sites will simply fail on next JWT refresh (because the `pk` claim was derived from the old key) and need to reconnect.

### Slot table (already present, confirm columns)

The table in `admin/people/show.blade.php` already shows:
- Slot ID, domain_label, status (Active / Released), authorized_at, last_connected_at, released_at
- Force-release button (active slots only)

No changes needed to the slot table itself once domain-on-fill is fixed and labels populate correctly.

---

## Auth Flow (complete, corrected)

### Step 1 — Token request from a new domain

`POST /api/plugin/token` with `plugin_key` (apwp-ao key) + `plugin_domain` + `plugin_sku=apwp-ao`

1. Detect `plugin_sku=apwp-ao` — enter apwp-ao path
2. Look up user by `ao_plugin_key_hash` (NOT `plugin_key_hash`)
3. 401 if not found
4. Check `plugin_revoked_at` and `plugin_domain_reset_requested_at` cooldown
5. Derive `licenseSecret = hash_hmac('sha256', $user->ao_plugin_key, $jwtSecret)`
6. Compute `domainHash = hash_hmac('sha256', $normalizedHost, $licenseSecret)`
7. Legacy migration: if `users.plugin_domain_hash` exists but no matching slot row, create one
8. If active slot found for `domainHash` → issue JWT immediately (Step 3)
9. If domain released within 30 days → 403 `slot_release_cooldown`
10. Slot availability via `PluginAccessService::aoSlotAvailability()` (counts purchased + gifted)
11. If no slots available → 403 `domain_limit_reached`
12. Create/refresh pending slot row, send confirmation email → 202

### Step 2 — Email confirmation

`GET /plugin/ao-authorize?token=XXX`

1. Find slot by `auth_token`
2. Verify not expired
3. Copy `pending_domain` → `domain_label` if not already set
4. Set `is_active=true`, `authorized_at=now()`, clear token fields and `pending_domain`
5. Render confirm view

### Step 3 — JWT issue (domain active)

1. Look up slot by `(user_id, domainHash, is_active=true)`
2. Verify `PluginAccessService::userHasAccessToSku($user, 'apwp-ao')` still holds
3. Derive `licenseSecret` from `ao_plugin_key`
4. JWT claims: `sub` (user id), `pk` (hmac of ao_plugin_key), `dh` (domain hash), `sid` (slot id), `exp` (now + 3 min)
5. Update `slot->last_connected_at`

### Step 4 — JWT refresh

`POST /api/plugin/refresh` with `Authorization: Bearer <jwt>`

1. Decode JWT, extract `sub`, `pk`, `sid`, `dh`, verify `exp` within refresh window
2. Load user by `sub`
3. Detect `sid` claim → branch to `refreshAoMultiDomain()`
4. Derive `licenseSecret` from `user->ao_plugin_key`
5. Verify `pk` = `hash_hmac('sha256', $ao_plugin_key, $jwtSecret)`
6. Verify JWT signature with `licenseSecret`
7. Find slot by `id=sid AND user_id=sub AND is_active=true`
8. Verify `dh` matches `slot->domain_hash`
9. Verify purchase/gift still active
10. Issue new JWT, update `last_connected_at`

---

## Slot Count — Purchased + Gifted

`PluginAccessService::aoAllowedSlots()` is implemented and correct:

```
aoAllowedSlots = (purchasedLicenseQty + activeGiftedCount) × 5
```

- `purchasedLicenseQty($user, 'apwp-ao')` — sums `order_items.quantity` across completed, paid orders
- `activeGiftedCount` — number of active `gifted_products` records for `sku=apwp-ao` (within `start_date`/`end_date`)
- Each unit — whether purchased or gifted — grants **5 domain slots**
- The user never sees a breakdown of purchased vs gifted; it is one unified slot pool displayed as "N site slots available"

**Examples:**
- qty=1 purchased → 5 slots
- qty=2 purchased → 10 slots
- qty=1 purchased + 1 active gift → 10 slots
- 2 active gifts (no purchase) → 10 slots
- Expired gift → not counted

---

## Backward Compatibility — Transition Period

One known client site was validated against the old auth model (using `plugin_key` as the credential and as the basis for domain hash derivation and JWT signing). When Phase 8 and 9 are deployed:

1. **Phase 8 only (new lookup, old secret base):** The site continues to work on `plugin_key` via the fallback lookup. No action needed yet.

2. **Phase 9 (new secret base):** The domain hash derivation changes. The site's existing `plugin_ao_domains` slot row has a hash derived from `plugin_key` — the new token request will compute a hash from `ao_plugin_key`, which won't match. The site will receive `401 Invalid plugin key` (if still on old key) or fail to find the active slot (if updated to ao_plugin_key but old slot has old hash).

**Migration path for the test site:**
- Admin generates the user's `ao_plugin_key` (Phase 8 backfill or Phase 12 admin action)
- Admin shares the key with the site operator
- Operator enters the new key in plugin Settings → Connect This Site
- Server creates a new slot row with ao_plugin_key-derived hash
- Old slot row remains as audit trail (can be admin-released or left inactive)

**Fallback removal:** Once all known client sites have reconnected under the new key, the `plugin_key_hash` fallback in `PluginAuthController::issue()` is removed. This is Phase 14 (future, no timeline yet).

---

## Summary of Remaining Files to Create / Modify

| File | Phase | Change |
|---|---|---|
| `database/migrations/YYYY_add_ao_plugin_key_to_users.php` | 8 | New — `ao_plugin_key` (text, nullable) + `ao_plugin_key_hash` (varchar 64, nullable, unique indexed) |
| `app/Models/User.php` | 8 | Add `ensureAoPluginKey()` helper method |
| `app/Http/Controllers/PluginAuthController.php` | 8, 9 | Phase 8: `issue()` routes apwp-ao to `ao_plugin_key_hash` lookup with `plugin_key_hash` fallback. Phase 9: `issueAoMultiDomain()` / `issueAoJwt()` / `refreshAoMultiDomain()` derive `licenseSecret` and `pk` claim from `ao_plugin_key` |
| `app/Http/Controllers/PluginAuthorizationController.php` | 10 | `handleAoAuthorize()`: copy `pending_domain` → `domain_label` before clearing |
| `app/Http/Controllers/DownloadController.php` | 11 | Add `$aoPluginKey = $user->ao_plugin_key` to passed variables |
| `resources/views/account/downloads.blade.php` | 11 | Add key display section (masked, Show/Hide, Copy button) |
| `app/Http/Controllers/Vanilo/Admin/PeopleController.php` | 12 | Pass `$aoPluginKey`; add `regenerateAoPluginKey()` method |
| `routes/web.php` | 12 | Add `POST /admin/people/{user}/ao-plugin-key/regenerate` route |
| `resources/views/vendor/vanilo/people/show.blade.php` | 12 | Add `ao_plugin_key` display with Copy button and Regenerate form |
| `app/Console/Commands/BackfillAoPluginKeys.php` | 13 | New — one-time key generation for all existing apwp-ao users |
| Key generation event listener | 13 | Fire `ensureAoPluginKey()` on order completion and gifted product creation for `sku=apwp-ao` |

---

## Resolved Decisions

1. **Separate key from Pro Connector** — apwp-ao uses `users.ao_plugin_key`. The Pro Connector key never leaves the agency's own environment.

2. **One key per user account** — one `ao_plugin_key` covers all slots the license allows. No per-slot key, no per-site key.

3. **Domain stored on fill** — `pending_domain` copied to `domain_label` in `handleAoAuthorize()` before being cleared. Raw domain URL is shown in dashboard.

4. **Gift = purchase qty 1** — view uses `$aoAllowedSlots` (purchased + gifted). No visual distinction between gift and purchase. Gifted user sees identical UI to a user who bought one license.

5. **Slot release cooldown = 30 days** — same as Pro Connector domain reset cooldown. Confirm dialog shows the domain name and mentions the cooldown period.

6. **No key regenerate in user dashboard (v1)** — key display is read-only for users. Admin can regenerate from the people view. Regeneration does not delete slot records; sites simply fail on next JWT refresh and must reconnect.

7. **Slot release cooldown = 30 days** — already enforced in `issueAoMultiDomain()`.

8. **Legacy single-domain migration** — existing `users.plugin_domain_hash` records auto-migrated to a slot row on first apwp-ao token request under the new path.

9. **`plugin_sku` sent explicitly by WP plugin** — WP plugin sends `plugin_sku=apwp-ao` in the token body; server uses it to branch to the apwp-ao key lookup and slot path.

10. **Admin can force-release slots** — route and controller method already exist and are correct.
