# Sprint 3F — API middleware (J1/J2) Implementation Plan

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

**Goal:** Закрыть аудит-находки J1 (auth+tenant middleware на `/api/deals*`) и J2 (стаб-гейт SaaS-admin зоны `/api/admin/*`) — убрать незащищённые API-эндпоинты, где tenant подставляется параметром запроса.

**Architecture:** J1 — на 8 роутов `/api/deals*` навешивается `['auth:sanctum','tenant']`; три контроллера (`DealController`, `DealBulkActionController`, `DealExportController`) перестают читать `tenant_id` из запроса и берут его из `auth()->user()->tenant_id`; 8 Pest-файлов мигрируют с `?tenant_id=` на `actingAs($user)`. J2 — новый middleware `EnsureSaasAdmin` (стаб: dev/testing пропускает, production fail-closed 503) вешается на весь блок `/api/admin/*`; реальная Yandex 360 SSO-авторизация — TODO под Б-1+DO-4.

**Tech Stack:** PHP 8.3 + Laravel 13, Sanctum SPA session auth, PostgreSQL 16 (RLS), Pest 4.

---

## Контекст (audit J1/J2)

Аудит портала ([docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md)), раздел J:

- **J1** — «CTO-18 — auth+tenant middleware на `/api/deals` (требует Б-1 для prod)». Сейчас 8 роутов `/api/deals*` идут **без middleware**: `tenant_id` берётся параметром. Любой клиент читает/пишет сделки чужого тенанта, подставив `tenant_id`. `auth:sanctum`+`tenant` уже используются на `/api/reminders`, `/api/reports/*`, `/api/billing/*`, `/api/projects` — на dev работают, Б-1 их **не блокирует** (Б-1 блокирует только production-deploy). J1 = применить тот же middleware к `/api/deals*`.
- **J2** — «`/api/admin/*` — auth:saas-admin middleware (требует Б-1 + DO-4)». Гварда `saas-admin` в `config/auth.php` **нет** (только `web`); реальный гвард = Yandex 360 SSO, аудит явно пишет «**после Б-1+DO-4**» — оба registry-блокера открыты. Полноценный J2 невозможен. Решение заказчика (2026-05-16): **заготовка-стаб** — middleware-гейт, на dev пропускает, на production fail-closed; production SSO — TODO.

**Scope J1 — backend + Pest.** Фронтенд (`app/resources/js/api/deals.ts` и 3 view) НЕ трогаем: после рефактора backend игнорирует клиентский `tenant_id` (Laravel `validate()` молча отбрасывает лишние ключи; лишний query-параметр игнорируется), фронт продолжает слать сессионную cookie и работает без изменений. Клиентский `tenant_id` становится вестигиальным безвредным параметром — его удаление косметическое, в аудите J1 не значится, вне scope Sprint 3F. Это устраняет дублирующий риск (8 frontend-файлов + 11 Vitest-спеков) при нулевом выигрыше для безопасности: backend, игнорируя клиентский `tenant_id`, уже закрывает кросс-tenant утечку.

**Регистровые items.** J1 связан с CTO-18, J2 — с Б-1+DO-4 (все открыты). Sprint 3F реализует **код** находок J1/J2 (что заказчик авторизовал командой «делай 3f»), но **не закрывает** CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` — закрытие требует явного «закрываем» от заказчика. Реестр в этом спринте не трогаем.

---

## File Structure

**Task 1 (J2):**

- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php` — стаб-гейт SaaS-admin зоны.
- Modify: `app/bootstrap/app.php` — alias `'saas-admin'` в `$middleware->alias([...])`.
- Modify: `app/routes/web.php` — обернуть блок `/api/admin/*` (impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) в `Route::middleware('saas-admin')->group(...)`.
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php` — passthrough на testing + fail-closed 503 на production.

**Task 2 (J1):**

- Modify: `app/routes/web.php` — 8 роутов `/api/deals*` в `Route::middleware(['auth:sanctum','tenant'])->group(...)`.
- Modify: `app/app/Http/Controllers/Api/DealController.php` — `index/show/store/update`: tenant из `auth()->user()`.
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php` — `transition/destroy/restore`: то же.
- Modify: `app/app/Http/Controllers/Api/DealExportController.php` — `export`: то же.
- Test (migrate): `app/tests/Feature/DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php` (только 3 `/api/deals`-теста).

**НЕ трогать:** `app/dev-indices.json` (авто-генерируемый, pre-existing `M` — не стейджить); фронтенд `deals.ts` и deal-views (см. Scope выше); `DealModelTest.php` (модельный unit-тест, HTTP не вызывает); lookup-эндпоинты `/api/managers` и `/api/lead-statuses` (в аудит-находке J1 не значатся — остаются без middleware; `/api/managers`-тесты в `LookupsTest` не трогать).

---

## Task 1: J2 — стаб-гейт `EnsureSaasAdmin` на `/api/admin/*`

**Files:**

- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php`
- Modify: `app/bootstrap/app.php`
- Modify: `app/routes/web.php`
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php`

- [ ] **Step 1: Написать failing-тест `SaasAdminMiddlewareTest.php`**

Создать `app/tests/Feature/SaasAdminMiddlewareTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;

/**
 * J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
 *
 * EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
 * работает на dev), прочие окружения — fail-closed 503 до подключения
 * реального Yandex 360 SSO (TODO под Б-1+DO-4).
 */
uses(DatabaseTransactions::class);

test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
    // Дефолтное тестовое окружение = testing → middleware пропускает.
    $this->getJson('/api/admin/tenants')->assertStatus(200);
});

test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
    $this->app->detectEnvironment(fn () => 'production');

    $this->getJson('/api/admin/tenants')->assertStatus(503);
});
```

- [ ] **Step 2: Прогнать тест — убедиться, что падает**

Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
Expected: FAIL — middleware `EnsureSaasAdmin` ещё не существует, alias `saas-admin` не зарегистрирован, на роуты не навешан; тест «503 вне dev/testing» получит 200.

- [ ] **Step 3: Создать middleware `EnsureSaasAdmin.php`**

Создать `app/app/Http/Middleware/EnsureSaasAdmin.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
 *
 * СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
 * SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
 * реального механизма аутентификации нет.
 *
 * Поведение стаба:
 *   - dev / testing (local, testing) → пропускаем. Admin-панель работает на
 *     dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
 *   - прочие окружения (production / staging) → fail-closed 503: зона
 *     закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
 *     открытый /api/admin/* в проде.
 *
 * TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
 * saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
 */
class EnsureSaasAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! app()->environment('local', 'testing')) {
            abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
        }

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

- [ ] **Step 4: Зарегистрировать alias `saas-admin` в `bootstrap/app.php`**

В `app/bootstrap/app.php` добавить импорт и расширить `$middleware->alias([...])`:

Импорт (после `use App\Http\Middleware\SetTenantContext;`):

```php
use App\Http\Middleware\EnsureSaasAdmin;
```

Блок alias заменить на:

```php
        $middleware->alias([
            'tenant' => SetTenantContext::class,
            'saas-admin' => EnsureSaasAdmin::class,
        ]);
```

- [ ] **Step 5: Навесить `saas-admin` на блок `/api/admin/*` в `routes/web.php`**

В `app/routes/web.php` весь блок admin-роутов (от комментария `// SaaS-admin impersonation flow (Ю-1)...` до строки с `AdminSuppliersController@update` включительно — это группы impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) обернуть в `Route::middleware('saas-admin')->group(...)`. Структура:

```php
// J2 (Sprint 3F): стаб-гейт SaaS-admin зоны. EnsureSaasAdmin — dev/testing
// пропускает, production fail-closed 503. Реальный Yandex 360 SSO — TODO под
// Б-1+DO-4. admin_user_id внутри контроллеров (трейт ResolvesAdminUserId)
// стаб не меняет — это отдельная зона ответственности.
Route::middleware('saas-admin')->group(function () {
    // SaaS-admin impersonation flow (Ю-1). ...
    Route::prefix('/api/admin/impersonation')->group(function () {
        // ... без изменений ...
    });

    // ... все остальные admin-роуты без изменений, только с отступом +4 ...

    Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
        ->where('id', '[0-9]+');
});
```

Содержимое роутов внутри — без изменений (только индентация +4; `composer pint` в Step 7 выровняет, но писать сразу корректно). Роуты `/api/billing/charges`, `/api/billing/*`, `/api/api-keys`, `/api/tenants/me/webhook-settings`, `/api/dashboard/summary` и далее — **вне** этой группы (это tenant-зона, не admin).

- [ ] **Step 6: Прогнать тест — убедиться, что зелёный**

Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
Expected: PASS — 2/2.

- [ ] **Step 7: Pint + Larastan + регрессия admin-тестов**

Run: `cd app && composer pint` → 0 правок или авто-формат применён.
Run: `cd app && composer stan` → 0 ошибок (новый файл middleware типизирован; тест использует только реальные методы `TestCase`, динамических свойств нет — baseline regen не требуется).
Run: `cd app && composer test -- --filter="Admin"` → 0 failed. Все существующие admin-тесты (AdminBilling/AdminIncidents/AdminTenants/AdminSystemSettings/AdminPricingTiers/AdminSuppliers/Impersonation) проходят: на `testing`-окружении `EnsureSaasAdmin` прозрачен.

- [ ] **Step 8: Commit**

```bash
git add app/app/Http/Middleware/EnsureSaasAdmin.php app/bootstrap/app.php app/routes/web.php app/tests/Feature/SaasAdminMiddlewareTest.php
git commit -m "feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/*"
```

**НЕ стейджить** `app/dev-indices.json`.

---

## Task 2: J1 — `auth:sanctum`+`tenant` middleware на `/api/deals*`

**Files:**

- Modify: `app/routes/web.php`
- Modify: `app/app/Http/Controllers/Api/DealController.php`
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php`
- Modify: `app/app/Http/Controllers/Api/DealExportController.php`
- Test (migrate): `DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php`

> **NB про порядок шагов:** миграция атомарна — добавление middleware немедленно «краснит» все 8 deal-тест-файлов (они не аутентифицируются). Поэтому routes+контроллеры+тесты мигрируют в одной задаче/одном коммите; промежуточный red — внутри задачи (Step 3 это фиксирует как TDD-red), green — в Step 6.

- [ ] **Step 1: Навесить middleware на 8 роутов `/api/deals*` в `routes/web.php`**

В `app/routes/web.php` блок из 8 deal-роутов (`GET /api/deals`, `GET /api/deals/{id}`, `POST /api/deals`, `POST /api/deals/export`, `POST /api/deals/transition`, `PATCH /api/deals/{id}`, `DELETE /api/deals`, `POST /api/deals/restore`) обернуть в группу. Заменить docblock-комментарий и роуты на:

```php
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
//
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
// DealController, bulk (transition/destroy/restore) — в
// DealBulkActionController, export — в DealExportController.
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
    Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
    Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
    Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
    Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
    Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
    Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
    Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
    Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
});
```

Lookup-роуты `/api/managers` и `/api/lead-statuses` (идут сразу после) — **вне** группы, без изменений.

- [ ] **Step 2: Рефактор контроллеров — tenant из `auth()->user()`**

Универсальное правило для всех 4 методов `DealController` + 3 методов `DealBulkActionController` + `export` `DealExportController`:

1. Убрать чтение `tenant_id` из запроса: для `index`/`show` — строку `$tenantId = (int) $request->query('tenant_id', '0');` и следующий за ней блок `if ($tenantId < 1) { return ... 422; }`. Для `store`/`update`/`transition`/`destroy`/`restore`/`export` — ключ `'tenant_id' => 'required|integer|min:1',` из массива правил `$request->validate([...])`.
2. Убрать резолюцию `Tenant` + 404: блок `$tenant = Tenant::find(...); if ($tenant === null) { return ... 404; }` (в `export` — `abort(404, ...)`).
3. Добавить `$tenantId = (int) $request->user()->tenant_id;` (для `index`/`show` — на месте удалённого; для остальных — сразу после `$validated = $request->validate([...]);`).
4. Заменить все `$tenant->id` на `$tenantId`, в `use (...)` замыканий `$tenant` → `$tenantId`.
5. Убрать `use App\Models\Tenant;` (станет неиспользуемым; `composer pint` подчистит, но убрать явно).
6. Внутренние `DB::transaction(...)` + `DB::statement('SET LOCAL app.current_tenant_id = ...')` — **оставить без изменений**. Для write-методов это атомарность; для `DealExportController::export` это **обязательно** — StreamedResponse-замыкание выполняется уже после commit'а транзакции `tenant`-middleware (см. комментарий в `export()` строки про «после Laravel-response pipeline»), tenant-контекст middleware streaming НЕ покрывает.
7. Обновить docblock-и: убрать абзацы «На MVP без auth-middleware… `tenant_id` параметром… Production: middleware» — заменить на «J1 (Sprint 3F): `auth:sanctum`+`tenant`, `tenant_id` из `auth()->user()`.»

Конкретно по `DealController`:

- `index(Request $request)`: удалить строки `$tenantId = (int) $request->query('tenant_id', '0');` + `if ($tenantId < 1) {...422}` + `$tenant = Tenant::find($tenantId);` + `if ($tenant === null) {...404}`. На их место: `$tenantId = (int) $request->user()->tenant_id;`. Остальное (уже использует `$tenantId`) — без изменений.
- `show(Request $request, int $id)`: то же — удалить query/422/Tenant::find/404, поставить `$tenantId = (int) $request->user()->tenant_id;`.
- `store(Request $request)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)` замыкания, `SET LOCAL`, `Project::firstOrCreate`, `Deal::create`, `ActivityLog::create`).
- `update(Request $request, int $id)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)`, `SET LOCAL`, оба `where('tenant_id', ...)`, три `ActivityLog::create(['tenant_id' => ...])`).

`DealBulkActionController` — `transition`/`destroy`/`restore` идентично: убрать `tenant_id` из `validate`, убрать `Tenant::find`+404, `$tenantId = (int) $request->user()->tenant_id;`, `$tenant->id` → `$tenantId`, `use ($validated, $tenant)` → `use ($validated, $tenantId)`.

`DealExportController::export` — убрать `tenant_id` из `validate`, убрать `Tenant::find`+`abort(404)`, `$tenantId = (int) $request->user()->tenant_id;`, в `use (...)` StreamedResponse-замыкания `$tenant` → `$tenantId`, `$tenant->id` → `$tenantId`.

- [ ] **Step 3: Прогнать deal-тесты — убедиться в массовом red**

Run: `cd app && composer test -- --filter="Deal"`
Expected: FAIL — `DealIndexTest`/`DealShowTest`/`DealCreateTest`/`DealUpdateTest`/`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest` массово красные: запросы без `actingAs` теперь получают `401`. Это подтверждает, что auth-гейт активен (TDD-red).

- [ ] **Step 4: Мигрировать 8 тест-файлов на `actingAs`**

Универсальный рецепт для каждого HTTP-теста на `/api/deals*`:

**(R1) `beforeEach`** — после создания `$this->tenant` добавить:

```php
    $this->user = User::factory()->for($this->tenant)->create();
    $this->actingAs($this->user);
```

(`use App\Models\User;` — добавить в импорты файла, если ещё нет.)

**(R2) URL** — убрать `?tenant_id=...` / `&tenant_id=...`: `'/api/deals?tenant_id='.$this->tenant->id` → `'/api/deals'`; `'/api/deals?tenant_id='.$id.'&status_in[]=new'` → `'/api/deals?status_in[]=new'` (если параметр был первым — следующий `&` становится `?`).

**(R3) Body** — убрать ключ `'tenant_id' => ...,` из массивов `postJson`/`patchJson`/`deleteJson`.

**(R4) Тесты «404 unknown tenant_id»** — **удалить целиком**. После J1 tenant берётся из `auth()->user()->tenant_id` (FK-гарантированно валиден), пути «unknown tenant» больше нет. Удаляются: `DealIndexTest` «404 для unknown tenant_id», `DealShowTest` «404 для unknown tenant», `DealUpdateTest` «404 unknown tenant», `DealTransitionTest` «404 на unknown tenant», `DealDestroyTest` «404 на unknown tenant», `DealRestoreTest` «404 на unknown tenant», `DealCreateTest` «404 при unknown tenant_id» и «POST /api/deals/export 404 unknown tenant».

**(R5) Тесты «422 без tenant_id»** — конвертировать в «401 без auth»: тело — запрос **без** `actingAs` → `401`. Пример (`DealIndexTest`):

```php
test('GET /api/deals возвращает 401 без auth', function () {
    auth()->logout();
    $this->getJson('/api/deals')->assertStatus(401);
});
```

Конвертируются: `DealIndexTest` «422 без tenant_id», `DealShowTest` «422 без tenant_id», `DealUpdateTest` «422 без tenant_id».

**(R6) Endpoints без теста «422 без tenant_id»** (transition/destroy/restore/store/export) — добавить по одному новому тесту «401 без auth» (запрос без `actingAs`), чтобы каждый из 8 endpoint'ов имел 401-покрытие. Пример (`DealTransitionTest`):

```php
test('POST /api/deals/transition возвращает 401 без auth', function () {
    auth()->logout();
    $this->postJson('/api/deals/transition', ['ids' => [1], 'status' => 'new'])->assertStatus(401);
});
```

**(R7) Тесты пустого body «422»** (`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest`: `postJson('/api/deals/transition', [])->assertStatus(422)` и аналоги) — остаются `422` (поля `ids`/`status` по-прежнему `required`); `actingAs` обеспечивается через `beforeEach` (R1), иначе был бы `401`. Если имя теста содержит «без tenant_id» — переименовать (например «422 на пустой body»).

**(R8) `DealCreateTest` «422 без обязательных полей»** — остаётся `422` (`project_name`/`phone` по-прежнему `required`), но `assertJson`-проверка ключей: `toHaveKeys(['tenant_id', 'project_name', 'phone'])` → `toHaveKeys(['project_name', 'phone'])` (`tenant_id` больше не валидируемое поле).

**(R9) Кросс-tenant RLS-тесты** (`DealIndexTest` «не возвращает сделки чужого tenant'а», «изолирует чужие удалённые»; `DealShowTest` «404 чужая сделка»; `DealUpdateTest` «404 чужая сделка»; и т.п.) — **оставить логику**: чужие данные сеются через `DB::statement('SET app.current_tenant_id = ...')`, `actingAs` — пользователь `$this->tenant`. Применить только R2/R3 (убрать `tenant_id` из запроса). Изоляция продолжает проверяться: backend берёт tenant из auth-пользователя.

**(R10) `LookupsTest.php`** — содержит тесты `/api/managers` (НЕ трогать — endpoint без middleware) и 3 теста `POST /api/deals` (manager-guard). `beforeEach` НЕ менять (тесты `/api/managers` чувствительны к числу users тенанта — лишний `$this->user` сломал бы `toHaveCount(2)`). Вместо этого в каждый из 3 `/api/deals`-тестов («422 если manager_id не принадлежит tenant'у», «422 если manager_id не активен», «принимает manager_id из своего tenant'а») первой строкой добавить `$this->actingAs(User::factory()->for($this->tenant)->create());` и применить R3 (убрать `tenant_id` из body). Файл использует `RefreshDatabase` — created user не протекает между тестами.

- [ ] **Step 5: Прогнать deal-тесты — убедиться в green**

Run: `cd app && composer test -- --filter="Deal"`
Run: `cd app && composer test -- --filter="LookupsTest"`
Expected: PASS — 0 failed в обоих. Точное число тестов — из реального вывода (R4 удалил 8 тестов, R5/R6 добавил/конвертировал 401-тесты).

- [ ] **Step 6: Pint + Larastan (regen baseline) + полная регрессия Pest**

Run: `cd app && composer pint` → авто-формат применён (в т.ч. удаление неиспользуемого `use App\Models\Tenant;`).

Run: `cd app && composer stan` → ожидаются НОВЫЕ ошибки от `$this->user` (новое динамическое свойство в тест-файлах) + сдвиг номеров строк → регенерировать baseline (quirk 25, 3 шага):

1. В `app/phpstan.neon` временно закомментировать строку `- phpstan-baseline.neon` в `includes:`.
2. Run: `cd app && vendor/bin/phpstan analyse --generate-baseline`
3. Раскомментировать `- phpstan-baseline.neon` в `app/phpstan.neon`.

После — повторно `cd app && composer stan` → 0 ошибок.

Run: `cd app && composer test` → 0 failed (полная регрессия Pest). Базовый объём перед Sprint 3F (origin/main `ca0c4d9`) — 853 tests / 850 passed / 3 skipped / 0 failed; после Sprint 3F число изменится (J2 +2 теста; J1 −8 удалённых «404 unknown» +5..8 «401 без auth») — **точное число из реального вывода, не экстраполировать**.

- [ ] **Step 7: Commit**

```bash
git add app/routes/web.php app/app/Http/Controllers/Api/DealController.php app/app/Http/Controllers/Api/DealBulkActionController.php app/app/Http/Controllers/Api/DealExportController.php app/app/Http/Controllers/Api/Concerns app/tests/Feature/DealIndexTest.php app/tests/Feature/DealShowTest.php app/tests/Feature/DealCreateTest.php app/tests/Feature/DealUpdateTest.php app/tests/Feature/DealTransitionTest.php app/tests/Feature/DealDestroyTest.php app/tests/Feature/DealRestoreTest.php app/tests/Feature/LookupsTest.php app/phpstan-baseline.neon
git commit -m "feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*"
```

(`app/app/Http/Controllers/Api/Concerns` в `git add` — на случай, если рефактор ничего там не создаст, путь просто проигнорируется; основное — 4 backend-файла + 8 тестов + baseline.)

**НЕ стейджить** `app/dev-indices.json`.

---

## Self-Review

- **Spec coverage:** J1 (auth+tenant на `/api/deals*` — 8 роутов, 3 контроллера, 8 тест-файлов) ✅; J2 (стаб-гейт `/api/admin/*`) ✅. CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` не закрываются (нет «закрываем» от заказчика) — реализуется только код находок.
- **Placeholder scan:** нет TODO/TBD в коде, кроме намеренного docblock-`TODO` в `EnsureSaasAdmin` (фиксирует, что стаб ждёт реального SSO под Б-1+DO-4 — это документация контракта, не пропуск работы). Тест-миграция задана точным рецептом R1–R10 (механическое преобразование, не placeholder).
- **Type consistency:** `$tenantId` — `int` во всех 8 методах (`(int) $request->user()->tenant_id`); alias `'saas-admin'` и класс `EnsureSaasAdmin` совпадают между `bootstrap/app.php` и `routes/web.php`; middleware-массив `['auth:sanctum','tenant']` — порядок как в существующих группах (`/api/reminders` и др.).
- **Атомарность J1:** middleware и миграция тестов — один коммит (Step 1–7 одной задачи); промежуточный red зафиксирован Step 3 как TDD-проверка активности auth-гейта.
- **Регрессия admin/deal:** J2 на `testing` прозрачен → admin-тесты зелёные без изменений; J1 мигрирует все потребители `/api/deals*` (8 Pest-файлов, включая частично `LookupsTest`) — фронтенд не потребитель backend-тестов, его `tenant_id` backend молча игнорирует.
