Files
portal/docs/superpowers/plans/2026-05-16-sprint3f-api-middleware.md
T
2026-05-16 14:56:11 +03:00

356 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 молча игнорирует.