# Implementation Plan: CRM Authentication

## Overview

Convert the feature design into a series of prompts for a code-generation LLM that will implement each step with incremental progress. Make sure that each prompt builds on the previous prompts, and ends with wiring things together. There should be no hanging or orphaned code that isn't integrated into a previous step. Focus ONLY on tasks that involve writing, modifying, or testing code.

The work is split into vertical slices that each end in something runnable: (1) backend Sanctum + JSON envelope plumbing, (2) auth domain primitives (`UserResource`, `LoginRequest`, `LoginRateLimiter`), (3) `AuthController` + protected `routes/api.php`, (4) the `crm:invite` provisioning command, (5) the shared frontend HTTP client + auth Pinia store, (6) `Login.vue` + router guard, (7) the `App.vue` logout control and the `app.js` mount-probe sequence. Property tests use Pest 4 + `eris/eris` on the backend (≥100 iterations, tagged `[P{N}]`) and Vitest + `@vue/test-utils` + `fast-check` on the frontend (`numRuns: 100`, tagged `[P{N}]`). Each property test sub-task explicitly references its property number from the design and the requirement clauses it validates.

## Tasks

- [x] 1. Set up backend dependencies and Sanctum SPA configuration
  - [x] 1.1 Install Sanctum, publish config, set environment defaults
    - Run `composer require laravel/sanctum:^4` and `php artisan install:api --without-migration-prompt` to publish `config/sanctum.php`
    - Add `SANCTUM_STATEFUL_DOMAINS=localhost,localhost:5173,127.0.0.1,127.0.0.1:5173,::1` and `SESSION_LIFETIME=120` to `.env.example`; document `SESSION_SECURE_COOKIE`
    - Add `SANCTUM_STATEFUL_DOMAINS=localhost` to `phpunit.xml`'s `<server>` block so stateful tests have a deterministic origin
    - _Requirements: 3.1, 3.2, 3.3, 3.6, 11.1, 11.4_

  - [x] 1.2 Register `EnsureFrontendRequestsAreStateful` middleware in `bootstrap/app.php`
    - In `withMiddleware(...)`, prepend `\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class` to the `api` group, before `EnsureJsonRequestBody` and before route binding so the XSRF cookie and stateful resolution run on every `/api/*` request
    - Confirm the existing `EnforceJsonContentType` global middleware still runs first (so all auth responses retain `application/json; charset=utf-8`)
    - _Requirements: 3.1, 3.3, 3.4, 3.5, 13.1_

  - [x] 1.3 Tighten `users.email` to `VARCHAR(254)`
    - Create `database/migrations/2026_xx_xx_xxxxxx_tighten_users_email.php` with `$table->string('email', 254)->unique()->change()` in `up()` and a corresponding `down()`
    - Idempotent on MySQL; SQLite test runs are covered by `RefreshDatabase` rebuilding the table at the new column length
    - _Requirements: 1.2, 1.3_

  - [ ]* 1.4 Property test: stateful request detection
    - Pest + eris test in `tests/Feature/Auth/CsrfAndStatefulTest.php`
    - `genStatefulOrigin()` produces `(originHeader, hasSessionCookie)` pairs across all four combinations of (matches `SANCTUM_STATEFUL_DOMAINS`, has session cookie)
    - Asserts that for any combination, the controller resolves to anonymous unless **both** the origin matches the configured stateful domain list **and** the request carries the session cookie
    - **Property 16: Stateful request detection** — _Validates: Requirements 3.3_
    - Tag with `[P16]`, `withIterations(100)`

  - [ ]* 1.5 Smoke and config example tests
    - `tests/Feature/Auth/SanctumConfigTest.php`: assert `config('sanctum.stateful')` contains entries from `SANCTUM_STATEFUL_DOMAINS`, `config('session.lifetime') === 120`, `config('session.http_only') === true`, `config('session.same_site') === 'lax'`
    - `tests/Feature/Auth/RouteListTest.php`: assert `php artisan route:list` exposes `auth.login`, `auth.logout`, `auth.user`, and that the five `apiResource` Contact routes are inside the `auth:sanctum` middleware group
    - _Requirements: 3.1, 3.2, 3.6, 7.1, 7.2, 11.1, 11.4_

- [x] 2. Add JSON error envelope auth render closures in `bootstrap/app.php`
  - [x] 2.1 Register `AuthenticationException` (401), `TokenMismatchException` (419), and `ThrottleRequestsException` (429) render closures
    - In `withExceptions(...)` add three peer closures, each guarded by `$request->is('api/*')`
    - `AuthenticationException` → `response()->json(['message' => 'Unauthenticated.'], 401)`
    - `TokenMismatchException` → `response()->json(['message' => 'CSRF token mismatch.'], 419)`
    - `ThrottleRequestsException` → JSON 429 with `Retry-After` clamped to `[1, 60]`, no stack trace, no class name, no file path
    - All three responses MUST omit `Location` so anonymous browsers never get redirected
    - _Requirements: 5.2, 6.2, 7.1, 7.5, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_

  - [ ]* 2.2 Property test: anonymous JSON 401 envelope without redirect
    - `tests/Feature/Auth/ContactsAuthGateTest.php` using eris `forAll(genApiUrl(), genHttpMethod())`
    - For any URL matching `/api/logout`, `/api/user`, or `/api/contacts*` and any HTTP method, an anonymous request returns status 401, `Content-Type: application/json; charset=utf-8`, body `{message: <string of trimmed length 1..500>}`, no `Location` header, and leaves `contacts`, `users`, and `sessions` unchanged byte-for-byte
    - **Property 14: Anonymous JSON 401 envelope without redirect** — _Validates: Requirements 5.2, 6.2, 7.1, 13.1, 13.2, 13.3_
    - Tag with `[P14]`, `withIterations(100)`

  - [ ]* 2.3 Property test: CSRF mismatch on state-changing API calls
    - `tests/Feature/Auth/CsrfAndStatefulTest.php` using `genCsrfPair()` (cookie value ≠ header value) and the set of state-changing methods on `/api/login`, `/api/logout`, and `/api/contacts*`
    - Asserts status 419, `Content-Type: application/json; charset=utf-8`, body `{message: <string 1..500>}`, no `Location` header, and zero rows changed in `contacts`, `users`, `sessions`
    - **Property 15: CSRF mismatch on state-changing API calls** — _Validates: Requirements 3.4, 3.5, 7.5, 13.4_
    - Tag with `[P15]`, `withIterations(100)`

  - [ ]* 2.4 Property test: error envelope confidentiality
    - `tests/Feature/Auth/ErrorEnvelopeTest.php` triggers responses with status 401, 419, 422, and 429 from `/api/login`, `/api/logout`, `/api/user`, and `/api/contacts*`
    - Asserts top-level keys ⊆ `{message, errors}` (with `errors` only on 422); body contains no substring matching `/[A-Za-z0-9_./-]+\.php(:\d+)?/`, no PHP/Symfony exception class names (`AuthenticationException`, `TokenMismatchException`, `ThrottleRequestsException`, `ValidationException`), no PHP or Laravel version strings, no value equal to the request `password`, no value equal to the request `email`, and no `UserResource` field
    - **Property 18: Error envelope confidentiality** — _Validates: Requirements 12.2, 12.4, 13.6_
    - Tag with `[P18]`, `withIterations(100)`

- [x] 3. Implement auth domain primitives
  - [x] 3.1 Implement `UserResource`
    - `app/Http/Resources/UserResource.php::toArray` returns exactly `{id, name, email, created_at, updated_at}`
    - `created_at`/`updated_at` formatted as `Y-m-d\TH:i:s\Z` in UTC (matching `ContactResource`)
    - Never include `password`, `remember_token`, or `email_verified_at`
    - _Requirements: 1.8, 13.1_

  - [x] 3.2 Implement `LoginRequest`
    - `app/Http/Requests/LoginRequest.php::prepareForValidation` lower-cases + trims `email`
    - `rules()`: `email` required string 1..254 + `App\Rules\ValidEmail`; `password` required string 1..256
    - `authorize()` returns `true`
    - _Requirements: 1.2, 4.3, 4.10_

  - [x] 3.3 Implement `LoginRateLimiter` helper
    - `app/Support/LoginRateLimiter.php` wrapping `Illuminate\Cache\RateLimiter`
    - Two independent buckets per call: `login:e:<sha1(email)>` and `login:i:<ip>`, each with limit 5 per rolling 60s
    - Methods: `tooManyAttempts(emailLowerTrimmed, ip): ?int retryAfter`, `hit(emailLowerTrimmed, ip)`, `clear(emailLowerTrimmed, ip)`
    - _Requirements: 4.6, 4.7_

  - [ ]* 3.4 Property test: `UserResource` exposes only safe fields
    - `tests/Unit/Resources/UserResourceTest.php` using eris `forAll(genUserPopulation(1))`
    - For any persisted User `u`, `UserResource::make($u)->resolve()` is an object whose top-level keys equal `{id, name, email, created_at, updated_at}` exactly, with timestamps matching `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$` in UTC, and no `password`, no `remember_token`, no `email_verified_at`
    - **Property 5: UserResource exposes only safe fields** — _Validates: Requirements 1.8_
    - Tag with `[P5]`, `withIterations(100)`

  - [ ]* 3.5 Property test: email format validation universal
    - `tests/Unit/Requests/LoginRequestEmailFormatTest.php` using eris `forAll(oneOf(genValidEmail(), genInvalidEmail()))`
    - Asserts the email rule used by `LoginRequest` accepts `s` iff `lowercase(trim(s))` has length in `[1, 254]`, exactly one `@`, non-empty local-part, domain with at least one `.` and no whitespace; rejected inputs return 422 keyed on `email` from `POST /api/login` with no `users` row created or modified
    - **Property 2: Email format validation universal** — _Validates: Requirements 1.2, 1.10, 2.6, 4.3_
    - Tag with `[P2]`, `withIterations(100)`

- [x] 4. Implement `AuthController` and wire api routes
  - [x] 4.1 Implement `AuthController`
    - `app/Http/Controllers/Api/AuthController.php` with three actions:
      - `login(LoginRequest)`: throttle check via `LoginRateLimiter::tooManyAttempts` (returns 429 with `Retry-After`); locate User by lower-cased trimmed email; on success `Auth::guard('web')->login($user)`, `regenerate()` session, `clear()` throttle, return 200 `{data: UserResource}`; on failure `hit()` throttle and return 401 `{message: 'Invalid credentials.'}` with the same fixed body for both no-such-email and wrong-password cases
      - `logout(Request)`: `Auth::guard('web')->logout()`, `session()->invalidate()`, `session()->regenerateToken()`, return `noContent()` (204)
      - `currentUser(Request)`: returns 200 `{data: UserResource(request->user())}` shaped exactly as `{"data": {...}}`
    - Throws `\Throwable` propagate to the existing generic 500 envelope; never echoes the supplied password in any branch
    - _Requirements: 4.1, 4.2, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 5.1, 5.3, 5.4, 6.1, 6.3, 12.2, 12.3, 12.4, 12.5_

  - [x] 4.2 Wire `routes/api.php`
    - `Route::post('login', [AuthController::class, 'login'])->name('auth.login')` registered **outside** the `auth:sanctum` group so anonymous logins work
    - Inside `Route::middleware('auth:sanctum')->group(...)`: `POST /logout` (`auth.logout`), `GET /user` (`auth.user`), and the existing `Route::apiResource('contacts', ContactController::class)->whereNumber('contact')`
    - Behaviour for authenticated Contact requests must be byte-for-byte identical to the existing Event CRM Contacts feature
    - _Requirements: 5.1, 5.2, 6.1, 6.2, 7.1, 7.2_

  - [ ]* 4.3 Property test: login round trip
    - `tests/Feature/Auth/LoginTest.php` using eris `forAll(genUserPopulation(1), genPassword())`
    - For any User `u` provisioned with password `p`: `POST /api/login {email: lowercase(trim(u.email)), password: p}` returns 200 with body `{data: UserResource(u)}`; the post-response session id differs from the pre-request session id; a follow-up `GET /api/user` with the same session cookie returns 200 with the same User payload; if a different User `v` was authenticated on the same session, the post-login session resolves to `u` and `v` is no longer reachable
    - **Property 9: Login round trip** — _Validates: Requirements 4.1, 4.2, 4.8, 6.1_
    - Tag with `[P9]`, `withIterations(100)`

  - [ ]* 4.4 Property test: login authentication failure indistinguishability
    - `tests/Feature/Auth/LoginTest.php` using eris `forAll(genUserPopulation(n>=1), genValidEmail(), genPassword())`
    - For any well-shaped login body where either the email matches no User or the password fails to verify: status is 401, body equals exactly `{message: <fixed string>}` (same string for both branches), no `UserResource` field appears in the body, and no Auth_Session is established
    - **Property 10: Login authentication failure indistinguishability** — _Validates: Requirements 4.4, 4.5_
    - Tag with `[P10]`, `withIterations(100)`

  - [ ]* 4.5 Property test: login throttle bounded by both keys
    - `tests/Feature/Auth/LoginTest.php` and `tests/Unit/Support/LoginRateLimiterTest.php` using eris with `Carbon::setTestNow` for deterministic windows
    - For any sequence `(t₁..tₙ)` of failed `POST /api/login` requests fixed on a given email `E` (or IP `I`) within a rolling 60s window: the response of `tᵢ` for every `i ≥ 6` is 429, the `Retry-After` header is an integer in `[1, 60]`, after 60s of no further attempts the next attempt is no longer throttled, and any successful login resets both `(E, I)` counters to zero
    - **Property 11: Login throttle bounded by both keys** — _Validates: Requirements 4.6, 4.7_
    - Tag with `[P11]`, `withIterations(100)` for the unit-level limiter test, `withIterations(50)` for the controller-level test (documented inline)

  - [ ]* 4.6 Property test: logout invalidates only the current session
    - `tests/Feature/Auth/LogoutTest.php` using eris to provision multiple Users and authenticated sessions
    - For any set of authenticated sessions `{s₁..sₖ}`, after `POST /api/logout` from `sⱼ`: response is 204 with empty body and a `Set-Cookie` directive that clears the session cookie; `sⱼ` resolves to anonymous on every endpoint thereafter; every other `sᵢ` (i≠j) continues to resolve to its respective User; no `users` row is modified
    - **Property 12: Logout invalidates only the current session** — _Validates: Requirements 5.1, 5.3, 5.4_
    - Tag with `[P12]`, `withIterations(100)`

  - [ ]* 4.7 Property test: `GET /api/user` is read-only
    - `tests/Feature/Auth/CurrentUserTest.php` using eris `forAll(genUserPopulation(1), positiveInts())`
    - For any User `u` and any sequence of `N ≥ 1` `GET /api/user` calls authenticated as `u`, the post-state row of `u` equals the pre-state row byte-for-byte across every column including `updated_at`
    - **Property 13: GET /api/user is read-only** — _Validates: Requirements 6.3_
    - Tag with `[P13]`, `withIterations(100)`

  - [ ]* 4.8 Property test: no per-user filter on Contact API
    - `tests/Feature/Auth/ContactsAuthGateTest.php` using eris to provision two distinct Users `u₁`, `u₂` and a population of Contacts
    - For every `GET`, `POST`, `PUT`, `PATCH`, `DELETE` on `/api/contacts*` performed while authenticated as `u₂`, the response (status, body, headers) equals the response defined by the Event CRM Contacts feature for that operation, irrespective of which (if any) User created or last updated the Contact
    - **Property 17: No per-user filter on Contact API** — _Validates: Requirements 7.3, 7.4_
    - Tag with `[P17]`, `withIterations(100)`

  - [ ]* 4.9 Example tests for non-property backend criteria
    - `tests/Feature/Auth/LoginTest.php`: 429 response is emitted within 1000ms of the throttled request (Requirement 4.7)
    - `tests/Feature/Auth/LogoutTest.php`: successful logout completes within 5000ms (Requirement 5.1)
    - `tests/Feature/Auth/LoginTest.php`: hashing failure injected via `Hash::shouldReceive('check')->andThrow` returns generic 500 with no plaintext password leaked (Requirements 2.9, 12.5)
    - _Requirements: 2.9, 4.7, 5.1, 12.5_

- [x] 5. Implement `crm:invite` artisan command
  - [x] 5.1 Implement `App\Console\Commands\InviteUser`
    - `app/Console/Commands/InviteUser.php` with `signature = 'crm:invite {email} {--password=}'` and the `#[AsCommand(name: 'crm:invite')]` attribute
    - Lower-case + trim `email`; reject via exit 1 if it fails `App\Rules\ValidEmail` or has length outside `[1, 254]`
    - If `--password` is supplied: validate length in `[8, 128]`; otherwise generate a 16..32 char password from `random_int` containing at least one lowercase, one uppercase, one digit, Fisher-Yates-shuffled
    - Wrap create/reset in `DB::transaction` using `User::where('email', ...)` then `forceFill(['password' => Hash::make(...)])->save()` for resets, `User::create(['name' => mb_substr(localPart, 0, 120), 'email' => ..., 'password' => Hash::make(...)])` for new accounts
    - On runtime/DB/hashing failure print a generic error to stderr and exit 2; never echo the supplied or generated password in error paths
    - On generated-password success, print the plaintext password exactly once on its own line to stdout; exit 0
    - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.9, 1.10, 1.11, 1.12, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, 12.1, 12.3_

  - [ ]* 5.2 Property test: provisioning round trip
    - `tests/Feature/Console/InviteUserTest.php` using eris `forAll(genValidEmail(), genPassword())`
    - For any valid email `e` and password `p`, after `php artisan crm:invite e --password=p` against any `users` pre-state `P`: if no User in `P` has `lowercase(trim(e))`, post-state has exactly one new User with the canonical email, `name = mb_substr(local-part(lowercase(trim(e))), 0, 120)`, `Hash::check(p, hash) === true`, `created_at == updated_at`; if a User `u` already has the canonical email, post-state has the same User with `id`, `name`, `email`, `created_at` unchanged, `Hash::check(p, hash) === true`, `updated_at` strictly greater than before; in both cases exit code is 0
    - **Property 1: Provisioning round trip** — _Validates: Requirements 1.1, 1.4, 1.5, 1.6, 1.7, 2.2, 2.3, 12.1_
    - Tag with `[P1]`, `withIterations(100)`

  - [ ]* 5.3 Property test: email uniqueness invariant
    - `tests/Feature/Console/InviteUserTest.php` using eris to drive arbitrary sequences of provisioning operations
    - For any sequence and any pre-state `S`, no two persisted `users` rows in the post-state share `lowercase(trim(email))`; for any attempted insert that would violate the invariant, the operation is rejected and the post-state equals the pre-state
    - **Property 3: Email uniqueness invariant** — _Validates: Requirements 1.3, 1.9_
    - Tag with `[P3]`, `withIterations(100)`

  - [ ]* 5.4 Property test: `created_at` immutability and `updated_at` monotonicity
    - `tests/Feature/Console/InviteUserTest.php` using eris to generate a User and an arbitrary sequence of post-creation operations
    - Asserts `u.created_at` in the post-state equals `u.created_at` at creation, and `u.updated_at` is monotonically non-decreasing across the sequence (strictly greater after any operation that modifies a non-identity field of `u`)
    - **Property 4: created_at immutability and updated_at monotonicity** — _Validates: Requirements 1.6, 1.7_
    - Tag with `[P4]`, `withIterations(100)`

  - [ ]* 5.5 Property test: generated password invariants
    - `tests/Feature/Console/InviteUserTest.php` invoking `crm:invite` without `--password` `N ∈ [2, 100]` consecutive times against eris-generated emails
    - Asserts each printed password `p` satisfies `mb_strlen(p) ∈ [16, 32]`, contains at least one `[a-z]`, one `[A-Z]`, one `[0-9]`, and that across `N` invocations the resulting set has cardinality `N` (no collisions)
    - **Property 7: Generated password invariants** — _Validates: Requirements 2.4, 2.8_
    - Tag with `[P7]`, `withIterations(100)`

  - [ ]* 5.6 Property test: provisioning atomicity
    - `tests/Feature/Console/InviteUserTest.php` using eris to generate invalid emails (per Property 2), invalid `--password` lengths in `[0, 7] ∪ [129, 200]`, and runtime/DB/hashing failures (mocked `Hash::make` throw)
    - Asserts `users` post-state equals pre-state byte-for-byte across every column, exit code is 1 (validation) or 2 (runtime), stderr is non-empty
    - **Property 8: Provisioning atomicity** — _Validates: Requirements 2.6, 2.7, 2.9_
    - Tag with `[P8]`, `withIterations(100)`

  - [ ]* 5.7 Property test: plaintext passwords never leak
    - `tests/Feature/Auth/PasswordHygieneTest.php` using eris `forAll(genPassword())` exercising both `POST /api/login` and `crm:invite` (with and without `--password`)
    - For any plaintext password `p`, no observable channel contains `p` as a substring after the operation: HTTP response body, HTTP response headers, stdout (except the explicit `crm:invite` generated-password line), stderr, every Laravel log channel (request, error, debug, exception trace), and every column of every persisted row across all tables
    - **Property 6: Plaintext passwords never leak** — _Validates: Requirements 1.5, 1.12, 2.5, 12.1, 12.2, 12.3, 12.4, 13.6_
    - Tag with `[P6]`, `withIterations(100)`

  - [ ]* 5.8 Example test for hashing failure
    - `tests/Feature/Console/InviteUserTest.php` mocks `Hash::make` to throw; assert exit code 2, stderr contains a generic message, no `users` row written, and the supplied/generated password is absent from stderr, stdout, and every log channel
    - _Requirements: 2.9, 12.5_

- [x] 6. Backend checkpoint
  - Ensure all backend tests pass, ask the user if questions arise.

- [x] 7. Set up shared frontend HTTP client and auth API client
  - [x] 7.1 Implement shared `resources/js/api/http.js`
    - `axios.create({ baseURL: '/api', timeout: 10_000, withCredentials: true, xsrfCookieName: 'XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN', headers: { Accept: 'application/json' } })`
    - Response interceptor normalises errors to `{ kind: 'http'|'network'|'timeout', status, message, fields }`; for `kind === 'http'` with `status === 401` and `url` not ending in `/login`, dynamically import `stores/auth.js` and call `auth.setUnauthenticated({ from: window.location })`
    - Export the `http` instance and the `normaliseError` helper
    - _Requirements: 3.2, 9.5, 12.2_

  - [x] 7.2 Implement `resources/js/api/auth.js`
    - Wraps the shared `http` client: `csrfCookieRequest = () => http.get('/sanctum/csrf-cookie', { baseURL: '' })`, `loginRequest(body) = http.post('/login', body)`, `logoutRequest() = http.post('/logout')`, `currentUserRequest() = http.get('/user')`
    - _Requirements: 3.2, 4.1, 5.1, 6.1, 8.2, 8.3, 10.3_

  - [x] 7.3 Refactor `resources/js/api/contacts.js` to use the shared `http` instance
    - Replace the local `axios.create(...)` with an import of `http` from `./http.js` so credentials and XSRF helpers apply to every Contact request
    - Preserve the existing per-method API and the existing error normalisation (now provided by `http.js`)
    - Update `resources/js/api/contacts.test.js` if it asserted the local axios instance directly
    - _Requirements: 3.4, 3.5, 7.2, 7.5_

- [x] 8. Implement auth Pinia store and `useAuth` composable
  - [x] 8.1 Implement `resources/js/stores/auth.js`
    - State: `{ user: null, status: 'unknown', deferredTarget: null, lastError: null, inflight: false, logoutInflight: false }`
    - Actions:
      - `csrfCookie()`: awaits `csrfCookieRequest()`
      - `probe()`: calls `currentUserRequest()`; on 200 set `status = 'authenticated'`, `user = response.data.data`; on 401 set `status = 'anonymous'`, `user = null`; otherwise set `status = 'error'`, store `lastError`
      - `login({ email, password })`: sets `inflight = true`, awaits `csrfCookie()` then `loginRequest({ email: email.trim().toLowerCase(), password })`; on success set `status = 'authenticated'`, `user = response.data.data`; always reset `inflight`
      - `logout()`: sets `logoutInflight = true`, calls `logoutRequest()`; on 204 or 401 call `setUnauthenticated()`; on other failures store `lastError` and leave auth state untouched; always reset `logoutInflight`
      - `setUnauthenticated({ from } = {})`: clears `user`, sets `status = 'anonymous'`; if the current route is in `{contacts.index, contacts.create, contacts.edit}` capture `route.fullPath` as `deferredTarget`; navigate to `{ name: 'login' }` unless already there
      - `consumeDeferredTarget()`: returns and clears `deferredTarget`
    - _Requirements: 8.6, 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 10.5, 10.6_

  - [x] 8.2 Implement `resources/js/composables/useAuth.js`
    - Thin wrapper around `useAuthStore()` exposing `user`, `isAuthenticated`, `status` for components that don't need the full store API
    - _Requirements: 9.1, 10.1, 10.2_

  - [ ]* 8.3 Property test: mount probe maps backend response to SPA auth state
    - `resources/js/__tests__/auth.store.spec.js` using `fast-check` + mocked axios
    - For any backend response to the first `GET /api/user`: status 200 with `{data: <user>}` ⇒ `auth.status === 'authenticated' && auth.user === user`; status 401 ⇒ `auth.status === 'anonymous' && auth.user === null`; status outside `{200, 401}`, network error, or 10s timeout ⇒ `auth.status === 'error' && auth.user === null`; in every case `app.use(router)` is only called after `auth.probe()` resolves
    - **Property 19: Mount probe maps backend response to SPA auth state** — _Validates: Requirements 9.1, 9.6_
    - `fc.assert(prop, { numRuns: 100 })`, tag `[P19]`

  - [ ]* 8.4 Property test: post-mount 401 reaction
    - `resources/js/__tests__/auth.store.spec.js` using `fast-check` to generate post-mount axios responses with `status === 401` for arbitrary URLs (excluding `/api/login`)
    - Asserts: `auth.status` becomes `'anonymous'`, `auth.user = null`; if the active route at the time of the 401 is in `{contacts.index, contacts.create, contacts.edit}` the SPA navigates to `/login` and stores the route's `fullPath` as `deferredTarget`; otherwise the active route is unchanged
    - **Property 22: Post-mount 401 reaction** — _Validates: Requirements 9.5_
    - `numRuns: 100`, tag `[P22]`

- [x] 9. Implement `Login.vue` view and router guard
  - [x] 9.1 Implement `resources/js/views/Login.vue`
    - Template: labelled email input (`type="email"`, `autocomplete="username"`), labelled password input (`type="password"`, `autocomplete="current-password"`), submit button (`type="submit"`), inline per-field error slots, form-level error message slot
    - On mount: `await auth.csrfCookie()` so the XSRF cookie is present before the first `POST /api/login`
    - On submit: trim email locally; if either field is empty after trim, set inline errors and return without calling the API; otherwise call `auth.login({ email, password })`
    - Bind submit `:disabled="auth.inflight"` so a second click cannot dispatch a second request
    - Map response status to UI: 200 → clear errors, navigate to `auth.consumeDeferredTarget() ?? '/'`; 401 → form error "Invalid email or password.", clear password, retain email; 422 → assign per-field errors from response `fields`; 429 → form error "Too many attempts. Please try again later.", clear password, retain email; 419/≥500/network/timeout → form error "Could not complete the request.", retain values
    - In every branch, re-enable the submit control
    - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9, 8.10_

  - [x] 9.2 Add `/login` route, global `beforeEach` guard, and `requiresAuth` meta
    - `resources/js/router/index.js`: register a `login` named route at `/login` mounting `Login.vue` with `meta.requiresAuth = false`; mark `contacts.index`, `contacts.create`, `contacts.edit` with `meta.requiresAuth = true`
    - Global `beforeEach` guard: if target is in `{contacts.index, contacts.create, contacts.edit}` and `auth.status !== 'authenticated'`, store `to.fullPath` as `auth.deferredTarget` and redirect to `{ name: 'login' }`; if target is `login` and `auth.status === 'authenticated'`, redirect to `/`; otherwise pass through
    - The existing `beforeEnter` for `contacts.edit` continues to fetch the contact, but only after the global guard allows the navigation
    - _Requirements: 8.1, 9.2, 9.3, 9.4_

  - [ ]* 9.3 Property test: `Login.vue` submission shape
    - `resources/js/__tests__/Login.spec.js` using Vitest + `@vue/test-utils` + `fast-check`
    - For any user-typed `(E, P)` and any number of submit clicks `K ≥ 1` before a response arrives: if `trim(E) === '' || P === ''`, zero `POST /api/login` and zero `POST /api/logout` requests are issued; otherwise exactly one `POST /api/login` is issued with `Content-Type: application/json`, body `{email: lowercase(trim(E)), password: P}`, header `X-XSRF-TOKEN` echoing the current `XSRF-TOKEN` cookie, and a 10-second timeout, and exactly one `GET /sanctum/csrf-cookie` has completed before that login request
    - **Property 23: Login.vue submission shape** — _Validates: Requirements 8.2, 8.3, 8.4, 8.5_
    - `numRuns: 100`, tag `[P23]`

  - [ ]* 9.4 Property test: `Login.vue` response → UI state mapping
    - `resources/js/__tests__/Login.spec.js` using `fast-check` over `fcLoginResponse()`
    - For any response (status `s`, body `b`, network error, 10s timeout) to a single `POST /api/login`, the post-response state matches the design's table for `auth.status`, `auth.user`, visible message, email field, password field, and active route; in every case `auth.inflight === false`
    - **Property 24: Login.vue response → UI state mapping** — _Validates: Requirements 8.6, 8.7, 8.8, 8.9, 8.10_
    - `numRuns: 100`, tag `[P24]`

  - [ ]* 9.5 Property test: router guard correctness
    - `resources/js/__tests__/AuthGuard.spec.js` using `fast-check` to generate `(from, to, auth.status)` triples
    - Asserts: target in protected set with `auth.status !== 'authenticated'` ⇒ post-guard route is `login` and `auth.deferredTarget === to.fullPath`; target is `login` with `auth.status === 'authenticated'` ⇒ post-guard route is `contacts.index`; otherwise post-guard route equals target
    - **Property 20: Router guard correctness** — _Validates: Requirements 9.2, 9.3_
    - `numRuns: 100`, tag `[P20]`

  - [ ]* 9.6 Property test: deferred target round trip
    - `resources/js/__tests__/AuthGuard.spec.js` using `fcDeferredPath()` (e.g. `/`, `/contacts/new`, `/contacts/42/edit?focus=name`, `/contacts/42/edit#email`)
    - For any anonymous initial state and any deferred path `to.fullPath` whose route name is in `{contacts.index, contacts.create, contacts.edit}`, after `navigate(to) → guard cancels → land on /login → submit valid Login_Form → API returns 200`, the active route's `fullPath` equals `to.fullPath` and `auth.deferredTarget === null`
    - **Property 21: Deferred target round trip** — _Validates: Requirements 8.6, 9.4_
    - `numRuns: 100`, tag `[P21]`

  - [ ]* 9.7 Example test: `Login.vue` static markup
    - `resources/js/__tests__/Login.spec.js`: assert the labelled email input (`type="email"`), labelled password input (`type="password"`), and single submit button (`type="submit"`) render with their accessible names (Requirement 8.1)
    - _Requirements: 8.1_

- [x] 10. Wire logout control in `App.vue`
  - [x] 10.1 Implement the logout control in `resources/js/App.vue`
    - In the application header render `<button type="button" :disabled="auth.logoutInflight" @click="auth.logout()" aria-label="Log out">Log out</button>` accompanied by `auth.user?.email`, conditionally rendered by `v-if="auth.status === 'authenticated'"`
    - On click guard: short-circuit with a non-blocking toast "Could not complete log out." if no `XSRF-TOKEN` cookie is present (no API call issued, auth state retained)
    - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_

  - [ ]* 10.2 Property test: logout control rendering and submit
    - `resources/js/__tests__/LogoutControl.spec.js` using `fast-check` over auth state and click sequences
    - Asserts the control is rendered iff `auth.status === 'authenticated'`; when rendered, accessible name contains "Log out" and displayed text contains `auth.user.email`; for any number of clicks during a single in-flight logout, exactly one `POST /api/logout` is issued and only when a non-empty `XSRF-TOKEN` cookie is present at click time; if the cookie is absent or empty, zero `POST /api/logout` requests are issued
    - **Property 25: Logout control rendering and submit** — _Validates: Requirements 10.1, 10.2, 10.3, 10.4, 10.7_
    - `numRuns: 100`, tag `[P25]`

  - [ ]* 10.3 Property test: logout response → UI state mapping
    - `resources/js/__tests__/LogoutControl.spec.js` using `fast-check` over `fcLogoutResponse()`
    - Asserts: status 204 or 401 ⇒ `auth.status === 'anonymous'`, `auth.user === null`, `auth.deferredTarget === null`, active route is `/login`; status 419, status ≥ 500, network error, or 10s timeout ⇒ `auth.status === 'authenticated'`, `auth.user` unchanged, control re-enabled, non-blocking error message displayed
    - **Property 26: Logout response → UI state mapping** — _Validates: Requirements 10.5, 10.6_
    - `numRuns: 100`, tag `[P26]`

- [x] 11. Wire frontend mount sequence in `app.js`
  - [x] 11.1 Update `resources/js/app.js`
    - Reorder mount: `createApp(App)`, `app.use(createPinia())`, then `await useAuthStore(pinia).probe()` before `app.use(router)` and `app.mount('#app')`
    - The probe always resolves (`status` becomes `authenticated`, `anonymous`, or `error`) so the SPA never gets stuck
    - _Requirements: 9.1, 9.6_

  - [ ]* 11.2 Example test: mount probe non-200/401 fallback
    - `resources/js/__tests__/auth.store.spec.js`: with mocked axios returning 503, network error, or no response within 10 seconds, assert `auth.status === 'error'`, a non-blocking error toast is queued ("Connection failed."), and if the entry route is in `{contacts.index, contacts.create, contacts.edit}` the SPA navigates to `/login` storing the entry route as `deferredTarget`
    - _Requirements: 9.6_

- [ ] 12. Final integration and wiring
  - [ ] 12.1 Verify end-to-end auth gate
    - Confirm `routes/api.php` has `auth.login`, `auth.logout`, `auth.user`, and the five `apiResource` Contact routes wrapped in `auth:sanctum`
    - Confirm `App.vue` shows the logout control only when `auth.status === 'authenticated'`, the `Login.vue` route is reachable at `/login`, and the global router guard redirects anonymous protected navigations through `/login` with deferred-target round trip
    - Confirm `npm run build` produces a working bundle and `php artisan serve` returns the SPA shell on `/login` and JSON 401 on `/api/contacts` for anonymous requests
    - _Requirements: 7.1, 7.2, 8.1, 8.6, 9.2, 9.3, 9.4, 10.1, 10.5_

- [~] 13. Final checkpoint
  - Ensure all backend and frontend tests pass, ask the user if questions arise.

## Notes

- Tasks marked with `*` are optional and can be skipped for a faster MVP. The 26 property test sub-tasks (1.4, 2.2–2.4, 3.4–3.5, 4.3–4.8, 5.2–5.7, 8.3–8.4, 9.3–9.6, 10.2–10.3) and the example tests (1.5, 4.9, 5.8, 9.7, 11.2) are all optional sub-tasks under non-optional parent tasks.
- Each property test sub-task explicitly references its property number from the design (Property 1..26) and the requirement clauses it validates, per the design's PBT conventions (`[P{N}]` tag, ≥100 iterations on backend with `eris/eris`, `numRuns: 100` on frontend with `fast-check`).
- Property 11 (login throttle) caps the controller-level run at 50 iterations because each iteration involves real cache writes and `Carbon::setTestNow` decay; the unit-level `LoginRateLimiterTest` runs the full 100. This deviation is documented inline in the test.
- Property 6 (plaintext password leak) is placed under task 5 because `crm:invite` is the higher-risk leak channel; it sweeps both `POST /api/login` and the artisan command in a single test.
- Backend property tests run on in-memory SQLite (`phpunit.xml`) with `RefreshDatabase`, and the rate limiter falls back to `array` cache during tests via `CACHE_STORE=array` in `phpunit.xml`'s `<server>` block.
- Frontend property tests use Vitest + `happy-dom` + mocked axios, with `fc.scheduler` or fake timers where ordering must be exercised (e.g. CSRF before login, single-submit gating).
- Checkpoints (6, 13) are the recommended pause points before moving from backend → frontend and before declaring the feature complete.

## Task Dependency Graph

```json
{
  "waves": [
    { "id": 0, "tasks": ["1.1", "1.3", "3.1", "3.2", "3.3", "7.1"] },
    { "id": 1, "tasks": ["1.2", "3.4", "5.1", "7.2", "7.3"] },
    { "id": 2, "tasks": ["2.1", "4.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.8", "8.1"] },
    { "id": 3, "tasks": ["4.2", "8.2", "9.1", "10.1"] },
    { "id": 4, "tasks": ["8.3", "8.4", "9.2", "11.1"] },
    { "id": 5, "tasks": ["1.4", "1.5", "2.2", "2.3", "2.4", "3.5", "4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "5.7", "9.3", "9.4", "9.5", "9.6", "9.7", "10.2", "10.3", "11.2"] },
    { "id": 6, "tasks": ["12.1"] }
  ]
}
```
