# apwp-ao Multi-Domain Auth — Laravel Implementation Phases

**Status:** ALL PHASES COMPLETE — Phases 1–13 implemented and validated. Phase 14 (remove fallback) is future work after all client sites migrate.
**Date:** 2026-06-22 (updated 2026-06-23)
**Design doc:** `build-docs/apwp-ao-multi-domain-auth-plan.md`

Resolved decisions driving all phases:
- `plugin_sku=apwp-ao` sent explicitly by the WP plugin in the token request body
- `domain_label` stored per slot — written from `pending_domain` at confirmation time (Phase 10 fixes the gap)
- Each purchased OR gifted apwp-ao license unit grants **5 domain slots** (not 1)
- Existing users with `plugin_domain_hash` migrated to slot row on first new-path token request
- Slot release cooldown: 30 days, enforced per domain hash
- Admin panel: slot table in People detail view (done); key display + regenerate (Phase 12)
- Downloads page: Connected Sites section + key display (Phase 11 fills the key gap)
- Separate `ao_plugin_key` per user — scoped to apwp-ao only, safe for client sites (Phases 8–9)
- Backward compat: `plugin_key_hash` fallback lookup remains during transition, removed as Phase 14

---

## Phase 1 — Database & Model ✓ COMPLETE

**Goal:** Get the `plugin_ao_domains` table and Eloquent model in place. Nothing else changes yet.

### 1.1 Migration

File: `database/migrations/YYYY_MM_DD_000001_create_plugin_ao_domains_table.php`

```php
Schema::create('plugin_ao_domains', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('user_id');
    $table->string('domain_hash', 128);
    $table->string('domain_label', 191)->nullable();   // display-safe label sent by WP plugin
    $table->string('pending_domain', 255)->nullable(); // raw domain before confirm, cleared after
    $table->string('pending_ip', 45)->nullable();
    $table->string('auth_token', 128)->nullable()->unique();
    $table->timestamp('auth_token_expires_at')->nullable();
    $table->timestamp('authorized_at')->nullable();
    $table->timestamp('last_connected_at')->nullable();
    $table->boolean('is_active')->default(false);
    $table->timestamp('released_at')->nullable();      // when slot was freed by user
    $table->timestamps();

    $table->unique(['user_id', 'domain_hash']);
    $table->index(['user_id', 'is_active']);
    $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
```

### 1.2 Model

File: `app/Models/PluginAoDomain.php`

```php
class PluginAoDomain extends Model
{
    protected $fillable = [
        'user_id', 'domain_hash', 'domain_label',
        'pending_domain', 'pending_ip',
        'auth_token', 'auth_token_expires_at',
        'authorized_at', 'last_connected_at',
        'is_active', 'released_at',
    ];

    protected $casts = [
        'auth_token_expires_at' => 'datetime',
        'authorized_at'         => 'datetime',
        'last_connected_at'     => 'datetime',
        'released_at'           => 'datetime',
        'is_active'             => 'boolean',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    // Returns true when this slot is within the 30-day release cooldown
    public function isInReleaseCooldown(): bool
    {
        return $this->released_at !== null
            && $this->released_at->diffInDays(now()) < 30;
    }
}
```

### 1.3 Add relation to User model

In `app/Models/User.php`, add:
```php
public function pluginAoDomains(): HasMany
{
    return $this->hasMany(PluginAoDomain::class);
}
```

### Completion check
- `php artisan migrate` runs without error
- `PluginAoDomain::count()` returns 0 with no exceptions
- No existing functionality changes

---

## Phase 2 — Slot Count Service ✓ COMPLETE

**Goal:** Centralize the logic that computes how many domain slots a user is allowed (purchased + gifted) and how many are currently used. This is called in the token issue path and in the UI.

### 2.1 Extend `PluginAccessService`

Add two static methods to `app/Services/PluginAccessService.php`:

**`aoAllowedSlots(User $user): int`**
- Calls the existing `purchasedLicenseCount`-equivalent query (already in `DownloadController`) to sum `order_items.quantity` for completed, paid `apwp-ao` orders
- Additionally checks `gifted_products` for active `apwp-ao` gifts — each active gift adds exactly **1** slot
- Returns the total (purchased qty + number of active gifts)
- Returns 0 if no valid orders and no active gifts

**`aoUsedSlots(User $user): int`**
- Counts `plugin_ao_domains` rows where `user_id = $user->id AND is_active = true`
- Returns count

**`aoSlotAvailability(User $user): array`**
- Returns `['allowed' => int, 'used' => int, 'available' => int]`
- One call site for both pieces of data together

### 2.2 Extract `purchasedLicenseCount` query

`DownloadController::purchasedLicenseCount()` is currently a protected instance method. Extract the core query into `PluginAccessService::purchasedLicenseQty(User $user, string $sku): int` so it is reusable without the controller. `DownloadController` then calls the service method internally.

### Completion check ✓
- `aoAllowedSlots()` returns `(purchasedQty + giftedCount) × 5` — each unit (purchased or gifted) = 5 slots
- `aoUsedSlots()` counts active `plugin_ao_domains` rows
- `aoSlotAvailability()` returns correct `allowed / used / available` with floor at 0
- `DownloadController` uses `aoAllowedSlots()` as the single slot count for the view
- `$aoLicenseCount` variable removed; replaced by `$aoLicenseCount = aoAllowedSlots()` (same variable name preserved in view for now, value is now total slots not license count)

---

## Phase 3 — Token Issue: Multi-Domain Branch ✓ COMPLETE (secret base will be updated in Phase 9)

**Goal:** Extend `PluginAuthController::issue()` so that when `plugin_sku === 'apwp-ao'` is present in the request, it enters the new multi-domain path instead of the existing single-domain path.

### 3.1 Request validation

Add `plugin_sku` and `domain_label` to the validation block in `issue()`:
```php
$request->validate([
    'plugin_key'    => 'required|string',
    'plugin_domain' => 'nullable|string',
    'plugin_sku'    => 'nullable|string|max:32',
    'domain_label'  => 'nullable|string|max:191',
]);
```

### 3.2 Branch point

After the existing user lookup, revoke check, and cooldown check — add:
```php
if ($request->input('plugin_sku') === 'apwp-ao') {
    return $this->issueAoMultiDomain($request, $user);
}
```

All existing single-domain logic below that point is untouched.

### 3.3 New private method: `issueAoMultiDomain(Request $request, User $user)`

Implements the 6-step logic from the design doc:

**Step A — Compute domain hash**
```
$licenseSecret = hash_hmac('sha256', $user->plugin_key, $this->jwtSecret());
$normalizedHost = $this->normalizeDomainHost($pluginDomain);
$domainHash = hash_hmac('sha256', $normalizedHost, $licenseSecret);
```
(Reuses the existing private `normalizeDomainHost()` method.)

**Step B — Legacy migration**
Check if the user has `plugin_domain_hash` set and no active slots yet:
```php
$hasSlots = $user->pluginAoDomains()->where('is_active', true)->exists();
if (! $hasSlots && ! empty($user->plugin_domain_hash)) {
    PluginAoDomain::create([
        'user_id'       => $user->id,
        'domain_hash'   => $user->plugin_domain_hash,
        'domain_label'  => null,
        'is_active'     => true,
        'authorized_at' => $user->plugin_authorized_at ?? now(),
    ]);
}
```
This runs once automatically for any existing apwp-ao user on their first new-path request.

**Step C — Check if this domain already has an active slot**
```php
$activeSlot = $user->pluginAoDomains()
    ->where('domain_hash', $domainHash)
    ->where('is_active', true)
    ->first();
if ($activeSlot) {
    // → proceed directly to Step F (issue token)
}
```

**Step D — Release cooldown check**
Check for a released row for this same `domain_hash` within 30 days:
```php
$recentlyReleased = $user->pluginAoDomains()
    ->where('domain_hash', $domainHash)
    ->whereNotNull('released_at')
    ->where('released_at', '>=', now()->subDays(30))
    ->exists();
if ($recentlyReleased) {
    return response()->json([
        'message' => 'Domain slot release cooldown in effect',
        'error'   => 'slot_release_cooldown',
    ], 403);
}
```

**Step E — Slot availability check**
```php
$availability = PluginAccessService::aoSlotAvailability($user);
if ($availability['available'] <= 0) {
    return response()->json([
        'message'       => 'Domain limit reached',
        'error'         => 'domain_limit_reached',
        'slots_used'    => $availability['used'],
        'slots_allowed' => $availability['allowed'],
    ], 403);
}
```

**Step F — Find or create pending auth row, send email**
```php
$pendingSlot = $user->pluginAoDomains()
    ->where('domain_hash', $domainHash)
    ->where('is_active', false)
    ->whereNull('authorized_at')
    ->first();

if ($pendingSlot && $pendingSlot->auth_token_expires_at?->isFuture()) {
    // reuse existing pending slot — resend email with existing token
    $token = $pendingSlot->auth_token;
    $expiresAt = $pendingSlot->auth_token_expires_at;
} else {
    $token = bin2hex(random_bytes(32));
    $expiresAt = now()->addHours(24);

    if ($pendingSlot) {
        // refresh expired pending row
        $pendingSlot->update([
            'auth_token'            => $token,
            'auth_token_expires_at' => $expiresAt,
            'pending_domain'        => $pluginDomain,
            'pending_ip'            => $request->ip(),
            'domain_label'          => $request->input('domain_label'),
        ]);
    } else {
        // new slot row
        PluginAoDomain::create([
            'user_id'               => $user->id,
            'domain_hash'           => $domainHash,
            'domain_label'          => $request->input('domain_label'),
            'pending_domain'        => $pluginDomain,
            'pending_ip'            => $request->ip(),
            'auth_token'            => $token,
            'auth_token_expires_at' => $expiresAt,
            'is_active'             => false,
        ]);
    }
}

// Send email — reuse PluginAuthorizationMail with ao-authorize URL
// (see Phase 5 for the URL change)
Mail::to($user->email)->send(
    new PluginAuthorizationMail($user, $token, $pluginDomain, $request->ip(), $expiresAt, 'apwp-ao')
);
return response()->json(['message' => 'Authorization email sent'], 202);
```

**Step G — Issue JWT for active slot**
Called when an active slot was found in Step C:
```php
$now = time();
$payload = [
    'iss'             => url('/'),
    'sub'             => $user->id,
    'pk'              => hash_hmac('sha256', $user->plugin_key, $this->jwtSecret()),
    'dh'              => $domainHash,
    'sid'             => $activeSlot->id,   // new claim: slot_id
    'ver'             => $request->input('plugin_version'),
    'iat'             => $now,
    'exp'             => $now + 180,        // 3 minutes, same as existing
    'reissue_allowed' => true,
];
$licenseSecret = hash_hmac('sha256', $user->plugin_key, $this->jwtSecret());
$token = $this->encodeJwtWithSecret($payload, $licenseSecret);

$activeSlot->update(['last_connected_at' => now()]);

return response()->json([
    'message'      => 'Token issued',
    'access_token' => $token,
    'expires_at'   => $now + 180,
]);
```

### 3.4 Verify `check.plugin.purchase:apwp-ao` still fires

The `ao-backup` and other apwp-ao API endpoints already apply `check.plugin.purchase:apwp-ao` middleware. No change needed there — that middleware runs after the JWT middleware validates the token, so it still gates access correctly.

### Completion check
- `POST /api/plugin/token` with `plugin_sku=apwp-ao` and a valid key for a user with no slots returns `202`
- A second call with the same key and domain (pending slot exists, token not expired) returns `202` without creating a duplicate row
- A call with `slots_used >= slots_allowed` returns `403 domain_limit_reached`
- A call without `plugin_sku` still follows the old single-domain path — existing behavior unchanged
- Existing tests for other plugins (`apwp-mc`, etc.) remain green

---

## Phase 4 — Token Refresh: Multi-Domain Branch ✓ COMPLETE (secret base will be updated in Phase 9)

**Goal:** Extend `PluginAuthController::refresh()` to validate the `sid` (slot_id) claim for apwp-ao JWTs, replacing the `plugin_domain_hash` lookup on the user row.

### 4.1 Extract `sid` from JWT payload

In `refresh()`, after decoding the payload and verifying the basic structure, check for the `sid` claim:
```php
$isAoMultiDomain = isset($payload['sid']) && is_numeric($payload['sid']);
if ($isAoMultiDomain) {
    return $this->refreshAoMultiDomain($token, $payload, $user);
}
// existing single-domain refresh logic continues below unchanged
```

### 4.2 New private method: `refreshAoMultiDomain(string $jwt, array $payload, User $user)`

1. Verify JWT signature (existing `verifyJwtWithSecret()` — unchanged)
2. Look up slot:
   ```php
   $slot = PluginAoDomain::where('id', $payload['sid'])
       ->where('user_id', $user->id)
       ->where('is_active', true)
       ->first();
   if (! $slot) {
       return response()->json(['message' => 'Slot not found or inactive', 'error' => 'slot_inactive'], 403);
   }
   ```
3. Validate `dh` claim against slot's stored `domain_hash`:
   ```php
   if (! hash_equals($slot->domain_hash, $payload['dh'] ?? '')) {
       return response()->json(['message' => 'Domain hash mismatch', 'error' => 'dh_mismatch'], 403);
   }
   ```
4. Verify `check.plugin.purchase:apwp-ao` is still valid via `PluginAccessService::userHasAccessToSku($user, 'apwp-ao')`. If not, return `403`.
5. Issue new JWT with same `sid` and `dh`, updated `iat`/`exp`.
6. Update `$slot->last_connected_at = now()` (not `$user->plugin_last_connected_at`).
7. Return token.

### Completion check
- A valid apwp-ao JWT can be refreshed and returns a new JWT with the same `sid`
- A JWT with `sid` pointing to a released or non-existent slot returns `403 slot_inactive`
- A JWT without `sid` (old single-domain apwp-ao or other plugins) still follows the existing refresh path
- Existing token refresh tests remain green

---

## Phase 5 — Email Confirmation Route ✓ COMPLETE

**Goal:** Provide the `GET /plugin/ao-authorize?token=XXX` route that activates a pending domain slot when the user clicks the confirmation email link.

### 5.1 Extend `PluginAuthorizationMail`

The existing mail uses `'/plugin/authorize?token='` for the URL. Add an optional `$sku` parameter:
```php
public function __construct($user, string $token, $pluginDomain, $pluginIp, $expiresAt, string $sku = '')
{
    ...
    $this->sku = $sku;
}

public function build()
{
    $path = $this->sku === 'apwp-ao' ? '/plugin/ao-authorize' : '/plugin/authorize';
    $authorizeUrl = rtrim(config('app.url'), '/') . $path . '?token=' . urlencode($this->token);
    ...
}
```

This keeps one mail class with minimal branching — the view templates can remain the same since the confirmation message is identical.

### 5.2 Add `handleAoAuthorize()` to `PluginAuthorizationController`

```php
public function handleAoAuthorize(Request $request)
{
    $token = $request->query('token');
    if (empty($token)) {
        return response()->view('plugin.authorize_error', ['message' => 'Missing token'], 400);
    }

    $slot = PluginAoDomain::where('auth_token', $token)->first();
    if (! $slot) {
        return response()->view('plugin.authorize_error', ['message' => 'Invalid token'], 404);
    }

    if (empty($slot->auth_token_expires_at) || $slot->auth_token_expires_at->isPast()) {
        return response()->view('plugin.authorize_error', ['message' => 'Token expired'], 400);
    }

    $slot->update([
        'is_active'             => true,
        'authorized_at'         => now(),
        'auth_token'            => null,
        'auth_token_expires_at' => null,
        'pending_domain'        => null,   // cleared post-confirm (pending_ip retained for audit)
    ]);

    Log::info('apwp-ao domain slot authorized', [
        'slot_id' => $slot->id,
        'user_id' => $slot->user_id,
        'ip'      => $request->ip(),
    ]);

    return view('plugin.authorize_confirm', ['user' => $slot->user]);
}
```

### 5.3 Register the route

In `routes/web.php`, alongside the existing `/plugin/authorize` route:
```php
Route::get('/plugin/ao-authorize', [PluginAuthorizationController::class, 'handleAoAuthorize'])
    ->name('plugin.ao_authorize');
```

### Completion check
- Visiting `/plugin/ao-authorize?token=<valid>` sets `is_active = true` and shows the confirm view
- Visiting with an expired or non-existent token shows the error view
- The existing `/plugin/authorize` route and single-domain flow are completely unaffected
- Email sent during Phase 3 contains `/plugin/ao-authorize?token=...` URL when `plugin_sku=apwp-ao`

---

## Phase 6 — User-Facing Slot Management ✓ COMPLETE

**Goal:** Let users see their active domain slots on the Downloads page and release individual slots.

### 6.1 New controller: `PluginAoDomainController`

File: `app/Http/Controllers/PluginAoDomainController.php`

**`release(Request $request, int $id): JsonResponse`**
- Auth: `auth` middleware, user must own the slot
- Fetch slot: `PluginAoDomain::where('id', $id)->where('user_id', auth()->id())->firstOrFail()`
- Guard: slot must be `is_active = true`
- Guard: if `released_at` is within last 30 days, return `409 slot_release_cooldown`  (per-slot; prevents someone releasing then immediately freeing the same slot — but a fresh new slot for the same domain is blocked by the cooldown in the issue path)
- Action: `$slot->update(['is_active' => false, 'released_at' => now()])`
- Return: `200 {"message": "Slot released"}`

### 6.2 Register routes

In `routes/web.php`, inside the authenticated `auth` middleware group:
```php
Route::delete('/account/plugin-domains/{id}', [PluginAoDomainController::class, 'release'])
    ->name('account.plugin_domains.release');
```

No separate list route is needed — the slots are rendered inline in the existing downloads view.

### 6.3 Update `DownloadController::index()`

Pass the user's active apwp-ao domain slots to the view:
```php
$aoActiveSlots = auth()->user()
    ->pluginAoDomains()
    ->where('is_active', true)
    ->orderBy('authorized_at')
    ->get(['id', 'domain_label', 'authorized_at', 'last_connected_at']);
```
Pass as `compact('products', 'aoProduct', 'aoLicenseCount', 'aoActiveSlots')`.

### 6.4 Update `resources/views/account/downloads.blade.php`

In the apwp-ao section (after the existing license count display), add a "Connected Sites" sub-section:

```blade
@if($aoLicenseCount > 0 && $aoActiveSlots->isNotEmpty())
    <div class="mt-4">
        <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
            Connected Sites ({{ $aoActiveSlots->count() }} of {{ $aoLicenseCount }})
        </h4>
        <ul class="space-y-2">
            @foreach($aoActiveSlots as $slot)
                <li class="flex items-center justify-between text-sm">
                    <span class="text-gray-800 dark:text-gray-200">
                        {{ $slot->domain_label ?? 'Site #' . $loop->iteration }}
                    </span>
                    <span class="text-xs text-gray-500 mr-4">
                        Last connected: {{ $slot->last_connected_at?->diffForHumans() ?? 'Never' }}
                    </span>
                    <button
                        data-slot-id="{{ $slot->id }}"
                        class="js-release-ao-slot text-xs text-red-600 hover:underline"
                    >Release</button>
                </li>
            @endforeach
        </ul>
    </div>
@endif
```

Add a small inline script (or in `assets/js/account.js`) to handle the release button:
```js
document.querySelectorAll('.js-release-ao-slot').forEach(btn => {
    btn.addEventListener('click', async () => {
        if (!confirm('Release this domain slot? The site will lose premium access until re-authorized.')) return;
        const id = btn.dataset.slotId;
        const res = await fetch(`/account/plugin-domains/${id}`, {
            method: 'DELETE',
            headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
        });
        if (res.ok) location.reload();
        else alert('Could not release slot. Please try again.');
    });
});
```

### Completion check
- Downloads page shows correct slot list for a user with active apwp-ao slots
- "Release" button fires DELETE, slot row gets `is_active = false` and `released_at` set
- Releasing a slot that the authenticated user doesn't own returns 403
- A domain that was released within 30 days cannot re-register (Phase 3 cooldown check catches it)
- Users with 0 purchased licenses see no slot UI

---

## Phase 7 — Admin Panel Extension ✓ COMPLETE (key display added in Phase 12)

**Goal:** Surface apwp-ao domain slots in the existing admin People detail page, with the ability to force-release or view all slot history per user.

### 7.1 Update `PeopleController`

In `app/Http/Controllers/Vanilo/Admin/PeopleController.php`, update `show()` to pass slot data:
```php
$aoDomains = $user->pluginAoDomains()
    ->orderByDesc('is_active')
    ->orderByDesc('authorized_at')
    ->get();
$aoAllowedSlots = PluginAccessService::aoAllowedSlots($user);
```
Pass to view: `compact('user', 'aoDomains', 'aoAllowedSlots', ...)`.

### 7.2 New admin actions in `PeopleController`

**`forceReleaseAoSlot(Request $request, User $user, PluginAoDomain $slot)`**
- Route: `POST /admin/people/{user}/ao-domain/{slot}/release`
- Guard: `is_admin` middleware (already on all admin routes)
- Validates `$slot->user_id === $user->id`
- Sets `is_active = false`, `released_at = now()`
- Logs: `Log::info('Admin force-released apwp-ao slot', ['admin_id' => auth()->id(), 'user_id' => $user->id, 'slot_id' => $slot->id])`
- Redirects back with success flash

### 7.3 Register admin routes

In `routes/web.php`, inside the existing `['auth', 'is_admin']` people admin group:
```php
Route::post('people/{user}/ao-domain/{slot}/release', [PeopleController::class, 'forceReleaseAoSlot'])
    ->name('admin.people.ao_domain.release');
```

### 7.4 Update `resources/views/admin/people/show.blade.php`

Add a new "Admin Menu Organizer Slots" section after the existing plugin domain reset section:

```blade
<section class="mt-6">
    <h3 class="font-semibold text-gray-700">apwp-ao Domain Slots
        <span class="text-sm font-normal text-gray-500">
            ({{ $aoDomains->where('is_active', true)->count() }} active / {{ $aoAllowedSlots }} allowed)
        </span>
    </h3>
    <table class="mt-2 w-full text-sm border">
        <thead>
            <tr class="bg-gray-100 text-left">
                <th class="p-2">ID</th>
                <th class="p-2">Label</th>
                <th class="p-2">Status</th>
                <th class="p-2">Authorized</th>
                <th class="p-2">Last Connected</th>
                <th class="p-2">Released</th>
                <th class="p-2">Actions</th>
            </tr>
        </thead>
        <tbody>
            @foreach($aoDomains as $slot)
                <tr class="border-t">
                    <td class="p-2">{{ $slot->id }}</td>
                    <td class="p-2">{{ $slot->domain_label ?? '—' }}</td>
                    <td class="p-2">
                        @if($slot->is_active)
                            <span class="text-green-600">Active</span>
                        @elseif($slot->released_at)
                            <span class="text-gray-500">Released</span>
                        @else
                            <span class="text-yellow-600">Pending</span>
                        @endif
                    </td>
                    <td class="p-2">{{ $slot->authorized_at?->format('Y-m-d H:i') ?? '—' }}</td>
                    <td class="p-2">{{ $slot->last_connected_at?->diffForHumans() ?? '—' }}</td>
                    <td class="p-2">{{ $slot->released_at?->format('Y-m-d') ?? '—' }}</td>
                    <td class="p-2">
                        @if($slot->is_active)
                            <form method="POST"
                                  action="{{ route('admin.people.ao_domain.release', [$user, $slot]) }}"
                                  onsubmit="return confirm('Force-release this domain slot?')">
                                @csrf
                                <button class="text-red-600 hover:underline text-xs">Force Release</button>
                            </form>
                        @endif
                    </td>
                </tr>
            @endforeach
        </tbody>
    </table>
</section>
```

### Completion check
- Admin People show page displays the domain slot table for a user
- "Force Release" marks the slot inactive with `released_at` set
- The slot count heading shows correct totals
- Non-admin users cannot reach the admin routes (existing `is_admin` middleware)

---

## Dependency Map

```
Phase 1 (DB/Model) ✓
    └── Phase 2 (Slot Count Service) ✓
            ├── Phase 3 (Token Issue Branch) ✓
            │       └── Phase 5 (Email Confirm Route) ✓
            │               └── Phase 6 (User Slot Management) ✓
            │                       └── Phase 7 (Admin Panel) ✓
            └── Phase 4 (Token Refresh Branch) ✓

Phase 8 (ao_plugin_key Column + Lookup) ✓
    └── Phase 9 (ao_plugin_key as Secret Base)
            ├── Phase 10 (Domain Label on Slot Fill)  [independent, can run with Phase 9]
            ├── Phase 11 (Account Dashboard Key Display)
            └── Phase 12 (Admin Panel Key Display + Regenerate)
                    └── Phase 13 (Backfill Command + Generation Triggers)

Phase 14 (Remove plugin_key_hash Fallback) — future, after all sites migrated
```

Phases 8 and 9 must be sequential (9 depends on the column added in 8). Phase 10 is independent and can be deployed any time after Phase 5, but is grouped here for release coherence. Phases 11 and 12 depend on Phase 8 (key must exist before it can be displayed). Phase 13 should run before 11/12 are deployed so users always see a key.

---

## File Checklist

### Phases 1–7 (complete)

| File | Phase | Status |
|---|---|---|
| `database/migrations/2026_06_22_000001_create_plugin_ao_domains_table.php` | 1 | ✓ Done |
| `app/Models/PluginAoDomain.php` | 1 | ✓ Done |
| `app/Models/User.php` | 1 | ✓ Done — `pluginAoDomains()` relation added |
| `app/Services/PluginAccessService.php` | 2 | ✓ Done — slot count methods + 5× multiplier |
| `app/Http/Controllers/DownloadController.php` | 2, 6 | ✓ Done — delegates to service, passes slot variables |
| `app/Http/Controllers/PluginAuthController.php` | 3, 4 | ✓ Done — `issueAoMultiDomain()`, `refreshAoMultiDomain()` (uses `plugin_key` as secret base until Phase 9) |
| `app/Mail/PluginAuthorizationMail.php` | 5 | ✓ Done — `$sku` param branches URL |
| `app/Http/Controllers/PluginAuthorizationController.php` | 5 | ✓ Done — `handleAoAuthorize()` added (domain-on-fill missing until Phase 10) |
| `routes/web.php` | 5, 6, 7, 8 | ✓ Done — ao-authorize, account release, admin release routes |
| `app/Http/Controllers/PluginAoDomainController.php` | 6 | ✓ Done |
| `app/Http/Controllers/Vanilo/Admin/PeopleController.php` | 7 | ✓ Done — slot table data + `forceReleaseAoSlot()` |
| `resources/views/account/downloads.blade.php` | 6 | ✓ Done — Connected Sites section (key display missing until Phase 11) |
| `resources/views/vendor/vanilo/people/show.blade.php` | 7 | ✓ Done — slot table (key display missing until Phase 12) |

### Phases 8–13 (pending)

| File | Phase | New or Modified |
|---|---|---|
| `database/migrations/2026_06_23_000001_add_ao_plugin_key_to_users.php` | 8 | ✓ Done |
| `app/Models/User.php` | 8 | ✓ Done — `ensureAoPluginKey()` added |
| `app/Http/Controllers/PluginAuthController.php` | 8, 9 | ✓ Phase 8 done — dual-path lookup with `lookup_path` logging. Phase 9 pending. |
| `tests/Feature/Phase8Test.php` | 8 | ✓ Done — 5 tests: lookup, fallback, scope isolation, idempotency, hash |
| `app/Http/Controllers/PluginAuthorizationController.php` | 10 | Modified — copy `pending_domain` → `domain_label` on confirm |
| `app/Http/Controllers/DownloadController.php` | 11 | Modified — pass `$aoPluginKey` |
| `resources/views/account/downloads.blade.php` | 11 | Modified — add key display section |
| `app/Http/Controllers/Vanilo/Admin/PeopleController.php` | 12 | Modified — pass `$aoPluginKey`; add `regenerateAoPluginKey()` |
| `routes/web.php` | 12 | Modified — add regenerate key route |
| `resources/views/vendor/vanilo/people/show.blade.php` | 12 | Modified — add key display + regenerate form |
| `app/Console/Commands/BackfillAoPluginKeys.php` | 13 | New |
| Event listener (order/gift) | 13 | New — trigger `ensureAoPluginKey()` |

---

## Testing Notes (per phase)

**Phase 1:** No behavior change — only schema and model. Run `php artisan migrate` and verify table exists.

**Phase 2:** Unit test `PluginAccessService::aoAllowedSlots()` with a seeded user who has:
- 2 purchased licenses → expect 2
- 1 purchased license + 1 active gift → expect 2 (gift adds 1)
- 1 purchased license + 1 expired gift → expect 1

**Phase 3:** Feature test `POST /api/plugin/token` with `plugin_sku=apwp-ao`:
- New domain, slot available → 202, DB row created with `is_active = false`
- Same domain second call, pending still valid → 202, no duplicate row
- Domain already active → 200 with JWT
- Slots full → 403 `domain_limit_reached`
- Domain released within 30 days → 403 `slot_release_cooldown`
- Legacy user with `plugin_domain_hash` → slot auto-created, token issued on same call

**Phase 4:** Feature test `POST /api/plugin/refresh` with an apwp-ao JWT (`sid` claim present):
- Valid JWT, active slot → 200 with new JWT
- Valid JWT, slot released between issue and refresh → 403 `slot_inactive`
- JWT without `sid` → existing refresh path unchanged

**Phase 5:** Feature test `GET /plugin/ao-authorize?token=...`:
- Valid token → slot `is_active` = true, confirm view shown
- Expired token → error view
- Unknown token → error view

**Phase 6:** Feature test authenticated DELETE `/account/plugin-domains/{id}`:
- Own active slot → 200, `is_active` = false, `released_at` set
- Another user's slot → 403
- Non-existent slot → 404

**Phase 7:** Admin panel smoke test — People show page loads without error for a user with apwp-ao slots. Force-release form works.

---

## Phase 8 — ao_plugin_key: Column, Generation, and Backward-Compatible Lookup ✓ COMPLETE

**Goal:** Add the separate apwp-ao credential to the `users` table, auto-generate it for new and existing users, and update `PluginAuthController::issue()` to resolve users by `ao_plugin_key_hash` for apwp-ao requests — with a fallback to `plugin_key_hash` while the known test site is still on the old credential.

### 8.1 Migration

New file: `database/migrations/YYYY_add_ao_plugin_key_to_users.php`

Adds two nullable columns to `users`:
- `ao_plugin_key` — text, nullable. Raw 64-char hex string (32 random bytes). Displayed in account dashboard for copy-paste to client sites. Never used for other plugin SKUs.
- `ao_plugin_key_hash` — varchar(64), nullable, unique index. `hash('sha256', $ao_plugin_key)`. Used for O(1) lookup on every apwp-ao token request.

### 8.2 `User::ensureAoPluginKey()` helper

Static method on the `User` model. Generates `ao_plugin_key` and `ao_plugin_key_hash` if both are null and saves. Idempotent — does nothing if the key already exists. Called from the backfill command, the order completion listener, and the gift creation listener.

### 8.3 `PluginAuthController::issue()` — apwp-ao lookup path

Replace the current apwp-ao branch (which re-uses the master `plugin_key_hash` lookup) with:

1. Hash the incoming `plugin_key` value: `$keyHash = hash('sha256', trim($request->input('plugin_key')))`
2. Try `ao_plugin_key_hash` first: `$user = User::where('ao_plugin_key_hash', $keyHash)->first()`
3. If not found, fall back to `plugin_key_hash` for the transition period: `$user = $user ?? User::where('plugin_key_hash', $keyHash)->first()`
4. If still not found: `401 Invalid plugin key`
5. Continue to revoke/cooldown checks and `issueAoMultiDomain()` as before

The fallback in step 3 allows the known test site to continue operating while it migrates to the new key. Phase 14 removes it.

### Completion check
- `POST /api/plugin/token` with `plugin_sku=apwp-ao` and the user's `ao_plugin_key` value → 200 or 202 (same flow as before, just different lookup)
- Same request with the user's old `plugin_key` still works via fallback → 200 or 202
- Neither key works for non-apwp-ao SKU requests (apwp-ao branch only activated by `plugin_sku`)
- Users without `ao_plugin_key` generated yet still work via fallback

---

## Phase 9 — ao_plugin_key as License Secret Base

**Goal:** Update the three apwp-ao controller methods so that `licenseSecret` is derived from `ao_plugin_key` instead of `plugin_key`. This makes domain hashes and JWTs independent of the Pro Connector key and scoped to apwp-ao only.

### 9.1 `issueAoMultiDomain()` — domain hash derivation

Change:
```
$licenseSecret = hash_hmac('sha256', $user->plugin_key, $this->jwtSecret());
```
To:
```
$licenseSecret = hash_hmac('sha256', $user->ao_plugin_key, $this->jwtSecret());
```

All domain hashes computed after this point use `ao_plugin_key` as the secret base.

### 9.2 `issueAoJwt()` — JWT signing and `pk` claim

Change both occurrences of `$user->plugin_key` in `licenseSecret` and `pk` derivation to `$user->ao_plugin_key`.

The `pk` claim in new JWTs becomes `hash_hmac('sha256', $user->ao_plugin_key, $this->jwtSecret())`.

### 9.3 `refreshAoMultiDomain()` — signature verification and `pk` validation

Change `$user->plugin_key` → `$user->ao_plugin_key` in:
- `$licenseSecret` derivation
- `$expected` (the expected `pk` value)
- New JWT payload `pk` claim

### Breaking change handling

Existing domain slot rows in `plugin_ao_domains` have hashes derived from `plugin_key`. After Phase 9 is deployed, token requests for those slots will compute a different hash (from `ao_plugin_key`) and will not find the existing slot row in Step C of `issueAoMultiDomain()`. The request falls through to the email-confirmation path, treating the domain as a new (unregistered) connection.

**For the known test site:** The site will fail token issuance after Phase 9 deploys (hash mismatch). The operator must:
1. Obtain the new `ao_plugin_key` from the account dashboard (generated in Phase 8/13)
2. Enter it in plugin Settings on the client site
3. Re-connect — new slot row created with the correct hash derivation
4. Old slot row can be admin-released or left as audit trail

Guard: Phase 9 should not be deployed until Phase 13 (backfill) has run and Phase 11/12 (key display) are live, so users can actually obtain their `ao_plugin_key` before the test site goes dark.

### Completion check
- `POST /api/plugin/token` with `ao_plugin_key` and `plugin_sku=apwp-ao` → domain hash computed from `ao_plugin_key` → 202 for new domain, 200 for already-active slot
- JWT `pk` claim = `hash_hmac('sha256', $ao_plugin_key, $jwtSecret)` — verifiable in tinker
- Refresh of a Phase-9-issued JWT → `refreshAoMultiDomain()` validates `pk` against `ao_plugin_key` → 200
- Refresh of a Phase-3/4-issued JWT (old `pk` from `plugin_key`) → `pk` mismatch → 401 (expected — triggers reconnect on client site)
- Existing tests for other SKUs remain green (apwp-ao branch only)

---

## Phase 10 — Domain Label on Slot Fill

**Goal:** Populate `domain_label` from `pending_domain` when the user clicks the email confirmation link, so every authorized slot has a human-readable domain record for the account dashboard.

### 10.1 `PluginAuthorizationController::handleAoAuthorize()`

Before the `$slot->save()` call, add:
```php
if (empty($slot->domain_label) && ! empty($slot->pending_domain)) {
    $slot->domain_label = $slot->pending_domain;
}
```

This runs only when `domain_label` is not already set (WP plugin sends `domain_label` separately; this is the server-side fallback from `pending_domain`). `pending_domain` is still cleared after this assignment.

### Completion check
- Click confirmation link for a slot where WP plugin did not send `domain_label` → `slot->domain_label` is now set to the full `pending_domain` URL
- Click for a slot where WP plugin did send `domain_label` → existing `domain_label` preserved unchanged
- `pending_domain` is `null` after confirmation (unchanged behaviour)
- Admin People slot table now shows domain URLs instead of `—` for all newly confirmed slots

---

## Phase 11 — Account Dashboard Key Display

**Goal:** Show the `ao_plugin_key` in the account downloads page so the agency can copy it to install on client sites.

### 11.1 `DownloadController::index()`

Add `$aoPluginKey = $user->ao_plugin_key` to the variables passed to the view alongside the existing `$aoLicenseCount` and `$aoActiveSlots`.

### 11.2 `resources/views/account/downloads.blade.php`

In the apwp-ao card, add a "Your apwp-ao Plugin Key" section between the description paragraph and the Connected Sites section. Show only when `$aoAllowedSlots > 0`:

- Key displayed as `••••` masked characters by default
- **Show / Hide** button toggles between masked and plain text (JS in-page, no server round-trip)
- **Copy** button copies raw key to clipboard, briefly shows "Copied!"
- If `$aoPluginKey` is null (backfill not yet run): show "Your plugin key is being generated. Refresh this page in a moment." This is a transitional state only.

The key display section precedes the Connected Sites section so the copy-and-paste workflow is top-to-bottom: see key → copy → go to client site → paste.

### Completion check
- User with `ao_plugin_key` set → key display section appears in the apwp-ao card
- User without `ao_plugin_key` (pre-backfill) → "generating" message shown instead of masked key
- User with 0 allowed slots → key section hidden (same gate as Connected Sites)
- Show/Hide toggle works client-side without page reload
- Copy button copies the exact value of `ao_plugin_key`

---

## Phase 12 — Admin Panel Key Display and Regenerate

**Goal:** Surface `ao_plugin_key` in the admin People detail view with a copy button and a regenerate action for support and key rotation scenarios.

### 12.1 `PeopleController::show()`

Pass `$aoPluginKey = $user->ao_plugin_key` alongside the existing `$aoDomains` and `$aoAllowedSlots`.

### 12.2 `PeopleController::regenerateAoPluginKey()`

New action method. Generates a new 32-byte random hex key, saves `ao_plugin_key` and `ao_plugin_key_hash`, logs the event with admin ID. Redirects back with success flash. All connected client sites will fail on their next token issue or refresh (they receive 401 or `pk` mismatch), triggering re-entry of the new key.

### 12.3 Route

In the `appshell.` admin route group:
```
POST admin/people/{user}/ao-plugin-key/regenerate → appshell.people.ao_plugin_key.regenerate
```

### 12.4 `resources/views/vendor/vanilo/people/show.blade.php`

Add a card above the slot table in the apwp-ao section:

- `ao_plugin_key` value displayed (partially masked: first 8 chars, `••••`, last 4 chars)
- **Copy** button
- **Regenerate** button with confirm dialog: "Regenerate this user's apwp-ao key? All connected client sites will lose access on their next token attempt and must be updated with the new key."

If `ao_plugin_key` is null: show "No key generated yet." with a Generate button (same regenerate action — generate and show).

### Completion check
- Admin People show page renders the ao_plugin_key card
- Key is partially masked in the display
- Regenerate form POSTs, updates `ao_plugin_key` and `ao_plugin_key_hash`, redirects with flash
- Non-admin users cannot reach the regenerate route (existing `is_admin` middleware)

---

## Phase 13 — Backfill Command and Key Generation Triggers

**Goal:** Ensure every user who has or gains apwp-ao access has an `ao_plugin_key`. Backfill existing users and wire automatic generation for future purchases and gifts.

### 13.1 Artisan command: `app:backfill-ao-plugin-keys`

Finds all users who have either:
- A completed, paid order containing `apwp-ao` (via `purchasedLicenseQty > 0`), OR
- An active or future-starting gifted product with `sku=apwp-ao`

...and who do not yet have `ao_plugin_key` set. Calls `User::ensureAoPluginKey()` for each. Runs in chunks. Reports count at completion.

Run this command **before deploying Phase 9** and **before deploying Phases 11/12** so users always find a key when they open the dashboard.

### 13.2 Order completion trigger

In the existing order completion flow (wherever `status` is set to `completed` and payment is validated), check if any item has `sku=apwp-ao` and call `User::ensureAoPluginKey($user)` if so. This covers all future purchases without requiring a manual backfill.

### 13.3 Gifted product creation trigger

In `PeopleController::gift()` (admin gifting), after the `GiftedProduct::create()` call, if `$product->sku === 'apwp-ao'` call `User::ensureAoPluginKey($user)`.

### Completion check
- Command runs without error, generates keys for all qualifying users without one
- New order for apwp-ao → user gets `ao_plugin_key` generated immediately
- Admin gifts apwp-ao → user gets `ao_plugin_key` generated immediately
- `ensureAoPluginKey()` is idempotent — calling it on a user who already has a key changes nothing

---

## Phase 14 — Remove plugin_key_hash Fallback (future)

**Goal:** Remove the backward-compat `plugin_key_hash` fallback from `PluginAuthController::issue()` once all known client sites have migrated to the `ao_plugin_key`.

No implementation detail yet. Track via: confirm with the site operator that the test site has reconnected under the new key, then confirm no `plugin_key_hash` lookups are appearing in logs for `plugin_sku=apwp-ao` requests. At that point, delete the fallback line from `issue()`.
