30 KiB
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), раздел 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
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
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;):
use App\Http\Middleware\EnsureSaasAdmin;
Блок alias заменить на:
$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(...). Структура:
// 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
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-комментарий и роуты на:
// Сделки — 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:
- Убрать чтение
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([...]). - Убрать резолюцию
Tenant+ 404: блок$tenant = Tenant::find(...); if ($tenant === null) { return ... 404; }(вexport—abort(404, ...)). - Добавить
$tenantId = (int) $request->user()->tenant_id;(дляindex/show— на месте удалённого; для остальных — сразу после$validated = $request->validate([...]);). - Заменить все
$tenant->idна$tenantId, вuse (...)замыканий$tenant→$tenantId. - Убрать
use App\Models\Tenant;(станет неиспользуемым;composer pintподчистит, но убрать явно). - Внутренние
DB::transaction(...)+DB::statement('SET LOCAL app.current_tenant_id = ...')— оставить без изменений. Для write-методов это атомарность; дляDealExportController::exportэто обязательно — StreamedResponse-замыкание выполняется уже после commit'а транзакцииtenant-middleware (см. комментарий вexport()строки про «после Laravel-response pipeline»), tenant-контекст middleware streaming НЕ покрывает. - Обновить 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 добавить:
$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):
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):
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 шага):
- В
app/phpstan.neonвременно закомментировать строку- phpstan-baseline.neonвincludes:. - Run:
cd app && vendor/bin/phpstan analyse --generate-baseline - Раскомментировать
- 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
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_idbackend молча игнорирует.