# G7-B — Дверь impersonation + машинный ключ под ИИ — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Достроить режим «Войти как клиент» до реально работающей, безопасной, аудируемой двери в кабинет клиента — проходимой и человеком (браузер, полный доступ), и будущим ИИ-агентом (машинный `Bearer`-ключ с узким envelope).

**Architecture:** На `verify` кода система (а) логинит браузер админа целевым пользователем тенанта + ставит маркер impersonation в сессию (путь человека), и (б) выдаёт высокоэнтропийный `lpimp_<id>_<secret>`, храня хеш в новой колонке `impersonation_tokens.session_token_hash` (путь ИИ). Машинный ключ авторизует через кастомный guard `impersonation` (`Auth::viaRequest`) только на «рабочих» эндпоинтах кабинета. Изоляция: при активном impersonation вход в `/api/admin/*` запрещён; авто-истечение 1 ч; чистый выход через `/api/impersonation/leave`. Письма-код (init) и -уведомление (end) — по образцу G1.

**Tech Stack:** PHP 8.3 / Laravel 13, Sanctum SPA (web-guard, cookie), PostgreSQL 16 (RLS), Pest 4, Vue 3 + Vuetify 3, Pinia, vue-router. Соединение `pgsql_supplier` (BYPASSRLS) для кросс-тенантных таблиц.

**Рабочие команды:**
- Pest: `composer --working-dir=app test -- <path>`
- Pint: `composer --working-dir=app pint <files>`
- Larastan: `composer --working-dir=app stan`
- Миграции dev: `php app/artisan migrate --force`; тест: `DB_DATABASE=liderra_testing php app/artisan migrate:fresh --force`
- Фронт: `npm --prefix app run type-check`, `npm --prefix app run lint:vue`, `npm --prefix app run build`
- Коммит: `LEFTHOOK=0 git commit`. Пуш: `git push gitea main`. **Стейджить ТОЛЬКО явные пути** (репо имеет пред-существующие грязные файлы — НЕ `git add -A`).
- Демо-клиент dev: `admin@demo.local`/`password` (tenant 1). Сервер: `php artisan serve`.

---

## Карта файлов (что создаём / меняем)

**Backend — создаём:**
- `app/app/Mail/ImpersonationCodeMail.php` — письмо с кодом на init.
- `app/app/Mail/ImpersonationEndedMail.php` — письмо о завершении.
- `app/resources/views/emails/impersonation_code.blade.php`
- `app/resources/views/emails/impersonation_ended.blade.php`
- `app/app/Http/Middleware/ImpersonationContext.php` — авто-истечение human-маркера (web group).
- `app/database/migrations/2026_06_19_160000_add_session_token_hash_to_impersonation_tokens.php`

**Backend — меняем:**
- `db/schema.sql` — +колонка `session_token_hash`, bump v8.49; `db/CHANGELOG_schema.md`.
- `app/app/Models/ImpersonationToken.php` — `$fillable` + методы `isSessionActive()`, `sessionExpiresAt()`.
- `app/app/Http/Controllers/Api/ImpersonationController.php` — init (письмо), verify (takeover + ключ), end (письмо), новый `leave`.
- `app/app/Providers/AppServiceProvider.php` — регистрация guard `impersonation` через `Auth::viaRequest`.
- `app/app/Http/Middleware/EnsureSaasAdmin.php` — блок при активном impersonation (маркер сессии ИЛИ `lpimp_`-bearer).
- `app/bootstrap/app.php` — append `ImpersonationContext` в web-группу.
- `app/routes/web.php` — envelope (`auth:sanctum,impersonation` на разрешённых группах) + роут `/api/impersonation/leave`.
- `app/app/Http/Controllers/Api/AuthController.php` — поле `impersonation` в `me()`.

**Frontend — меняем/создаём:**
- `app/resources/js/api/auth.ts` — тип `AuthUser.impersonation` + `impersonationLeave()`.
- `app/resources/js/api/admin.ts` — тип ответа `verify` с `machine_token`.
- `app/resources/js/components/admin/ImpersonationSessionBanner.vue` — **создаём** (клиентская плашка).
- `app/resources/js/components/admin/ImpersonationDialog.vue` — на verify success: редирект + показ ключа.
- `app/resources/js/layouts/AppLayout.vue` — подключить `ImpersonationSessionBanner`.

**Тесты (Pest):**
- `app/tests/Feature/Admin/ImpersonationDoorTest.php` — основной (init-mail, verify-takeover+token, leave, expiry).
- `app/tests/Feature/Admin/ImpersonationMachineTokenTest.php` — guard accept/reject + envelope + изоляция.

---

## Task 1: Схема — колонка `session_token_hash`

**Files:**
- Modify: `db/schema.sql` (блок `CREATE TABLE impersonation_tokens`, ~стр. 544-566; header-версия)
- Modify: `db/CHANGELOG_schema.md`
- Create: `app/database/migrations/2026_06_19_160000_add_session_token_hash_to_impersonation_tokens.php`

- [ ] **Step 1: schema.sql** — в `CREATE TABLE impersonation_tokens` после строки `session_ended_at TIMESTAMPTZ,` добавить:

```sql
    session_token_hash VARCHAR(255),                       -- bcrypt машинного ключа lpimp_ (NULL пока ключ не выдан; G7-B)
```

Обновить header-версию схемы на **v8.49** (строка базовой версии + дата 2026-06-19, причина «G7-B: impersonation_tokens.session_token_hash под машинный ключ ИИ»).

- [ ] **Step 2: CHANGELOG_schema.md** — добавить запись v8.48→v8.49 с описанием колонки.

- [ ] **Step 3: Миграция (guard'нутая, идемпотентная)**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('ALTER TABLE impersonation_tokens ADD COLUMN IF NOT EXISTS session_token_hash VARCHAR(255)');
    }

    public function down(): void
    {
        DB::statement('ALTER TABLE impersonation_tokens DROP COLUMN IF EXISTS session_token_hash');
    }
};
```

- [ ] **Step 4: Применить и проверить дрейф**

Run: `DB_DATABASE=liderra_testing php app/artisan migrate:fresh --force` — Expected: зелёно, без ошибок.
Run: `php app/artisan migrate --force` (dev) — Expected: применилась.
Проверить ZERO_DRIFT (tinker-скан pg_tables/колонок vs schema.sql, как в G7-A) — Expected: 0 расхождений.

- [ ] **Step 5: Commit**

```bash
git add db/schema.sql db/CHANGELOG_schema.md app/database/migrations/2026_06_19_160000_add_session_token_hash_to_impersonation_tokens.php
LEFTHOOK=0 git commit -m "feat(G7-B): колонка session_token_hash под машинный ключ impersonation, schema v8.49"
```

---

## Task 2: Два письма (init-код + end-уведомление)

**Files:**
- Create: `app/app/Mail/ImpersonationCodeMail.php`, `app/app/Mail/ImpersonationEndedMail.php`
- Create: `app/resources/views/emails/impersonation_code.blade.php`, `app/resources/views/emails/impersonation_ended.blade.php`
- Test: `app/tests/Feature/Admin/ImpersonationDoorTest.php`

Образец — `app/app/Mail/EmailVerificationCodeMail.php` + `emails/email_verification_code.blade.php`.

- [ ] **Step 1: Failing test (init шлёт код)** — создать `ImpersonationDoorTest.php`:

```php
<?php

declare(strict_types=1);

use App\Mail\ImpersonationCodeMail;
use Illuminate\Support\Facades\Mail;

it('init отправляет код-согласие на contact_email тенанта', function () {
    Mail::fake();
    $tenant = makeTenantWithUser(); // helper: создаёт tenant + активного user, см. ниже
    $reason = str_repeat('тест основания impersonation ', 2); // >=30

    $resp = $this->postJson('/api/admin/impersonation/init', [
        'tenant_id' => $tenant->id,
        'requested_by' => 1,
        'reason' => $reason,
    ]);

    $resp->assertOk();
    Mail::assertQueued(ImpersonationCodeMail::class, fn ($m) => $m->hasTo($tenant->contact_email));
});
```

Добавить helper `makeTenantWithUser()` в тест (или в существующий trait, если есть — проверить `tests/`): создаёт `Tenant` (status active, contact_email) + `User` (is_active=true) на `pgsql_supplier`. Сверить с тем, как делают существующие impersonation-тесты (`grep ImpersonationToken app/tests`).

- [ ] **Step 2: Run — FAIL** (`ImpersonationCodeMail` не существует).

Run: `composer --working-dir=app test -- tests/Feature/Admin/ImpersonationDoorTest.php`

- [ ] **Step 3: ImpersonationCodeMail.php** (зеркало EmailVerificationCodeMail):

```php
<?php

declare(strict_types=1);

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

/** Код-согласие на вход поддержки в кабинет клиента (G7-B / Ю-1). */
final class ImpersonationCodeMail extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function __construct(
        public readonly string $code,
        public readonly string $email,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Код доступа: запрос входа поддержки в ваш кабинет',
            to: [$this->email],
        );
    }

    public function content(): Content
    {
        return new Content(view: 'emails.impersonation_code', with: ['code' => $this->code]);
    }
}
```

- [ ] **Step 4: blade `impersonation_code.blade.php`** (текст про согласие):

```blade
<p>Здравствуйте!</p>
<p>Поддержка Лидерры запросила временный вход в ваш личный кабинет для помощи. Код доступа:</p>
<p style="font-size:24px;font-weight:bold;letter-spacing:3px;">{{ $code }}</p>
<p>Если вы обращались в поддержку — продиктуйте код сотруднику. Код действует 15 минут.</p>
<p>Если вы НЕ запрашивали помощь — просто проигнорируйте письмо: без этого кода вход в ваш кабинет невозможен.</p>
```

- [ ] **Step 5: ImpersonationEndedMail.php** — конструктор `public readonly string $email, public readonly ?string $tenantName = null`; subject «Сессия поддержки в вашем кабинете завершена»; view `emails.impersonation_ended`.

- [ ] **Step 6: blade `impersonation_ended.blade.php`:**

```blade
<p>Здравствуйте!</p>
<p>Сессия поддержки в вашем кабинете Лидерры завершена. Если у вас остались вопросы — ответьте на это письмо или напишите в раздел «Помощь».</p>
```

- [ ] **Step 7: Wire в `ImpersonationController::init`** — заменить TODO-строку `// TODO: отправить email...` на (best-effort, паттерн `RegistrationService::sendCode`):

```php
try {
    \Illuminate\Support\Facades\Mail::to((string) $tenant->contact_email)
        ->queue(new \App\Mail\ImpersonationCodeMail($plainCode, (string) $tenant->contact_email));
} catch (\Throwable $e) {
    \Illuminate\Support\Facades\Log::warning('impersonation init: не удалось поставить письмо с кодом: '.$e->getMessage());
}
```

- [ ] **Step 8: Run — PASS.** Run: `composer --working-dir=app test -- tests/Feature/Admin/ImpersonationDoorTest.php`

- [ ] **Step 9: Pint + Commit**

```bash
composer --working-dir=app pint app/app/Mail/ImpersonationCodeMail.php app/app/Mail/ImpersonationEndedMail.php app/app/Http/Controllers/Api/ImpersonationController.php
git add app/app/Mail/ImpersonationCodeMail.php app/app/Mail/ImpersonationEndedMail.php app/resources/views/emails/impersonation_code.blade.php app/resources/views/emails/impersonation_ended.blade.php app/app/Http/Controllers/Api/ImpersonationController.php app/tests/Feature/Admin/ImpersonationDoorTest.php
LEFTHOOK=0 git commit -m "feat(G7-B): письма impersonation код-согласие + завершение, wire в init"
```

---

## Task 3: Model-хелперы + verify (session-takeover + машинный ключ)

**Files:**
- Modify: `app/app/Models/ImpersonationToken.php`
- Modify: `app/app/Http/Controllers/Api/ImpersonationController.php` (`verify`)
- Test: `app/tests/Feature/Admin/ImpersonationDoorTest.php`

Константа лимита сессии: добавить в контроллер `private const SESSION_TTL_MINUTES = 60;`

- [ ] **Step 1: Хелперы модели** — в `ImpersonationToken` добавить `'session_token_hash'` в `$fillable`, в `casts()` ничего нового, и методы:

```php
/** Сессия impersonation активна: код подтверждён, не завершена, не инвалидирована, в пределах 60 мин. */
public function isSessionActive(int $ttlMinutes = 60): bool
{
    return $this->used_at !== null
        && $this->session_ended_at === null
        && $this->invalidated_at === null
        && $this->used_at->copy()->addMinutes($ttlMinutes)->isFuture();
}

public function sessionExpiresAt(int $ttlMinutes = 60): ?\Illuminate\Support\Carbon
{
    return $this->used_at?->copy()->addMinutes($ttlMinutes);
}
```

Добавить `@property ?string $session_token_hash` в docblock модели.

- [ ] **Step 2: Failing test (verify ставит сессию + возвращает ключ)** — в `ImpersonationDoorTest.php`:

```php
use App\Models\ImpersonationToken;

it('verify входит как пользователь тенанта и возвращает машинный ключ', function () {
    $tenant = makeTenantWithUser();
    $owner = \App\Models\User::on('pgsql_supplier')->where('tenant_id', $tenant->id)->orderBy('id')->first();

    // init → получаем token_id + dev-код
    $init = $this->postJson('/api/admin/impersonation/init', [
        'tenant_id' => $tenant->id, 'requested_by' => 1,
        'reason' => str_repeat('основание для теста impersonation ', 2),
    ])->assertOk()->json();

    $verify = $this->postJson('/api/admin/impersonation/verify', [
        'token_id' => $init['token_id'], 'code' => $init['_dev_plain_code'],
    ])->assertOk()->json();

    expect($verify)->toHaveKey('machine_token');
    expect($verify['machine_token'])->toStartWith('lpimp_'.$init['token_id'].'_');
    $this->assertAuthenticatedAs($owner); // браузерная сессия = пользователь тенанта

    $token = ImpersonationToken::on('pgsql_supplier')->find($init['token_id']);
    expect($token->used_at)->not->toBeNull();
    expect($token->session_token_hash)->not->toBeNull();
});
```

- [ ] **Step 3: Run — FAIL** (нет `machine_token`, нет Auth::login).

- [ ] **Step 4: Реализация `verify`** — заменить блок `// Success: mark used.` ... `$token->update(['used_at' => now()]);` на:

```php
// Success: целевой пользователь тенанта = самый ранний активный.
$targetUser = \App\Models\User::on(self::DB_CONNECTION)
    ->where('tenant_id', $token->tenant_id)
    ->where('is_active', true)
    ->orderBy('id')
    ->first();

if ($targetUser === null) {
    return response()->json(['message' => 'У тенанта нет активного пользователя для входа.'], 422);
}

// Машинный ключ для ИИ: lpimp_<id>_<secret>. Храним только хеш.
$secret = \Illuminate\Support\Str::random(48);
$machineToken = 'lpimp_'.$token->id.'_'.$secret;

$token->update([
    'used_at' => now(),
    'session_token_hash' => \Illuminate\Support\Facades\Hash::make($secret),
]);

// Путь человека: логиним браузер целевым пользователем + маркер impersonation в сессию.
\Illuminate\Support\Facades\Auth::login($targetUser);
$request->session()->put('impersonation', [
    'token_id' => $token->id,
    'tenant_id' => $token->tenant_id,
    'target_user_id' => $targetUser->id,
    'started_at' => now()->toIso8601String(),
]);

$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());

return response()->json([
    'token_id' => $token->id,
    'tenant_id' => $token->tenant_id,
    'used_at' => $token->used_at->toIso8601String(),
    'expires_at' => $token->sessionExpiresAt()->toIso8601String(),
    'machine_token' => $machineToken,
    'message' => 'Impersonation начат. Сессия активна 1 час.',
]);
```

(Секрет `$secret` хранится отдельно от `code_hash` — это другой токен, высокоэнтропийный.)

- [ ] **Step 5: Run — PASS.** Если `assertAuthenticatedAs` падает из-за guard — убедиться, что тест бьёт по web-сессии (postJson хранит сессию).

- [ ] **Step 6: Pint + Commit**

```bash
composer --working-dir=app pint app/app/Models/ImpersonationToken.php app/app/Http/Controllers/Api/ImpersonationController.php
git add app/app/Models/ImpersonationToken.php app/app/Http/Controllers/Api/ImpersonationController.php app/tests/Feature/Admin/ImpersonationDoorTest.php
LEFTHOOK=0 git commit -m "feat(G7-B): verify — session-takeover целевого юзера + выдача машинного ключа"
```

---

## Task 4: Guard `impersonation` для машинного ключа

**Files:**
- Modify: `app/app/Providers/AppServiceProvider.php`
- Test: `app/tests/Feature/Admin/ImpersonationMachineTokenTest.php`

- [ ] **Step 1: Failing test (ключ авторизует на /api/deals)** — создать `ImpersonationMachineTokenTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\ImpersonationToken;

function activeMachineToken(): array {
    $tenant = makeTenantWithUser();
    $init = test()->postJson('/api/admin/impersonation/init', [
        'tenant_id' => $tenant->id, 'requested_by' => 1,
        'reason' => str_repeat('машинный ключ основание теста ', 2),
    ])->json();
    $verify = test()->postJson('/api/admin/impersonation/verify', [
        'token_id' => $init['token_id'], 'code' => $init['_dev_plain_code'],
    ])->json();
    // важно: сбросить сессию, чтобы тестировать именно bearer, а не cookie
    test()->flushSession();
    return ['tenant' => $tenant, 'token' => $verify['machine_token']];
}

it('машинный ключ авторизует чтение сделок в периметре тенанта', function () {
    ['token' => $token] = activeMachineToken();
    $this->withHeader('Authorization', 'Bearer '.$token)
        ->getJson('/api/deals')->assertOk();
});

it('битый/протухший ключ не авторизует', function () {
    $this->withHeader('Authorization', 'Bearer lpimp_999999_deadbeef')
        ->getJson('/api/deals')->assertUnauthorized();
});
```

(Если `flushSession()` недоступен — использовать `$this->app['session']->flush()` или отдельный HTTP-клиент. Проверить, как тесты управляют сессией.)

- [ ] **Step 2: Run — FAIL** (guard `impersonation` не зарегистрирован; на этом этапе `/api/deals` ещё `auth:sanctum` — тест из Step 1 даст 401 и для валидного ключа). FAIL ожидаем.

- [ ] **Step 3: Регистрация guard** — в `AppServiceProvider::boot()` добавить:

```php
\Illuminate\Support\Facades\Auth::viaRequest('impersonation', function (\Illuminate\Http\Request $request) {
    $header = (string) $request->header('Authorization', '');
    if (! str_starts_with($header, 'Bearer lpimp_')) {
        return null;
    }
    $raw = trim(substr($header, 7)); // lpimp_<id>_<secret>
    $parts = explode('_', $raw, 3);
    if (count($parts) !== 3 || $parts[0] !== 'lpimp' || ! ctype_digit($parts[1])) {
        return null;
    }
    [$, $idStr, $secret] = $parts;
    $token = \App\Models\ImpersonationToken::on('pgsql_supplier')->find((int) $idStr);
    if ($token === null || $token->session_token_hash === null || ! $token->isSessionActive()) {
        return null;
    }
    if (! \Illuminate\Support\Facades\Hash::check($secret, (string) $token->session_token_hash)) {
        return null;
    }
    return \App\Models\User::on('pgsql_supplier')
        ->where('tenant_id', $token->tenant_id)
        ->where('is_active', true)
        ->orderBy('id')
        ->first();
});
```

(PHP не позволяет `[$, ...]` — заменить на `$idStr = $parts[1]; $secret = $parts[2];`.)

- [ ] **Step 4: Run** — тест ещё может падать, т.к. `/api/deals` пока `auth:sanctum` (без `impersonation`). Это закрывается Task 5; **оставить тесты Task 4 красными до Task 5 ИЛИ** временно проверить guard юнит-тестом `Auth::guard('impersonation')`. Рекомендация: закоммитить guard, тесты accept/reject прогнать после Task 5.

- [ ] **Step 5: Commit (guard)**

```bash
composer --working-dir=app pint app/app/Providers/AppServiceProvider.php
git add app/app/Providers/AppServiceProvider.php app/tests/Feature/Admin/ImpersonationMachineTokenTest.php
LEFTHOOK=0 git commit -m "feat(G7-B): guard impersonation для машинного Bearer-ключа lpimp_"
```

---

## Task 5: Envelope — какие группы принимают машинный ключ

**Files:**
- Modify: `app/routes/web.php`
- Test: `app/tests/Feature/Admin/ImpersonationMachineTokenTest.php`

Принцип: на **разрешённых** группах `auth:sanctum` → `auth:sanctum,impersonation` (мульти-guard). Опасные группы не трогаем.

- [ ] **Step 1: Разрешённые группы** — заменить `'auth:sanctum'` на `'auth:sanctum,impersonation'` в группах: `/api/deals` (стр.243), `/api/projects` (280), `/api/reports/jobs` (76), `/api/imports` (257), `/api/dashboard` (229), `/api/managers` (269), `/api/lead-statuses` (272), `/api/notifications` (58), `/api/support-requests` (234), `/api/billing/charges` (193).

Пример (deals): `Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(...)`.

- [ ] **Step 2: НЕ трогать** (остаются `auth:sanctum` only): `/api/auth/*` (профиль/2FA/logout), `/api/billing` (topup/wallet, стр.200), `/api/api-keys` (209), webhook-settings (220), `/api/tenant/requisites` (66), `/api/2fa/*` (304).

- [ ] **Step 3: Тесты envelope** — дописать в `ImpersonationMachineTokenTest.php`:

```php
it('машинный ключ НЕ авторизует опасные группы (деньги/ключи)', function () {
    ['token' => $token] = activeMachineToken();
    $this->withHeader('Authorization', 'Bearer '.$token)
        ->getJson('/api/api-keys')->assertUnauthorized();
    $this->withHeader('Authorization', 'Bearer '.$token)
        ->postJson('/api/billing/topup', [])->assertUnauthorized();
});

it('обычная cookie-сессия по-прежнему работает на разрешённой группе', function () {
    $tenant = makeTenantWithUser();
    $user = \App\Models\User::on('pgsql_supplier')->where('tenant_id', $tenant->id)->orderBy('id')->first();
    $this->actingAs($user)->withHeader('X-Tenant-Id', (string) $tenant->id)
        ->getJson('/api/deals')->assertOk();
});
```

- [ ] **Step 4: Run — все тесты Task 4+5 PASS.** Run: `composer --working-dir=app test -- tests/Feature/Admin/ImpersonationMachineTokenTest.php`
Если падает обычная cookie-сессия — проверить порядок guard в строке (`sanctum` первым).

- [ ] **Step 5: Commit**

```bash
git add app/routes/web.php app/tests/Feature/Admin/ImpersonationMachineTokenTest.php
LEFTHOOK=0 git commit -m "feat(G7-B): envelope машинного ключа — auth:sanctum,impersonation на рабочих группах кабинета"
```

---

## Task 6: Изоляция — блок админ-зоны + авто-истечение 1 ч

**Files:**
- Modify: `app/app/Http/Middleware/EnsureSaasAdmin.php`
- Create: `app/app/Http/Middleware/ImpersonationContext.php`
- Modify: `app/bootstrap/app.php`
- Test: `app/tests/Feature/Admin/ImpersonationMachineTokenTest.php`, `ImpersonationDoorTest.php`

- [ ] **Step 1: Failing test (изоляция + expiry)** — добавить:

```php
it('при активном impersonation вход в админ-зону запрещён (bearer)', function () {
    ['token' => $token] = activeMachineToken();
    $this->withHeader('Authorization', 'Bearer '.$token)
        ->getJson('/api/admin/tenants')->assertForbidden();
});
```

В `ImpersonationDoorTest.php`:

```php
it('сессия старше 60 минут авто-истекает', function () {
    $tenant = makeTenantWithUser();
    $init = $this->postJson('/api/admin/impersonation/init', [
        'tenant_id' => $tenant->id, 'requested_by' => 1,
        'reason' => str_repeat('основание expiry теста impersonation ', 2),
    ])->json();
    $this->postJson('/api/admin/impersonation/verify', [
        'token_id' => $init['token_id'], 'code' => $init['_dev_plain_code'],
    ])->assertOk();

    $this->travel(61)->minutes();
    $this->getJson('/api/auth/me')->assertUnauthorized();
});
```

- [ ] **Step 2: Run — FAIL.**

- [ ] **Step 3: EnsureSaasAdmin — блок при impersonation** — заменить тело `handle`:

```php
public function handle(Request $request, Closure $next): Response
{
    // G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
    // вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
    $hasMarker = $request->hasSession() && $request->session()->has('impersonation');
    $hasBearer = str_starts_with((string) $request->header('Authorization', ''), 'Bearer lpimp_');
    if ($hasMarker || $hasBearer) {
        abort(403, 'Во время сессии impersonation доступ в админ-зону запрещён.');
    }

    return $next($request);
}
```

- [ ] **Step 4: ImpersonationContext middleware** (авто-истечение human-маркера):

```php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Models\ImpersonationToken;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

/**
 * G7-B: на web-запросах с активным impersonation-маркером проверяет 60-мин
 * лимит. Истёк → завершает токен, шлёт письмо, разлогинивает, чистит маркер.
 * No-op, если маркера нет.
 */
class ImpersonationContext
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->hasSession() || ! $request->session()->has('impersonation')) {
            return $next($request);
        }

        $marker = $request->session()->get('impersonation');
        $token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id'] ?? 0);

        if ($token === null || ! $token->isSessionActive()) {
            if ($token !== null && $token->session_ended_at === null) {
                $token->update(['session_ended_at' => now()]);
                // письмо best-effort
                try {
                    \Illuminate\Support\Facades\Mail::to((string) $token->sent_to_email)
                        ->queue(new \App\Mail\ImpersonationEndedMail((string) $token->sent_to_email));
                } catch (\Throwable $e) {
                    \Illuminate\Support\Facades\Log::warning('impersonation expiry mail: '.$e->getMessage());
                }
            }
            Auth::guard('web')->logout();
            $request->session()->forget('impersonation');
        }

        return $next($request);
    }
}
```

- [ ] **Step 5: Зарегистрировать в web-группе** — в `bootstrap/app.php` внутри `withMiddleware`, после `$middleware->alias([...])`:

```php
$middleware->web(append: [
    \App\Http\Middleware\ImpersonationContext::class,
]);
```

- [ ] **Step 6: Run — PASS.** Run: `composer --working-dir=app test -- tests/Feature/Admin/`

- [ ] **Step 7: Pint + Commit**

```bash
composer --working-dir=app pint app/app/Http/Middleware/EnsureSaasAdmin.php app/app/Http/Middleware/ImpersonationContext.php
git add app/app/Http/Middleware/EnsureSaasAdmin.php app/app/Http/Middleware/ImpersonationContext.php app/bootstrap/app.php app/tests/Feature/Admin/ImpersonationMachineTokenTest.php app/tests/Feature/Admin/ImpersonationDoorTest.php
LEFTHOOK=0 git commit -m "feat(G7-B): изоляция админ-зоны при impersonation + авто-истечение сессии 1 ч"
```

---

## Task 7: Выход из кабинета (`leave`) + поле `me.impersonation`

**Files:**
- Modify: `app/app/Http/Controllers/Api/ImpersonationController.php` (метод `leave`)
- Modify: `app/routes/web.php` (роут leave)
- Modify: `app/app/Http/Controllers/Api/AuthController.php` (`me`)
- Test: `app/tests/Feature/Admin/ImpersonationDoorTest.php`

- [ ] **Step 1: Failing test** — добавить:

```php
it('me отдаёт impersonation-контекст, leave завершает и разлогинивает', function () {
    $tenant = makeTenantWithUser();
    $init = $this->postJson('/api/admin/impersonation/init', [
        'tenant_id' => $tenant->id, 'requested_by' => 1,
        'reason' => str_repeat('основание leave теста impersonation ', 2),
    ])->json();
    $this->postJson('/api/admin/impersonation/verify', [
        'token_id' => $init['token_id'], 'code' => $init['_dev_plain_code'],
    ])->assertOk();

    $me = $this->getJson('/api/auth/me')->assertOk()->json('user.impersonation');
    expect($me['active'])->toBeTrue();

    $this->postJson('/api/impersonation/leave')->assertOk();
    $this->getJson('/api/auth/me')->assertUnauthorized();
});
```

- [ ] **Step 2: Run — FAIL.**

- [ ] **Step 3: Метод `leave` в ImpersonationController:**

```php
/** POST /api/impersonation/leave — завершить свою impersonation-сессию из кабинета. */
public function leave(Request $request, ImpersonationAuditService $audit): JsonResponse
{
    $marker = $request->session()->get('impersonation');
    if ($marker === null) {
        return response()->json(['message' => 'Сессия impersonation не активна.'], 422);
    }

    $token = ImpersonationToken::on(self::DB_CONNECTION)->find($marker['token_id']);
    if ($token !== null && $token->session_ended_at === null) {
        $token->update(['session_ended_at' => now()]);
        $audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
        try {
            \Illuminate\Support\Facades\Mail::to((string) $token->sent_to_email)
                ->queue(new \App\Mail\ImpersonationEndedMail((string) $token->sent_to_email));
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::warning('impersonation leave mail: '.$e->getMessage());
        }
    }

    \Illuminate\Support\Facades\Auth::guard('web')->logout();
    $request->session()->forget('impersonation');
    $request->session()->regenerate();

    return response()->json(['message' => 'Вы вышли из режима поддержки.']);
}
```

Также в существующий `end` добавить отправку `ImpersonationEndedMail` (заменить TODO `:235`), best-effort.

- [ ] **Step 4: Роут leave** — в web.php (под `auth:sanctum`, вне admin-группы):

```php
Route::middleware('auth:sanctum')->post('/api/impersonation/leave', 'App\Http\Controllers\Api\ImpersonationController@leave');
```

- [ ] **Step 5: Поле `impersonation` в `AuthController::userResource`** — добавить вычисление в `me()` (не в userResource, нужен доступ к session). В `me()`:

```php
public function me(Request $request): JsonResponse
{
    /** @var User $user */
    $user = $request->user();
    $resource = $this->userResource($user);

    $marker = $request->hasSession() ? $request->session()->get('impersonation') : null;
    if ($marker !== null) {
        $token = \App\Models\ImpersonationToken::on('pgsql_supplier')->find($marker['token_id']);
        $tenant = $token?->tenant;
        $resource['impersonation'] = [
            'active' => true,
            'tenant_name' => $tenant?->organization_name,
            'started_at' => $marker['started_at'] ?? null,
            'expires_at' => $token?->sessionExpiresAt()?->toIso8601String(),
        ];
    }

    return response()->json(['user' => $resource]);
}
```

- [ ] **Step 6: Run — PASS.**

- [ ] **Step 7: Pint + Commit**

```bash
composer --working-dir=app pint app/app/Http/Controllers/Api/ImpersonationController.php app/app/Http/Controllers/Api/AuthController.php
git add app/app/Http/Controllers/Api/ImpersonationController.php app/app/Http/Controllers/Api/AuthController.php app/routes/web.php app/tests/Feature/Admin/ImpersonationDoorTest.php
LEFTHOOK=0 git commit -m "feat(G7-B): leave из кабинета + impersonation-контекст в /api/auth/me + end-письмо"
```

---

## Task 8: Frontend — плашка, диалог, типы, leave

**Files:**
- Modify: `app/resources/js/api/auth.ts`, `app/resources/js/api/admin.ts`
- Create: `app/resources/js/components/admin/ImpersonationSessionBanner.vue`
- Modify: `app/resources/js/components/admin/ImpersonationDialog.vue`, `app/resources/js/layouts/AppLayout.vue`

(vitest сломан G8 → проверка `type-check` + `lint:vue` + `build` + живой Playwright в Task 9.)

- [ ] **Step 1: Типы в `api/auth.ts`** — в `interface AuthUser` добавить:

```ts
    impersonation?: {
        active: boolean;
        tenant_name: string | null;
        started_at: string | null;
        expires_at: string | null;
    };
```

И функция:

```ts
export async function impersonationLeave(): Promise<void> {
    await ensureCsrfCookie();
    await apiClient.post('/api/impersonation/leave');
}
```

- [ ] **Step 2: Тип ответа verify в `api/admin.ts`** — найти тип ответа `impersonationVerify` и добавить поля `machine_token: string; expires_at: string;` (сверить точное имя интерфейса в файле).

- [ ] **Step 3: `ImpersonationSessionBanner.vue`** (клиентская красная плашка):

```vue
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '../../stores/auth';
import { impersonationLeave } from '../../api/auth';

const auth = useAuthStore();
const imp = computed(() => auth.user?.impersonation);

async function leave(): Promise<void> {
    try {
        await impersonationLeave();
    } finally {
        window.location.assign('/admin/tenants');
    }
}
</script>

<template>
    <div v-if="imp?.active" class="imp-session-banner" role="alert" data-testid="impersonation-session-banner">
        <v-icon size="16" class="imp-session-banner__icon">mdi-account-switch</v-icon>
        <span class="imp-session-banner__label">
            Вы в кабинете клиента «{{ imp.tenant_name ?? '—' }}» — режим поддержки.
        </span>
        <button type="button" class="imp-session-banner__leave" data-testid="impersonation-leave-btn" @click="leave">
            Выйти
        </button>
    </div>
</template>

<style scoped>
.imp-session-banner {
    display: flex; align-items: center; gap: 8px;
    background: #b3261e; color: #fff; font-size: 13px; padding: 8px 24px;
    position: sticky; top: 0; z-index: 2000;
}
.imp-session-banner__label { flex: 1; }
.imp-session-banner__leave {
    background: #fff; color: #b3261e; border: 0; border-radius: 4px;
    padding: 2px 12px; font-weight: 600; cursor: pointer;
}
</style>
```

- [ ] **Step 4: Подключить в `AppLayout.vue`** — импорт + в `<template>` первым ребёнком `<v-app>` (над `AppSidebar`):

```vue
import ImpersonationSessionBanner from '../components/admin/ImpersonationSessionBanner.vue';
```
```vue
<v-app>
    <ImpersonationSessionBanner />
    <AppSidebar v-model:drawer-open="drawerOpen" />
    ...
```

- [ ] **Step 5: `ImpersonationDialog.vue` — на verify success редирект + показ ключа.** В `submitVerify()` после успеха заменить `step.value = 'active'` на: сохранить `machineToken.value = r.machine_token`, перейти на шаг `'active'`. На шаге `'active'` добавить: кнопку «Войти в кабинет» → `window.location.assign('/dashboard')`, и блок с копируемым `machineToken` (подпись «Ключ доступа для ИИ-интеграции — скопируйте, больше не покажем»). Добавить `const machineToken = ref<string | null>(null)` и сброс в `resetState()`.

- [ ] **Step 6: Проверки фронта**

Run: `npm --prefix app run type-check` — Expected: 0 ошибок.
Run: `npm --prefix app run lint:vue` — Expected: 0 ошибок.
Run: `npm --prefix app run build` — Expected: успешная сборка.

- [ ] **Step 7: Commit**

```bash
git add app/resources/js/api/auth.ts app/resources/js/api/admin.ts app/resources/js/components/admin/ImpersonationSessionBanner.vue app/resources/js/components/admin/ImpersonationDialog.vue app/resources/js/layouts/AppLayout.vue
LEFTHOOK=0 git commit -m "feat(G7-B): клиентская плашка impersonation + редирект/ключ в диалоге + leave"
```

---

## Task 9: Живая верификация + регрессия + security + push

**Files:** нет правок кода (только верификация); при находках — фиксы отдельными коммитами.

- [ ] **Step 1: Полный Pest по фиче**

Run: `composer --working-dir=app test -- tests/Feature/Admin/ImpersonationDoorTest.php tests/Feature/Admin/ImpersonationMachineTokenTest.php` — Expected: всё зелёное.

- [ ] **Step 2: Larastan**

Run: `composer --working-dir=app stan` — Expected: 0 (Pest-ложноположительные `TestCall::*` — в baseline тем же паттерном, как в G7-A).

- [ ] **Step 3: Живой Playwright** (dev: `php artisan serve` + `npm --prefix app run dev`, демо `admin@demo.local`/`password`):
  - Залогиниться → `/admin/tenants` → «Войти как клиент» у тенанта → reason ≥30 → «Запросить код».
  - Взять dev-код из баннера → verify → проверить, что появился `machine_token` (скопировать) и кнопка «Войти в кабинет».
  - «Войти в кабинет» → редирект `/dashboard` → **красная плашка** «Вы в кабинете клиента …» сверху → данные именно этого тенанта.
  - Проверить изоляцию: попытка перейти в `/admin/*` (URL вручную) — API админки даёт 403.
  - «Выйти» в плашке → ушли из режима → плашка исчезла.
  - Консоль браузера — 0 ошибок.

- [ ] **Step 4: Security-проход** — прогнать skill `threat-model` (или `security-go-live`) по impersonation: точки входа, эскалация, 152-ФЗ, не светится ли `machine_token` в логах. Зафиксировать вывод в конце плана/в коммит-сообщении. Находки → фиксы.

- [ ] **Step 5: Quick-регрессия фронта + докс-линт** (если затронуты .md): `npm --prefix app run type-check && npm --prefix app run build`.

- [ ] **Step 6: Push**

```bash
git push gitea main
```

- [ ] **Step 7: Обновить память** проекта (`project-golive-findings-status`: G7-B DONE; хэндофф закрыть) — через `direct:memory-sync` или `memory dump`.

---

## Self-Review (выполнено автором плана)

- **Покрытие спеки:** §3.1 письма → Task 2/7; §3.2 человек-takeover → Task 3; §3.3 машинный ключ → Task 3+4; §3.4 envelope+изоляция → Task 5+6; §3.5 leave+me+плашка → Task 7+8; §3.6 аудит → Task 3/7 (recordVerify/recordEnd); §5 схема → Task 1; §6 тесты → Task 2-9; §7 деплой-гейт → Task 9 Step 4 + ниже. Пробелов нет.
- **Placeholder-скан:** код приведён для всех нетривиальных шагов; зеркальные письма/типы — с явным образцом и точным diff. Отмечены 2 микро-правки синтаксиса (`[$,...]` деструктуризация недопустима в PHP — заменить на `$idStr=$parts[1]; $secret=$parts[2];`; `$tenant->contact_email` приведение к string).
- **Согласованность типов:** `machine_token`/`session_token_hash`/`isSessionActive()`/`sessionExpiresAt()`/`impersonation`-маркер — имена единообразны во всех задачах.

## Деплой-гейт (вынести владельцу при выкате)
- Дверь остаётся за **nginx HTTP Basic Auth** (`/admin` + `/api/admin/*`).
- Прод-логи не должны логировать тело ответа `verify` (там `machine_token`).
- На prod `machine_token` возвращается осознанно (нужен ИИ) — подтверждено владельцем.
```
