# Design Document

## Overview

The CRM Authentication feature gates the existing Event CRM Contacts application behind a first-party cookie/session login wall. It is delivered as a thin layer on top of the existing Laravel 13 / PHP 8.3 backend and the existing Vue 3 SPA: no new framework, no new database, no new transport. The mechanism is **Laravel Sanctum's SPA mode** — the same `web` session guard the framework already ships with, framed by Sanctum's stateful-domain check and CSRF cookie endpoint. No API tokens, no bearer tokens, no "remember me" cookies.

The feature touches the application at six places:

1. **A single new auth controller** (`AuthController`) with three actions: `login`, `logout`, `currentUser`.
2. **A single artisan command** (`crm:invite`) that creates or resets a User on the server.
3. **A login throttle** built on Laravel's existing `Illuminate\Cache\RateLimiter`, keyed by lower-cased email and by client IP, with rolling 60-second windows (5 attempts each).
4. **The existing `/api/contacts` resource** is wrapped in the `auth:sanctum` middleware so anonymous requests resolve to a JSON `401` instead of a Laravel redirect.
5. **The existing JSON error envelope in `bootstrap/app.php`** (`{ message, errors? }`) gains two additional render closures so 401/419/429 responses fit the same shape — Sanctum's default 419 redirect, the default 401 unauthenticated redirect, and the rate limiter's 429 are all rewritten into `application/json; charset=utf-8` bodies.
6. **The Vue SPA** gains a `Login.vue` view, an `auth` Pinia store, an `auth.js` API client, an axios response interceptor, a router guard, and a logout control in `App.vue`'s header. The mount sequence in `app.js` is reordered so that a `GET /api/user` probe completes before the router is activated.

The design is shaped by three decisions:

- **Reuse the existing JSON error envelope.** The current `bootstrap/app.php` already maps `ValidationException`, `NotFoundHttpException`, `BadRequestHttpException`, and a generic `\Throwable` to `{ message, errors? }`. Adding `AuthenticationException → 401`, `\Illuminate\Session\TokenMismatchException → 419`, and `Illuminate\Http\Exceptions\ThrottleRequestsException → 429` slots in alongside them. The existing `EnforceJsonContentType` middleware already pins the `Content-Type` to `application/json; charset=utf-8` for `/api/*` responses (see `app/Http/Middleware/EnforceJsonContentType.php`); it transparently covers the new auth responses too, satisfying Requirement 13.1 without a new piece of middleware.
- **Reuse the existing `users` table.** Laravel's default migration (`0001_01_01_000000_create_users_table.php`) already provides `id`, `name`, `email` (unique), `password`, `created_at`, `updated_at`, plus the `password_reset_tokens` and `sessions` tables. The existing `App\Models\User` model already carries `#[Hidden(['password', 'remember_token'])]` and `'password' => 'hashed'`, so user serialisation never leaks the hash. This feature ships with **no new migrations** unless `email` needs a length cap, which is handled by tightening it in a new migration `2026_xx_xx_xxxxxx_tighten_users_email.php` that does `ALTER … MODIFY email VARCHAR(254) NOT NULL UNIQUE`.
- **Mirror the Contact API style for the new endpoints.** The new `AuthController` uses `FormRequest` classes (`LoginRequest`), an `App\Http\Resources\UserResource`, and the same `application/json; charset=utf-8` envelope. This keeps the API uniform with `/api/contacts`.

### Research Notes

A small set of platform decisions was confirmed against the actual project state and the documentation linked below.

- **Sanctum is not yet installed.** `composer.json` has `laravel/framework: ^13.8` and no `laravel/sanctum`. The package is added with `composer require laravel/sanctum:^4`, then published with `php artisan install:api --without-migration-prompt`. Per the [Sanctum SPA documentation](https://laravel.com/docs/13.x/sanctum#spa-authentication) (paraphrased for licensing compliance), Sanctum SPA mode does not issue tokens; it relies on the existing `web` guard and a stateful-domain check to decide whether a request from a first-party SPA should be authenticated by cookie/session. Two pieces of plumbing are therefore needed: the `auth:sanctum` middleware on the API routes (Sanctum's guard transparently delegates to the `web` guard for stateful domains), and Sanctum's `EnsureFrontendRequestsAreStateful` middleware prepended to the `api` group so the `XSRF-TOKEN` cookie and stateful session resolution are available. *Content was rephrased for compliance with licensing restrictions.*
- **Laravel 13 ships without `php artisan install:api`'s Sanctum config by default.** We publish `config/sanctum.php` and add `SANCTUM_STATEFUL_DOMAINS` to `.env.example` with a comma-separated list (defaulting to `localhost,localhost:5173,127.0.0.1,127.0.0.1:5173,::1`). For production this is set to the host that serves the Frontend.
- **The `web` session guard already exists** (`config/auth.php` `defaults.guard = web`, `config/session.php` `driver = database`, lifetime 120 minutes). No new guard is required. The 120-minute idle lifetime in Requirement 11.1 is exactly the framework default and is achieved by leaving `SESSION_LIFETIME=120` in `.env`.
- **The session cookie defaults match Requirement 3.6.** `config/session.php` has `'http_only' => env('SESSION_HTTP_ONLY', true)` and `'same_site' => env('SESSION_SAME_SITE', 'lax')`, with `'secure'` defaulting to `null` (auto-detect). This satisfies HttpOnly and SameSite=lax out of the box; the design pins `SESSION_SECURE_COOKIE=true` for the production `.env`, leaves it auto-detect for local development.
- **The cache backend is the database driver.** `config/cache.php` `default = database`. Laravel's `Illuminate\Cache\RateLimiter` therefore stores its counters in the `cache` table, which the project already migrates. No Redis or Memcached dependency is required.
- **Pest 4 + `eris/eris` are already installed** (see `composer.json` `require-dev`). The same PBT setup that the Event CRM Contacts feature uses applies here. On the frontend, `fast-check`, `vitest`, `happy-dom`, `@vue/test-utils` are already installed (see `package.json`).

### High-Level Diagram

```mermaid
flowchart LR
    Browser[User Browser]
    subgraph Frontend [Vue 3 SPA - resources/js]
      Mount[app.js bootstrap]
      Router[Vue Router + auth guard]
      AuthStore[Pinia auth store]
      ContactsStore[Pinia contacts store]
      LoginView[Login.vue]
      ListView[ContactListView.vue]
      Header[App.vue header / logout control]
      AuthApi[api/auth.js]
      ContactsApi[api/contacts.js]
    end
    subgraph Backend [Laravel 13 API]
      SanctumCsrf[GET /sanctum/csrf-cookie]
      Routes[routes/api.php]
      Stateful[EnsureFrontendRequestsAreStateful]
      AuthGuard[auth:sanctum middleware]
      AuthCtl[AuthController]
      Throttle[Login throttle by email and IP]
      ContactCtl[ContactController existing]
      Exceptions[Exception render closures in bootstrap/app.php]
      Console[App\Console\Commands\InviteUser - crm:invite]
      DB[(MySQL users + sessions + cache)]
    end

    Browser -->|GET /api/user mount probe| AuthApi
    Browser -->|GET /sanctum/csrf-cookie| SanctumCsrf
    Mount --> AuthApi
    AuthApi --> Stateful --> AuthGuard --> AuthCtl
    AuthApi -. CSRF cookie .-> SanctumCsrf
    Mount --> AuthStore
    Mount --> Router --> ListView
    Router --> LoginView
    LoginView --> AuthStore --> AuthApi
    Header --> AuthStore
    ContactsStore --> ContactsApi --> Stateful --> AuthGuard --> ContactCtl
    AuthCtl --> Throttle
    AuthCtl --> DB
    ContactCtl --> DB
    AuthCtl -. errors .-> Exceptions
    ContactCtl -. errors .-> Exceptions
    Console --> DB
```

## Architecture

### Backend Layering

The auth feature follows the same Laravel layering used by the Contact API. Every cell below is either a new file or a small additive change to an existing file.

| Layer | File | Status | Responsibility |
| --- | --- | --- | --- |
| Routing | `routes/api.php` | Modified | Adds `/login`, `/logout`, `/user` routes, wraps Contact routes with `auth:sanctum`. |
| Routing | `routes/web.php` | Unchanged | Catch-all SPA shell already excludes `/api`; Sanctum's `/sanctum/csrf-cookie` is registered by `Sanctum::routes()` automatically. |
| Bootstrap | `bootstrap/app.php` | Modified | Prepends `EnsureFrontendRequestsAreStateful` to the `api` group; adds 401/419/429 render closures to the JSON error envelope. |
| Controller | `app/Http/Controllers/Api/AuthController.php` | New | Three actions: `login`, `logout`, `currentUser`. |
| Form Request | `app/Http/Requests/LoginRequest.php` | New | Validates `email`/`password` shape, normalises `email`. |
| Resource | `app/Http/Resources/UserResource.php` | New | Single source of truth for the User wire shape (`id`, `name`, `email`, `created_at`, `updated_at`). |
| Throttle | `App\Support\LoginRateLimiter` | New | Pure helper around `Illuminate\Cache\RateLimiter` with `tooManyAttempts(email, ip): ?int retryAfter`, `hit(email, ip)`, `clear(email, ip)`. |
| Console | `app/Console/Commands/InviteUser.php` | New | The `crm:invite` artisan command. |
| Migration | `database/migrations/2026_xx_xx_xxxxxx_tighten_users_email.php` | New | `ALTER TABLE users MODIFY email VARCHAR(254) NOT NULL UNIQUE` (idempotent on MySQL; SQLite test runs fall back to recreating the column). |
| Config | `config/sanctum.php` | New (published) | `stateful` reads from `SANCTUM_STATEFUL_DOMAINS`; no token guards are configured. |
| Env | `.env.example` | Modified | Adds `SANCTUM_STATEFUL_DOMAINS`, pins `SESSION_LIFETIME=120`, documents `SESSION_SECURE_COOKIE`. |

#### Why the existing JSON envelope already covers most of this

`bootstrap/app.php` is currently shaped as a sequence of `withExceptions(...)` render closures, each guarded by `$request->is('api/*')`. The auth feature adds three peers:

- `AuthenticationException` → `401 { message: "Unauthenticated." }`. Without this closure Laravel would redirect anonymous browsers to a `route('login')` (which does not exist on this app) — Requirement 7.1 forbids redirects.
- `Illuminate\Session\TokenMismatchException` → `419 { message: "CSRF token mismatch." }`. Sanctum's stateful middleware uses Laravel's `VerifyCsrfToken` under the hood, which throws this exception. The default response is also a redirect.
- `Illuminate\Http\Exceptions\ThrottleRequestsException` → `429 { message: <string> }` with a `Retry-After` header. Used both by the login throttle and by future protection of other endpoints.

Each closure runs only when `$request->is('api/*')`, leaving non-API routes untouched.

#### Sanctum middleware wiring

```php
// bootstrap/app.php (excerpt)
->withMiddleware(function (Middleware $middleware): void {
    $middleware->prepend(\App\Http\Middleware\EnforceJsonContentType::class);

    $middleware->appendToGroup('api', \App\Http\Middleware\EnsureJsonRequestBody::class);

    // Sanctum SPA: must run on /api so that the XSRF cookie is read,
    // stateful domains are matched, and the web session is started for
    // first-party requests. This must come BEFORE substituteBindings.
    $middleware->prependToGroup('api', \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class);
})
```

`EnsureFrontendRequestsAreStateful` does three things on a stateful request: starts the session if one isn't open, decrypts the `XSRF-TOKEN` cookie value, and applies CSRF verification on `POST/PUT/PATCH/DELETE`. Stateless requests (no Origin/Referer match) skip those steps and the request continues as if no auth context exists.

### Login Throttle

The `LoginRateLimiter` helper is a thin wrapper around `Illuminate\Cache\RateLimiter` (already provided by Laravel and backed by the `database` cache store on this project — see `config/cache.php`). It exposes three methods over two named buckets per request:

```php
final class LoginRateLimiter
{
    public function __construct(private readonly RateLimiter $limiter) {}

    public function tooManyAttempts(string $emailLowerTrimmed, string $ip): ?int
    {
        $emailKey = 'login:e:'.sha1($emailLowerTrimmed);
        $ipKey    = 'login:i:'.$ip;

        if ($this->limiter->tooManyAttempts($emailKey, 5)) {
            return $this->limiter->availableIn($emailKey);
        }
        if ($this->limiter->tooManyAttempts($ipKey, 5)) {
            return $this->limiter->availableIn($ipKey);
        }
        return null;
    }

    public function hit(string $emailLowerTrimmed, string $ip): void
    {
        $this->limiter->hit('login:e:'.sha1($emailLowerTrimmed), 60);
        $this->limiter->hit('login:i:'.$ip, 60);
    }

    public function clear(string $emailLowerTrimmed, string $ip): void
    {
        $this->limiter->clear('login:e:'.sha1($emailLowerTrimmed));
        $this->limiter->clear('login:i:'.$ip);
    }
}
```

Notes:

- `sha1($emailLowerTrimmed)` keeps the cache key length bounded and avoids leaking the email plaintext into the cache table's row keys.
- Both counters are independent (Requirement 4.6). `hit` increments both and `clear` clears both on a successful login (Requirement 4.7 implicitly, by preventing legitimate users from being throttled by their own success).
- `availableIn` returns the integer seconds until either bucket frees up (Laravel's `RateLimiter::availableIn` is documented to return seconds in the closed interval `[1, decay]`). The controller emits this as the `Retry-After` header.

### Login flow

```mermaid
sequenceDiagram
    participant SPA as Login.vue
    participant Csrf as GET /sanctum/csrf-cookie
    participant LoginEp as POST /api/login
    participant Stateful as EnsureFrontendRequestsAreStateful
    participant Throttle as LoginRateLimiter
    participant Auth as Auth::guard('web')
    participant DB as users table

    SPA->>Csrf: GET (on mount)
    Csrf-->>SPA: 204 + Set-Cookie XSRF-TOKEN, session cookie
    SPA->>LoginEp: POST { email, password } + X-XSRF-TOKEN
    LoginEp->>Stateful: middleware: verify CSRF, start session
    Stateful->>LoginEp: continue
    LoginEp->>Throttle: tooManyAttempts(email, ip)?
    alt 429
        Throttle-->>LoginEp: retryAfter seconds
        LoginEp-->>SPA: 429 { message } + Retry-After
    else proceed
        LoginEp->>DB: find user by lower(trim(email))
        alt found and password verified
            LoginEp->>Auth: Auth::login(user); regenerate session id
            LoginEp->>Throttle: clear(email, ip)
            LoginEp-->>SPA: 200 { data: UserResource }
        else
            LoginEp->>Throttle: hit(email, ip)
            LoginEp-->>SPA: 401 { message: "Invalid credentials." }
        end
    end
```

### Logout flow

```mermaid
sequenceDiagram
    participant SPA as App.vue logout button
    participant LogoutEp as POST /api/logout
    participant Auth as Auth::guard('web')
    participant Session as Illuminate\Contracts\Session\Session

    SPA->>LogoutEp: POST + X-XSRF-TOKEN
    LogoutEp->>Auth: logout()
    LogoutEp->>Session: invalidate(); regenerateToken()
    LogoutEp-->>SPA: 204 + clear session cookie
    SPA->>SPA: store.user = null; router.push('/login')
```

### Frontend Layering

The SPA already has a Pinia store and an axios client for contacts. The auth feature adds parallel structures for users and a single global concern (the response interceptor + router guard) that affects every request and every navigation.

| Layer | Path | Status | Responsibility |
| --- | --- | --- | --- |
| Bootstrap | `resources/js/app.js` | Modified | Awaits `auth.probe()` before calling `app.use(router)` so the first navigation already knows the auth state. |
| Root layout | `resources/js/App.vue` | Modified | Renders the logout control conditionally (`auth.user !== null`). Pulls user email into the header. |
| Routing | `resources/js/router/index.js` | Modified | Registers the `login` route, attaches a global `beforeEach` guard, sets each protected route's `meta.requiresAuth = true`. |
| Store | `resources/js/stores/auth.js` | New | Holds `{ user, status, deferredTarget, lastError, inflight }`; actions: `probe`, `login`, `logout`, `setUnauthenticated`. |
| API client | `resources/js/api/auth.js` | New | Wraps `axios` with `csrfCookie`, `login`, `logout`, `currentUser`. Reuses the global `http` instance configured below. |
| API client | `resources/js/api/http.js` | New (extracted) | Shared axios instance for all API calls (baseURL `/api`, timeout 10s, `withCredentials: true`, `xsrfCookieName: 'XSRF-TOKEN'`, `xsrfHeaderName: 'X-XSRF-TOKEN'`). The contacts client and the auth client both import this. |
| API client | `resources/js/api/contacts.js` | Modified | Reuses the new shared `http.js` (so it gets credentials + XSRF on every request). Existing error normalisation is preserved. |
| Interceptor | `resources/js/api/http.js` | New | A response interceptor inspects every API failure: when `status === 401` AND the URL is not `/api/login`, it dispatches `auth.setUnauthenticated()` (which captures the deferred target and pushes to `/login`). |
| View | `resources/js/views/Login.vue` | New | Login_View per Requirement 8: labelled email/password inputs, submit button, inline error display. |
| Composable | `resources/js/composables/useAuth.js` | New | Thin wrapper around `useAuthStore()` for components that just want `user`/`isAuthenticated`. |
| Test | `resources/js/stores/auth.test.js`, `resources/js/__tests__/Login.spec.js`, `resources/js/__tests__/AuthGuard.spec.js`, `resources/js/api/auth.test.js` | New | Vitest + `@vue/test-utils` + `fast-check`. |

#### Mount sequence

```js
// resources/js/app.js (modified)
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { useAuthStore } from './stores/auth.js';

const app = createApp(App);
const pinia = createPinia();
app.use(pinia);

const auth = useAuthStore(pinia);
await auth.probe();              // GET /api/user before router activates (Req 9.1)

app.use(router);
app.mount('#app');
```

The probe always resolves (it sets `status` to `authenticated`, `anonymous`, or `error`) so the SPA never gets stuck.

#### Router guard

```js
// resources/js/router/index.js (modified)
const router = createRouter({ ... });

router.beforeEach((to, from) => {
    const auth = useAuthStore();
    const protectedRoute = ['contacts.index', 'contacts.create', 'contacts.edit'].includes(to.name);

    if (protectedRoute && auth.status !== 'authenticated') {
        auth.deferredTarget = to.fullPath;          // Req 9.4
        return { name: 'login' };
    }

    if (to.name === 'login' && auth.status === 'authenticated') {
        return { path: '/' };                        // Req 9.3
    }

    return true;
});
```

#### Response interceptor

```js
// resources/js/api/http.js (excerpt)
http.interceptors.response.use(
    (r) => r,
    (error) => {
        const apiError = normaliseError(error);
        const url = error?.config?.url ?? '';
        const isLogin = url.endsWith('/login');

        if (apiError.kind === 'http' && apiError.status === 401 && !isLogin) {
            // Lazy import to avoid circular pinia dependency at module load.
            import('../stores/auth.js').then(({ useAuthStore }) => {
                const auth = useAuthStore();
                auth.setUnauthenticated({ from: window.location });
            });
        }
        return Promise.reject(apiError);
    },
);
```

`auth.setUnauthenticated({ from })` clears `auth.user`, sets `auth.status = 'anonymous'`, captures the current path as `deferredTarget` if and only if the current path's route name is one of `contacts.{index,create,edit}` (Requirement 9.5), then triggers `router.replace({ name: 'login' })`.

### URL → middleware → handler trace

| URL + method | Middleware applied (in order) | Handler |
| --- | --- | --- |
| `GET /sanctum/csrf-cookie` | `EnforceJsonContentType`, `web` group | `Sanctum::csrf` (built-in) |
| `POST /api/login` | `EnforceJsonContentType`, `EnsureFrontendRequestsAreStateful`, `EnsureJsonRequestBody`, `throttle:none` | `AuthController@login` |
| `POST /api/logout` | …, `auth:sanctum` | `AuthController@logout` |
| `GET /api/user` | …, `auth:sanctum` | `AuthController@currentUser` |
| `GET\|POST\|PUT\|PATCH\|DELETE /api/contacts*` | `EnforceJsonContentType`, `EnsureFrontendRequestsAreStateful`, `EnsureJsonRequestBody`, `auth:sanctum` | `ContactController@*` (existing) |

`EnforceJsonContentType` is global so it also normalises responses for unmatched API URLs. `EnsureJsonRequestBody` runs after the stateful middleware so CSRF verification happens before body parsing. `auth:sanctum` is **not** applied to `/api/login` (logging in must work for anonymous requests), and is **applied** to the Contact routes (Requirement 7).

## Components and Interfaces

### Backend Components

#### `routes/api.php` (modified)

```php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ContactController;
use Illuminate\Support\Facades\Route;

Route::post('login',  [AuthController::class, 'login'])->name('auth.login');

Route::middleware('auth:sanctum')->group(function () {
    Route::post('logout', [AuthController::class, 'logout'])->name('auth.logout');
    Route::get('user',    [AuthController::class, 'currentUser'])->name('auth.user');

    Route::apiResource('contacts', ContactController::class)->whereNumber('contact');
});
```

The `apiResource` line is moved inside the `auth:sanctum` group; everything else about it (controller, model binding, `whereNumber`) stays. The `route:list` shape is therefore identical to the existing one except for the added middleware (Requirement 7.2: behaviour for authenticated users is unchanged).

`Sanctum::routes()` is registered automatically by the package's service provider when `install:api` runs; it adds `GET /sanctum/csrf-cookie` to the `web` middleware group at the application root.

#### `AuthController`

```php
final class AuthController extends Controller
{
    public function __construct(private readonly LoginRateLimiter $limiter) {}

    public function login(LoginRequest $request): JsonResponse
    {
        $email = $request->validated('email');     // already lower-cased + trimmed
        $password = $request->validated('password');
        $ip = $request->ip();

        if (($retryAfter = $this->limiter->tooManyAttempts($email, $ip)) !== null) {
            return response()->json(
                ['message' => 'Too many login attempts. Please try again later.'],
                429,
                ['Retry-After' => (string) $retryAfter],
            );
        }

        $user = User::where('email', $email)->first();
        if ($user === null || ! Hash::check($password, $user->password)) {
            $this->limiter->hit($email, $ip);
            return response()->json(['message' => 'Invalid credentials.'], 401);
        }

        Auth::guard('web')->login($user);
        $request->session()->regenerate();
        $this->limiter->clear($email, $ip);

        return response()->json(['data' => new UserResource($user->fresh())], 200);
    }

    public function logout(Request $request): Response
    {
        Auth::guard('web')->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return response()->noContent();   // 204
    }

    public function currentUser(Request $request): JsonResource
    {
        return UserResource::make($request->user())->additional(['data' => true])
                          ->response()->setStatusCode(200)
                          ->setData(['data' => UserResource::make($request->user())->resolve()]);
    }
}
```

The `currentUser` method is written with explicit `setData` to keep the wire shape `{"data": {...}}` (Requirement 6.1).

Notes:

- `LoginRequest` does the email lower-casing + trim in `prepareForValidation`, so by the time the controller reads `validated('email')` the value is already canonical (Requirement 4.1, 4.3).
- `User::where('email', ...)` is a constant-time-equivalent lookup once the column has the unique index (already migrated). The `->password === null` and the `Hash::check` branch return the same fixed message (Requirement 4.4, 4.5) — there is no early `email-not-found` 401 with a different body.
- The `$request->session()->regenerate()` call mints a new session id on success (Requirement 4.1) and invalidates any existing session referenced by the same Auth_Session (Requirement 4.8) because Laravel's session handler binds at most one session id per cookie.
- `Auth::guard('web')->logout()` also forgets the user from the session payload; combined with `invalidate()`, every subsequent request that carries the same cookie is anonymous (Requirement 5.3).

#### `LoginRequest`

```php
final class LoginRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        $email = $this->input('email');
        $this->merge([
            'email' => is_string($email) ? mb_strtolower(trim($email)) : $email,
        ]);
    }

    public function rules(): array
    {
        return [
            'email'    => ['required', 'string', 'min:1', 'max:254', new ValidEmail()],
            'password' => ['required', 'string', 'min:1', 'max:256'],
        ];
    }

    public function authorize(): bool { return true; }
}
```

Reuse of the existing `App\Rules\ValidEmail` is deliberate (Requirements 1.2, 1.3): the format rule that polices Contact emails also polices login emails. Failures here produce 422 via the standard `ValidationException` render closure (Requirement 4.3).

#### `UserResource`

```php
final class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'created_at' => $this->created_at?->utc()->format('Y-m-d\TH:i:s\Z'),
            'updated_at' => $this->updated_at?->utc()->format('Y-m-d\TH:i:s\Z'),
        ];
    }
}
```

Identical timestamp format to `ContactResource` for consistency (Requirement 1.8 — only the safe fields appear). Critically `password` and `remember_token` are absent.

#### `App\Console\Commands\InviteUser` (`crm:invite`)

```php
#[AsCommand(name: 'crm:invite', description: 'Create or reset a CRM user.')]
final class InviteUser extends Command
{
    protected $signature = 'crm:invite {email} {--password=}';

    public function handle(): int
    {
        $rawEmail = (string) $this->argument('email');
        $email = mb_strtolower(trim($rawEmail));

        if (! (new ValidEmail())->passes('email', $email) || mb_strlen($email) < 1 || mb_strlen($email) > 254) {
            $this->error('Invalid --email argument.');
            return 1;
        }

        $password = $this->option('password');
        $generated = false;
        if ($password === null) {
            $password = $this->generatePassword();      // 16..32 chars; lower+upper+digit
            $generated = true;
        } elseif (! is_string($password) || mb_strlen($password) < 8 || mb_strlen($password) > 128) {
            $this->error('Invalid --password option.');
            return 1;
        }

        try {
            DB::transaction(function () use ($email, $password) {
                $user = User::where('email', $email)->first();
                if ($user) {
                    $user->forceFill(['password' => Hash::make($password)])->save();
                } else {
                    User::create([
                        'name'     => mb_substr($this->localPart($email), 0, 120),
                        'email'    => $email,
                        'password' => Hash::make($password),
                    ]);
                }
            });
        } catch (\Throwable $e) {
            $this->error('Failed to provision user.');
            // Note: the exception itself is intentionally not echoed to avoid
            // leaking the supplied password if it appears in driver-formatted
            // SQL errors. The full stack trace is still written to the
            // application log via Laravel's default exception reporter.
            return 2;
        }

        if ($generated) {
            $this->line($password);                      // exactly once, on its own line
        }
        return 0;
    }

    private function generatePassword(): string
    {
        $length = random_int(16, 32);
        $alphabets = ['abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', '0123456789'];
        $required = array_map(fn (string $a) => $a[random_int(0, strlen($a) - 1)], $alphabets);
        $pool = implode('', $alphabets);
        $rest = [];
        for ($i = count($required); $i < $length; $i++) {
            $rest[] = $pool[random_int(0, strlen($pool) - 1)];
        }
        $chars = array_merge($required, $rest);
        // Fisher-Yates shuffle so the required chars aren't always at the start.
        for ($i = count($chars) - 1; $i > 0; $i--) {
            $j = random_int(0, $i);
            [$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]];
        }
        return implode('', $chars);
    }

    private function localPart(string $email): string
    {
        $at = mb_strpos($email, '@');
        return $at === false ? $email : mb_substr($email, 0, $at);
    }
}
```

Notes:

- The plaintext password supplied via `--password` never enters `--password`'s log/echo path (`signature` declares it, but `handle()` reads it through `$this->option('password')` and then never echoes it). Requirement 2.5.
- The transaction guarantees that on hashing or DB failure, no partially-written row remains. Requirement 2.9.
- `DB::transaction` plus `forceFill` is used for password reset because `password` is in `$fillable` already (`#[Fillable(['name','email','password'])]`).

#### Email column tightening migration

```php
return new class extends Migration {
    public function up(): void {
        Schema::table('users', function (Blueprint $table) {
            $table->string('email', 254)->unique()->change();
        });
    }
    public function down(): void {
        Schema::table('users', function (Blueprint $table) {
            $table->string('email')->unique()->change();
        });
    }
};
```

Required because the default `users` migration uses `string('name')` / `string('email')` which on MySQL maps to `VARCHAR(255)`. Requirement 1.2's 254-character upper bound needs the column tightened to 254 to keep storage and validation aligned.

### Frontend Components

#### `Login.vue`

```text
<template>
  <form @submit.prevent="onSubmit" novalidate aria-labelledby="login-heading">
    <h1 id="login-heading">Log in</h1>
    <label for="login-email">Email</label>
    <input id="login-email" type="email" autocomplete="username" v-model="email" :disabled="auth.inflight" />
    <p v-if="errors.email">{{ errors.email }}</p>

    <label for="login-password">Password</label>
    <input id="login-password" type="password" autocomplete="current-password" v-model="password" :disabled="auth.inflight" />
    <p v-if="errors.password">{{ errors.password }}</p>

    <button type="submit" :disabled="auth.inflight">Log in</button>
    <p v-if="formError" role="alert">{{ formError }}</p>
  </form>
</template>
```

Behavioural rules implemented in `<script setup>`:

- On `mount`: `await auth.csrfCookie()` so the CSRF cookie is present before the first `POST /api/login` (Requirement 8.2).
- On `submit`: trim the email locally; if either field is empty after trim → set `errors.{email|password}` and return without calling the API (Requirement 8.5).
- Otherwise call `auth.login({ email, password })`. The action returns `{ ok: true }` on success, or rejects with the normalised `ApiError` on failure.
- Status mapping (Requirements 8.6–8.10):
  - `200`: clear errors + `formError`; navigate to `auth.consumeDeferredTarget()` or `/`.
  - `401`: `formError = 'Invalid email or password.'`; clear `password`.
  - `422`: assign `errors` from the response's `fields` (Validation_Error keyed by field).
  - `429`: `formError = 'Too many attempts. Please try again later.'`; clear `password`.
  - `419`, ≥500, network, timeout: `formError = 'Could not complete the request.'`.

The submit button binds `:disabled="auth.inflight"` so a second click cannot submit a second request (Requirement 8.4).

#### `App.vue` (modified)

```vue
<script setup>
import { computed } from 'vue';
import { useAuthStore } from './stores/auth.js';

const auth = useAuthStore();
const isAuthed = computed(() => auth.status === 'authenticated');

async function onLogout() {
    await auth.logout();
}
</script>

<template>
  <header>
    ...
    <div v-if="isAuthed">
      <span>{{ auth.user?.email }}</span>
      <button type="button" :disabled="auth.logoutInflight" @click="onLogout" aria-label="Log out">Log out</button>
    </div>
  </header>
  <RouterView />
</template>
```

Behavioural rules (Requirement 10):

- Renders only when `auth.status === 'authenticated'` (Requirement 10.1, 10.2).
- Disabled while `auth.logoutInflight === true` (Requirement 10.4).
- `auth.logout()` calls `POST /api/logout` with `axios` configured to auto-attach `X-XSRF-TOKEN`. If no CSRF cookie is present, the action short-circuits with a toast "Could not complete log out." and leaves auth state untouched (Requirement 10.7).

#### `auth` Pinia store

```js
export const useAuthStore = defineStore('auth', {
    state: () => ({
        user: null,                 // UserResource or null
        status: 'unknown',          // 'unknown' | 'authenticated' | 'anonymous' | 'error'
        deferredTarget: null,       // string fullPath
        lastError: null,            // ApiError or null
        inflight: false,            // login in flight
        logoutInflight: false,      // logout in flight
    }),

    actions: {
        async csrfCookie() {
            await csrfCookieRequest();
        },

        async probe() {
            try {
                const r = await currentUserRequest();
                this.user = r.data.data;
                this.status = 'authenticated';
            } catch (err) {
                if (err.kind === 'http' && err.status === 401) {
                    this.user = null;
                    this.status = 'anonymous';
                } else {
                    this.user = null;
                    this.status = 'error';
                    this.lastError = err;
                }
            }
        },

        async login({ email, password }) {
            this.inflight = true;
            this.lastError = null;
            try {
                await this.csrfCookie();
                const r = await loginRequest({ email: email.trim().toLowerCase(), password });
                this.user = r.data.data;
                this.status = 'authenticated';
                this.inflight = false;
                return { ok: true };
            } catch (err) {
                this.inflight = false;
                this.lastError = err;
                throw err;
            }
        },

        async logout() {
            this.logoutInflight = true;
            try {
                await logoutRequest();
                this.setUnauthenticated();
            } catch (err) {
                if (err.kind === 'http' && err.status === 401) {
                    this.setUnauthenticated();
                } else {
                    this.lastError = err;
                }
            } finally {
                this.logoutInflight = false;
            }
        },

        setUnauthenticated({ from = null } = {}) {
            this.user = null;
            this.status = 'anonymous';
            const route = router.currentRoute.value;
            const protectedRoute = ['contacts.index', 'contacts.create', 'contacts.edit'].includes(route.name);
            if (from && protectedRoute) {
                this.deferredTarget = route.fullPath;
            }
            if (router.currentRoute.value.name !== 'login') {
                router.push({ name: 'login' });
            }
        },

        consumeDeferredTarget() {
            const t = this.deferredTarget;
            this.deferredTarget = null;
            return t;
        },
    },
});
```

The deferred target lifecycle exactly matches Requirement 9.4 — a stored deferred target is consumed by the next successful login.

#### `api/auth.js`

```js
import { http } from './http.js';

export const csrfCookieRequest = () => http.get('/sanctum/csrf-cookie', { baseURL: '' });
export const loginRequest      = (body) => http.post('/login',  body);
export const logoutRequest     = ()     => http.post('/logout');
export const currentUserRequest= ()     => http.get('/user');
```

`csrfCookieRequest` overrides `baseURL` because Sanctum's CSRF endpoint is at `/sanctum/csrf-cookie`, not `/api/sanctum/csrf-cookie`. All other auth calls use the shared client which is rooted at `/api`.

#### `api/http.js` (new shared instance)

```js
export const http = axios.create({
    baseURL: '/api',
    timeout: 10_000,
    withCredentials: true,           // send the session cookie + XSRF cookie
    xsrfCookieName: 'XSRF-TOKEN',
    xsrfHeaderName: 'X-XSRF-TOKEN',
    headers: { Accept: 'application/json' },
});
```

`withCredentials: true` is required for Sanctum SPA mode: without it the browser does not send the session cookie, and Laravel cannot resolve the user. The XSRF helpers tell axios to read the `XSRF-TOKEN` cookie (set by `/sanctum/csrf-cookie`) and echo it as the `X-XSRF-TOKEN` request header on every state-changing request.

#### Routing (Frontend)

| Path | Name | Component | Meta |
| --- | --- | --- | --- |
| `/login` | `login` | `Login.vue` | `requiresAuth: false` |
| `/` | `contacts.index` | `ContactListView.vue` | `requiresAuth: true` |
| `/contacts/new` | `contacts.create` | `ContactFormView.vue` | `requiresAuth: true` |
| `/contacts/:id/edit` | `contacts.edit` | `ContactFormView.vue` | `requiresAuth: true` |

The protected routes already exist; the auth feature only adds the `login` route and the `requiresAuth` meta key. The router's existing `beforeEnter` for `contacts.edit` (which fetches the contact) runs **after** the global `beforeEach` guard, so an anonymous request to `/contacts/42/edit` is redirected to `/login` before the contact fetch is attempted.

## Data Models

### `users` table (MySQL)

The schema is the framework's default with `email` tightened to `VARCHAR(254)` and `email_verified_at` left in place (unused). The `remember_token` column is left in place (unused) but is `Hidden` on the model.

| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `id` | `BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY` | No | Server-generated, immutable. |
| `name` | `VARCHAR(255)` | No | Capped to 120 in validation (Requirement 1.4). |
| `email` | `VARCHAR(254)` | No | Unique. Lower-cased + trimmed before insert. |
| `email_verified_at` | `TIMESTAMP` | Yes | Unused by this feature. |
| `password` | `VARCHAR(255)` | No | bcrypt hash; cast `'hashed'` on the model. |
| `remember_token` | `VARCHAR(100)` | Yes | Unused; `Hidden` on the model. |
| `created_at` | `TIMESTAMP` | Yes | Set by Eloquent. |
| `updated_at` | `TIMESTAMP` | Yes | Set by Eloquent. |

### `sessions` table

Already migrated by the framework default (`0001_01_01_000000_create_users_table.php`). Schema:

```text
id           VARCHAR PRIMARY KEY     -- session identifier
user_id      BIGINT NULL INDEX        -- nullable so anonymous sessions can exist
ip_address   VARCHAR(45) NULL
user_agent   TEXT NULL
payload      LONGTEXT                 -- serialised session data
last_activity INTEGER INDEX           -- UNIX timestamp; framework auto-expires after lifetime
```

The 120-minute `Session_Lifetime` is enforced by Laravel's `StartSession` middleware: any request whose `last_activity` is older than `SESSION_LIFETIME * 60` is treated as anonymous and the session row is garbage-collected by the periodic sweeper (`'lottery' => [2, 100]`). On every authenticated request, `StartSession` updates `last_activity` to `time()` (Requirement 11.2).

### `cache` table (used by `LoginRateLimiter`)

Already migrated (`0001_01_01_000001_create_cache_table.php`). The login limiter writes two rows per attempt:

| `key` | `value` | `expiration` |
| --- | --- | --- |
| `cache:login:e:<sha1(email)>` | int counter | `time() + 60` |
| `cache:login:i:<ip>` | int counter | `time() + 60` |

### `users.email` normalisation invariant

Every `users.email` row, throughout its lifetime, satisfies `email = mb_strtolower(trim(email))`. This invariant is established by:

- The provisioning command (`InviteUser`) lower-cases + trims before insert/update (Requirement 1.2, 2.2).
- `LoginRequest::prepareForValidation` lower-cases + trims, so login lookups always hit the canonical form (Requirement 4.1).
- No other code path persists the `email` column.

### Wire formats

#### UserResource JSON shape

```json
{
  "id": 7,
  "name": "ada",
  "email": "ada@example.com",
  "created_at": "2025-03-14T09:12:33Z",
  "updated_at": "2025-03-14T09:12:33Z"
}
```

#### `POST /api/login` request body

```json
{ "email": "ada@example.com", "password": "<plaintext password>" }
```

#### `POST /api/login` success response (`200`)

```json
{ "data": { "id": 7, "name": "ada", "email": "ada@example.com", "created_at": "...", "updated_at": "..." } }
```

#### `GET /api/user` success response (`200`)

```json
{ "data": { "id": 7, "name": "ada", "email": "ada@example.com", "created_at": "...", "updated_at": "..." } }
```

#### Error envelope shapes

| HTTP status | Endpoint | Body |
| --- | --- | --- |
| `401` | login (bad creds) | `{ "message": "Invalid credentials." }` |
| `401` | logout / user / contacts (no session) | `{ "message": "Unauthenticated." }` |
| `419` | any state-changing API call | `{ "message": "CSRF token mismatch." }` |
| `422` | login | `{ "message": "The given data was invalid.", "errors": { "email": ["..."], "password": ["..."] } }` |
| `429` | login | `{ "message": "Too many login attempts. Please try again later." }` + `Retry-After` header |
| `204` | logout | empty body |

#### API contract summary

| Method | Path | Auth required | Success | Errors |
| --- | --- | --- | --- | --- |
| `GET` | `/sanctum/csrf-cookie` | No | `204` empty | — |
| `POST` | `/api/login` | No | `200` `{ data: UserResource }` | `400` malformed JSON, `419` CSRF, `422` validation, `401` bad creds, `429` throttled |
| `POST` | `/api/logout` | Yes | `204` empty | `401` anonymous, `419` CSRF |
| `GET` | `/api/user` | Yes | `200` `{ data: UserResource }` | `401` anonymous |
| `*` | `/api/contacts*` | Yes | (defined by Event CRM Contacts feature) | `401` anonymous, `419` CSRF |



## Correctness Properties

*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*

The properties below are derived from the prework analysis. Properties have been consolidated where multiple acceptance criteria reduce to the same invariant (for example all "password never appears anywhere" rules collapse into one universally-quantified plaintext-leak check; all 401 envelope rules collapse into one).

### Property 1: Provisioning round trip

*For any* valid email `e` and any password `p` (either supplied via `--password` or generated by the command), after running `php artisan crm:invite e --password=p` against any pre-existing population `P`:

- if no User in `P` has `lowercase(trim(e))`, then post-state contains exactly one new User whose `email = lowercase(trim(e))`, whose `name` equals `mb_substr(local-part(lowercase(trim(e))), 0, 120)`, whose stored `password` value is a bcrypt hash that satisfies `Hash::check(p, hash) === true`, and whose `created_at = updated_at`;
- if a User `u` in `P` has `lowercase(trim(e))`, then post-state contains the same User whose `id`, `name`, `email`, and `created_at` are unchanged from `P`, whose stored `password` is a bcrypt hash satisfying `Hash::check(p, hash) === true`, and whose `updated_at` is greater than its previous value.

In both cases the command exits with status code 0.

**Validates: Requirements 1.1, 1.4, 1.5, 1.6, 1.7, 2.2, 2.3, 12.1**

### Property 2: Email format validation universal

*For any* string `s`, the email rule (used by both `LoginRequest` and `InviteUser` for the `email` argument) accepts `s` iff `lowercase(trim(s))` has length in `[1, 254]`, contains exactly one `@` separator, has a non-empty local-part, and has a domain containing at least one `.` and no whitespace. For every rejected `s`, the validation failure is keyed on the `email` field (HTTP 422 for `LoginRequest`, exit-1 for `InviteUser`) and no User is created or modified.

**Validates: Requirements 1.2, 1.10, 2.6, 4.3**

### Property 3: Email uniqueness invariant

*For any* sequence of provisioning operations and any pre-state `S`, no two persisted User rows in the post-state share `lowercase(trim(email))`. *For any* attempted insert that would violate this invariant, the operation is rejected (HTTP 422 keyed on `email` for an API insert path, exit non-zero for `InviteUser` if the path were ever to attempt creation), and the post-state equals the pre-state.

**Validates: Requirements 1.3, 1.9**

### Property 4: created_at immutability and updated_at monotonicity

*For any* User `u` and any sequence of operations performed after `u`'s creation, `u.created_at` in the post-state equals `u.created_at` at creation time, and `u.updated_at` is monotonically non-decreasing across the sequence (strictly greater after any operation that modifies a non-identity field of `u`).

**Validates: Requirements 1.6, 1.7**

### Property 5: UserResource exposes only safe fields

*For any* User `u`, the JSON output of `UserResource::make(u)->resolve()` is an object whose top-level keys are exactly `{id, name, email, created_at, updated_at}` and whose values match the model's persisted values (timestamps formatted as `Y-m-d\TH:i:s\Z` in UTC). The output contains no `password`, no `remember_token`, and no `email_verified_at` field.

**Validates: Requirements 1.8**

### Property 6: Plaintext passwords never leak

*For any* plaintext password `p` supplied to `POST /api/login` or `crm:invite` (whether via `--password` or generated), and *for any* request/response cycle or command invocation derived from that input, no observable output channel contains the substring `p`. Specifically, post-event, none of the following contain `p` as a substring:

- the HTTP response body (success, validation, auth, throttle, or 5xx),
- any HTTP response header,
- any line written to standard output (except, in the explicit generated-password case of `crm:invite`, exactly one line containing `p` and nothing else),
- any line written to standard error,
- any log entry produced via the configured Laravel log channels (request log, error log, debug log, exception trace),
- any column of any persisted row in any database table.

**Validates: Requirements 1.5, 1.12, 2.5, 12.1, 12.2, 12.3, 12.4, 13.6**

### Property 7: Generated password invariants

*For any* invocation of `crm:invite` without `--password`, the printed plaintext password `p` satisfies `mb_strlen(p) ∈ [16, 32]`, contains at least one character matching `[a-z]`, at least one matching `[A-Z]`, at least one matching `[0-9]`, and is drawn from a cryptographically secure source. *For any* `N ∈ [2, 100]` consecutive invocations, the resulting set of generated passwords has cardinality `N` (no collisions in practice).

**Validates: Requirements 2.4, 2.8**

### Property 8: Provisioning atomicity

*For any* pre-state `S` of the `users` table and *for any* invocation of `crm:invite` whose arguments fail Property 2 (invalid email), or whose `--password` length is outside `[8, 128]`, or whose execution triggers a runtime/DB/hashing failure during persistence, the post-state of the `users` table equals `S` byte-for-byte across every column of every row, the command exits with status code `1` (validation failure) or `2` (runtime failure), and stderr contains a non-empty error message.

**Validates: Requirements 2.6, 2.7, 2.9**

### Property 9: Login round trip

*For any* User `u` provisioned with password `p`, after `POST /api/login` with `{email: lowercase(trim(u.email)), password: p}`:

- the response status is `200` and the body is `{ "data": <UserResource(u)> }`,
- the post-response session id differs from the pre-request session id (regenerated),
- a subsequent `GET /api/user` issued with the same session cookie returns status `200` with body `{ "data": <UserResource(u)> }`.

If the `POST /api/login` is performed while another User `v` (`v ≠ u`) is already authenticated on the same session, the resulting session resolves to `u` for every subsequent endpoint, and `v` is no longer reachable through that cookie.

**Validates: Requirements 4.1, 4.2, 4.8, 6.1**

### Property 10: Login authentication failure indistinguishability

*For any* `POST /api/login` request that satisfies Requirement 4.3 (well-shaped body) but whose `email` either does not match any persisted User or matches a User whose stored hash does not verify against the supplied password, the response status is `401`, the response body is exactly `{ "message": <fixed string> }` for the same `<fixed string>` regardless of which case occurred, and the response body contains no field of `UserResource` (`id`, `name`, `email`, `created_at`, `updated_at`). No Auth_Session is established by the request.

**Validates: Requirements 4.4, 4.5**

### Property 11: Login throttle bounded by both keys

*For any* sequence `(t₁, …, tₙ)` of `POST /api/login` requests where `tᵢ` ends in HTTP `401` or `422`, restricted to a fixed `lowercase(trim(email))` value `E` (or fixed source IP `I`), and where each `tᵢ` lies within a 60-second rolling window of the next, the response status of `tᵢ` is `429` for every `i ≥ 6`. Every `429` response carries a `Retry-After` header whose value is an integer in `[1, 60]`. After 60 seconds of no further attempts within either bucket, the next request keyed on `E` (or `I`) is no longer rate-limited. *For any* successful login (status `200`), both counters keyed on the matching `(E, I)` are reset to zero.

**Validates: Requirements 4.6, 4.7**

### Property 12: Logout invalidates only the current session

*For any* set of authenticated sessions `{s₁, …, sₖ}` (across any number of distinct Users), after `POST /api/logout` is performed using session `sⱼ`'s cookie:

- `sⱼ` is no longer present server-side; subsequent requests using `sⱼ`'s cookie are anonymous everywhere, including endpoints other than `/api/logout`,
- the response status is `204` with empty body and a `Set-Cookie` directive that clears the session cookie,
- every other session `sᵢ` (`i ≠ j`) continues to resolve to its respective User on every endpoint of the API,
- no User row in the `users` table is modified by the logout.

**Validates: Requirements 5.1, 5.3, 5.4**

### Property 13: GET /api/user is read-only

*For any* User `u` and any sequence of `N ≥ 1` `GET /api/user` calls authenticated as `u`, the post-state row of `u` in the `users` table equals the pre-state row byte-for-byte, including `updated_at`. (This explicitly forbids any code path that would touch `users.updated_at` on read.)

**Validates: Requirements 6.3**

### Property 14: Anonymous JSON 401 envelope without redirect

*For any* request to `POST /api/logout`, `GET /api/user`, or any URL matching `/api/contacts`, `/api/contacts/{id}`, or `/api/contacts/...`, regardless of HTTP method, where the request carries no valid Auth_Session, the response satisfies all of the following:

- status code is `401`,
- `Content-Type` header equals `application/json; charset=utf-8`,
- the body is a JSON object `{ "message": <string> }` whose `message` is a string of trimmed length `[1, 500]`,
- the response has no `Location` header and no `3xx` status,
- no row in `contacts`, `users`, or `sessions` is created, modified, or deleted as a result of the request.

**Validates: Requirements 5.2, 6.2, 7.1, 13.1, 13.2, 13.3**

### Property 15: CSRF mismatch on state-changing API calls

*For any* request whose method is `POST`, `PUT`, `PATCH`, or `DELETE` and whose URL matches `/api/login`, `/api/logout`, or `/api/contacts*`, that is sent from a stateful origin (per Requirement 3.3) without a CSRF token whose value matches the `XSRF-TOKEN` cookie, the response status is `419`, the `Content-Type` is `application/json; charset=utf-8`, the body is `{ "message": <string> }` of trimmed length `[1, 500]`, the response has no `Location` header, and no row in `contacts`, `users`, or `sessions` is created, modified, or deleted.

**Validates: Requirements 3.4, 3.5, 7.5, 13.4**

### Property 16: Stateful request detection

*For any* HTTP request to `/api/*` and any combination of (`Origin` header, `Referer` header, presence of session cookie), the request is treated as stateful by `EnsureFrontendRequestsAreStateful` if and only if either `Origin` host or `Referer` host (when `Origin` is absent) matches an entry in the configured `SANCTUM_STATEFUL_DOMAINS` list AND the request carries the session cookie. A non-stateful request reaches the controller without an active session, so `auth:sanctum` resolves to anonymous regardless of any tampered cookie value.

**Validates: Requirements 3.3**

### Property 17: No per-user filter on Contact API

*For any* two provisioned Users `u₁` and `u₂` (which may be equal or distinct) and *for any* Contact `c` persisted in the `contacts` table, every `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` operation against `/api/contacts*` performed while authenticated as `u₂` returns the response defined by the Event CRM Contacts feature for that operation, irrespective of which (if any) User created or last updated `c`.

**Validates: Requirements 7.3, 7.4**

### Property 18: Error envelope confidentiality

*For any* response whose status is `401`, `419`, `422`, or `429` emitted by `POST /api/login`, `POST /api/logout`, `GET /api/user`, or any `/api/contacts*` URL, the response body satisfies all of the following:

- it is a single JSON object whose top-level keys form a subset of `{message, errors}`, with `errors` present only when the status is `422`;
- `message` is a string of trimmed length `[1, 500]`;
- the body contains no substring matching server filesystem path patterns (e.g. `/[A-Za-z0-9_./-]+\.php(:\d+)?/`), no PHP/Symfony exception class names (e.g. `Exception`, `TokenMismatchException`, `AuthenticationException`, `ThrottleRequestsException`, `ValidationException`), no PHP version strings (e.g. `PHP 8`), no Laravel framework version strings, no value equal to the request's supplied `password`, no value equal to the request's supplied `email`, and no field of `UserResource`.

**Validates: Requirements 13.6, 12.2, 12.4**

### Property 19: Mount probe maps backend response to SPA auth state

*For any* response that the backend returns to the Frontend's first `GET /api/user` request, the SPA's post-`auth.probe()` state satisfies:

- response status `200` with body `{data: <user>}` ⇒ `auth.status === 'authenticated'` and `auth.user === user`;
- response status `401` ⇒ `auth.status === 'anonymous'` and `auth.user === null`;
- response status outside `{200, 401}`, network error, or no response within 10 seconds ⇒ `auth.status === 'error'` and `auth.user === null`.

In every case, `app.use(router)` is called only after `auth.probe()` resolves.

**Validates: Requirements 9.1, 9.6**

### Property 20: Router guard correctness

*For any* navigation `from → to` and any value of `auth.status`, the post-guard route is determined by:

- if `to.name ∈ {contacts.index, contacts.create, contacts.edit}` and `auth.status ≠ 'authenticated'` ⇒ post-guard route is `login` and `auth.deferredTarget` is set to `to.fullPath`;
- if `to.name === 'login'` and `auth.status === 'authenticated'` ⇒ post-guard route is `contacts.index`;
- otherwise ⇒ post-guard route is `to.name`.

**Validates: Requirements 9.2, 9.3**

### Property 21: Deferred target round trip

*For any* `to.fullPath` whose `to.name ∈ {contacts.index, contacts.create, contacts.edit}` and any anonymous initial state, after the sequence "navigate(to) → guard cancels and lands on /login → user submits valid Login_Form → API returns 200", the active route's `fullPath` equals `to.fullPath` and `auth.deferredTarget` is `null`.

**Validates: Requirements 9.4, 8.6**

### Property 22: Post-mount 401 reaction

*For any* axios response observed by the Frontend after the initial mount probe whose status is `401` and whose URL is not `/api/login`, the Frontend transitions to `auth.status === 'anonymous'`, sets `auth.user = null`, and:

- if the route at the time of the 401 is in `{contacts.index, contacts.create, contacts.edit}`, navigates to `/login` while storing the current `fullPath` as `auth.deferredTarget`;
- otherwise, leaves the active route unchanged.

**Validates: Requirements 9.5**

### Property 23: Login.vue submission shape

*For any* sequence of user interactions with `Login.vue` mounted from a fresh state, where the user types `email = E`, `password = P`, and clicks the submit button `K ≥ 1` times before any response arrives:

- if `trim(E) = ""` or `P = ""`, the Frontend issues zero `POST /api/login` requests and zero `POST /api/logout` requests;
- otherwise, the Frontend issues exactly one `POST /api/login` request whose `Content-Type` is `application/json`, whose body is `{email: lowercase(trim(E)), password: P}`, whose headers include `X-XSRF-TOKEN` echoing the current `XSRF-TOKEN` cookie value, and whose timeout is configured to 10 seconds. Before that `POST /api/login` request is dispatched, exactly one `GET /sanctum/csrf-cookie` request has completed.

**Validates: Requirements 8.2, 8.3, 8.4, 8.5**

### Property 24: Login.vue response → UI state mapping

*For any* response (status code `s`, body `b`, network error, or 10-second timeout) to a single `POST /api/login` from `Login.vue`, the post-response UI state and store state satisfy the table below. In every case, the submit control is re-enabled (`auth.inflight === false`).

| Outcome | `auth.status` | `auth.user` | Visible message | Email field | Password field | Active route |
| --- | --- | --- | --- | --- | --- | --- |
| `s = 200` | `authenticated` | response `data` | none | retained | cleared | `auth.consumeDeferredTarget()` or `/` |
| `s = 401` | `anonymous` | `null` | "Invalid email or password." | retained | cleared | `/login` |
| `s = 422` | `anonymous` | `null` | per-field errors from response | retained | retained on non-erroring fields | `/login` |
| `s = 429` | `anonymous` | `null` | "Too many attempts…" | retained | cleared | `/login` |
| `s = 419`, `s ≥ 500`, network error, timeout | `anonymous` | `null` | "Could not complete the request." | retained | retained | `/login` |

**Validates: Requirements 8.6, 8.7, 8.8, 8.9, 8.10**

### Property 25: Logout control rendering and submit

*For any* state of the SPA, the logout control is rendered in the application header iff `auth.status === 'authenticated'`. When rendered, its accessible name contains "Log out" and the displayed text contains `auth.user.email`. *For any* sequence of user clicks on the logout control during a single in-flight logout request, the Frontend issues exactly one `POST /api/logout` carrying the `X-XSRF-TOKEN` header, and only when a non-empty `XSRF-TOKEN` cookie is present at click time. If the cookie is absent or empty at click time, zero `POST /api/logout` requests are issued.

**Validates: Requirements 10.1, 10.2, 10.3, 10.4, 10.7**

### Property 26: Logout response → UI state mapping

*For any* response (status code `s`, network error, or 10-second timeout) to a `POST /api/logout` from the logout control, the post-response state satisfies:

- `s ∈ {204, 401}` ⇒ `auth.status === 'anonymous'`, `auth.user === null`, `auth.deferredTarget === null`, and the active route is `/login`;
- `s = 419`, `s ≥ 500`, network error, or timeout ⇒ `auth.status === 'authenticated'`, `auth.user` unchanged, the logout control re-enabled, and a non-blocking error message displayed.

**Validates: Requirements 10.5, 10.6**

## Error Handling

### Backend error catalog (auth-specific additions)

| Cause | HTTP | Response body | Source |
| --- | --- | --- | --- |
| Anonymous request to a protected endpoint (`/api/logout`, `/api/user`, `/api/contacts*`) | 401 | `{ "message": "Unauthenticated." }` | `AuthenticationException` render closure in `bootstrap/app.php` |
| Login with wrong email / wrong password | 401 | `{ "message": "Invalid credentials." }` | `AuthController@login` (explicit branch; same body for both cases) |
| CSRF token missing or mismatched on stateful state-change | 419 | `{ "message": "CSRF token mismatch." }` | `TokenMismatchException` render closure |
| Login validation failure (bad shape, missing fields, bad email format, bad password length) | 422 | `{ "message": "The given data was invalid.", "errors": { ... } }` | Existing `ValidationException` render closure |
| Login throttle exceeded | 429 | `{ "message": "Too many login attempts. Please try again later." }` + `Retry-After` header | `AuthController@login` (explicit branch). A peer render closure for `ThrottleRequestsException` covers any other future throttle. |
| Hashing failure inside `crm:invite` | (cli) | stderr line + exit 2 | `InviteUser::handle()` `try/catch` |
| Anything else thrown from `AuthController` (`\Throwable`) | 500 | `{ "message": "An unexpected error occurred." }` | Existing generic `\Throwable` render closure |

The `EnforceJsonContentType` middleware (existing) ensures that every `application/json`-bodied response above ends up with `Content-Type: application/json; charset=utf-8` (Requirement 13.1) without per-controller wiring.

`bootstrap/app.php` is augmented with three new render closures, each mirroring the existing pattern (only acting on `$request->is('api/*')`):

```php
$exceptions->render(function (AuthenticationException $e, $request) {
    if (! $request->is('api/*')) return null;
    return response()->json(['message' => 'Unauthenticated.'], 401);
});

$exceptions->render(function (TokenMismatchException $e, $request) {
    if (! $request->is('api/*')) return null;
    return response()->json(['message' => 'CSRF token mismatch.'], 419);
});

$exceptions->render(function (ThrottleRequestsException $e, $request) {
    if (! $request->is('api/*')) return null;
    $retryAfter = (int) ($e->getHeaders()['Retry-After'] ?? 60);
    return response()->json(
        ['message' => 'Too many requests.'],
        429,
        ['Retry-After' => (string) max(1, min(60, $retryAfter))],
    );
});
```

Stack traces, file paths, exception class names, and PHP version strings are scrubbed by virtue of the closures hard-coding the response body. The generic `\Throwable` closure (already in place) is the final safety net.

### Frontend error catalog (auth-specific additions)

| Cause | UI behaviour |
| --- | --- |
| Local form validation failure on `Login.vue` (empty email/password after trim) | Inline error per offending field; submit blocked; no API request issued; values retained. |
| Login `422` | Field errors merged into the form's `errors` map; submit re-enabled; non-erroring values retained. |
| Login `401` | Inline form error "Invalid email or password."; password cleared; email retained; submit re-enabled. |
| Login `429` | Inline form error "Too many attempts. Please try again later."; password cleared; email retained; submit re-enabled. |
| Login `419` / `5xx` / network / 10s timeout | Inline form error "Could not complete the request."; values retained; submit re-enabled. |
| Logout `204` or `401` | Auth state set to anonymous; cached User_Resource cleared; deferred target cleared; navigate to `/login`. |
| Logout `419` / `5xx` / network / timeout | Toast error "Logout failed. Please try again."; auth state retained; logout control re-enabled. |
| Logout attempted with no `XSRF-TOKEN` cookie | Toast error "Could not complete log out."; no API request issued; auth state retained. |
| Any non-`/api/login` `401` after the mount probe | `auth.status` set to anonymous; cached User_Resource cleared; if on a protected route, navigate to `/login` and store the current `fullPath` as deferred target. |
| Mount probe network/timeout/non-200/401 | `auth.status` set to error; non-blocking toast "Connection failed."; if entry route is protected, navigate to `/login` storing the entry route as deferred target. |

### Race conditions and concurrency

- **Concurrent logins from the same tab** are prevented by `auth.inflight`. The submit button binds `:disabled="auth.inflight"` so a second click can never produce a second `POST /api/login` (Requirement 8.4).
- **Concurrent logouts from the same tab** are prevented by `auth.logoutInflight` in the same way (Requirement 10.4).
- **Throttle bucket collisions across users** are deliberately partitioned: the email-keyed bucket uses `sha1(lowercase(trim(email)))` so two different IPs locking out the same email both contribute to the same email bucket (defending against credential stuffing of one account from many IPs); the IP-keyed bucket is independent so a single IP attempting many emails is still rate-limited.
- **Session regeneration on login** rotates the session id, so a previously captured `XSRF-TOKEN` value tied to the old session id will mismatch on the next state-changing request and produce a `419` per Property 15. The Frontend reissues `GET /sanctum/csrf-cookie` after a successful login if any subsequent state-changing call returns `419`.

## Testing Strategy

### Test Pyramid

| Layer | Tooling | Coverage |
| --- | --- | --- |
| Property-based backend tests | Pest 4 + `eris/eris`, in-memory SQLite, `RefreshDatabase` | Properties 1–18 (backend half). |
| Property-based frontend tests | Vitest + `@vue/test-utils` + `fast-check` + `happy-dom` | Properties 19–26 (frontend half). |
| Example unit tests | Pest 4 / Vitest | Smoke checks (config values, `route:list`), latency-bound criteria (4.7 "within 1 second", 5.1 "within 5 seconds"), the hashing-failure injection (2.9, 12.5), `email_verified_at` ignored on User serialisation, and `Login.vue` static markup (8.1). |
| Smoke tests | Pest 4 | `php artisan crm:invite --help` succeeds; `route:list` shows `auth.login`, `auth.logout`, `auth.user`, plus the 5 contact routes inside the `auth:sanctum` group; published `config/sanctum.php` exposes the expected stateful entries; `config('session.lifetime') === 120`. |

### Property-based testing libraries

- **Backend (PHP):** `eris/eris` (already installed, see `composer.json`). Each property test runs `forAll(...)->withIterations(100)` minimum (Property 11 throttle test runs `withIterations(50)` because each iteration involves real cache writes and timing — this is documented inline in the test). Failing examples are minimised by Eris's built-in shrinker; reproduction seeds are printed on failure.
- **Frontend (JS):** `fast-check` (already installed). Each property test runs `fc.assert(prop, { numRuns: 100 })`. Async properties use `fc.asyncProperty`. Schedulers are used to test ordering (Property 23 — CSRF before login, single-submit gating).

### PBT configuration conventions

- Every property test runs **at least 100 iterations**.
- Every property test is tagged with a comment of the form `// Feature: crm-authentication, Property {N}: {short property text}` and an `it(...)` description that includes `[P{N}]`.
- Tests that need real time are limited to a few iterations and use Laravel's `Carbon::setTestNow` and `Cache::clear` for determinism. The throttle-decay portion of Property 11 uses `Carbon::setTestNow` to skip past the 60-second window without waiting.

### Generator strategy

Backend generators (Eris):

- `genValidEmail()` — `local-part@domain` strings whose lowercased trimmed length is in `[1, 254]`, local-part and domain are non-empty, domain has at least one `.`, no whitespace.
- `genInvalidEmail()` — strings that fail at least one component of the predicate (no `@`, multiple `@`, empty local-part, domain without dot, embedded whitespace, length > 254, length 0).
- `genPassword(min=1, max=256)` — random non-null strings.
- `genInvalidPasswordOption()` — strings whose `mb_strlen` is in `[0, 7] ∪ [129, 200]`.
- `genUserPopulation(n)` — directly inserts `n` valid Users via `UserFactory` (which uses `Hash::make` via the existing factory).
- `genApiUrl()` — strings drawn from `{/api/contacts, /api/contacts/1, /api/contacts/42/, /api/contacts/anything}` for the anonymous-request property.
- `genHttpMethod()` — `GET|HEAD|POST|PUT|PATCH|DELETE`.
- `genCsrfPair()` — `(cookieValue, headerValue)` with `cookieValue ≠ headerValue` for the 419 property.
- `genStatefulOrigin()` — pairs `(originHeader, hasSessionCookie)` covering all four combinations of (matches stateful list, has session cookie).

Frontend generators (fast-check):

- `fcEmailValid()` / `fcEmailInvalid()` mirroring the backend predicates so the frontend property tests for `Login.vue` agree with the backend's contract.
- `fcPasswordValid()` / `fcPasswordInvalid()`.
- `fcDeferredPath()` — strings drawn from `[/, /contacts/new, /contacts/42/edit?focus=name, /contacts/42/edit#email]`.
- `fcLoginResponse()` — discriminated unions of `{kind: 'success', user}`, `{kind: 'http', status: 401|419|422|429|500|503}`, `{kind: 'network'}`, `{kind: 'timeout'}`.
- `fcLogoutResponse()` — same shape.

### Test layout

```
tests/
  Feature/
    Auth/
      LoginTest.php                     # Properties 9, 10, 11, 14, 15, 18
      LogoutTest.php                    # Properties 12, 14, 15, 18
      CurrentUserTest.php               # Properties 9 (round-trip portion), 13, 14, 18
      ContactsAuthGateTest.php          # Properties 14, 15, 17, 18
      CsrfAndStatefulTest.php           # Properties 15, 16
    Console/
      InviteUserTest.php                # Properties 1, 2, 3, 4, 7, 8
    PasswordHygieneTest.php             # Property 6 (response-body and log sweep)
  Unit/
    LoginRateLimiterTest.php            # Property 11 (cache-level invariants)
resources/js/
  __tests__/
    auth.store.spec.js                  # Properties 19, 22
    Login.spec.js                       # Properties 23, 24
    AuthGuard.spec.js                   # Properties 20, 21
    LogoutControl.spec.js               # Properties 25, 26
  api/
    auth.test.js                        # Auth API client unit cases
```

### Test configuration

- `phpunit.xml` is left as-is; the existing in-memory SQLite + `RefreshDatabase` stack covers the new tests. The login throttle uses Laravel's `RateLimiter` which falls back to `array` cache during tests (`CACHE_STORE=array` is set in `phpunit.xml`'s `<server>` block; this is consistent with `cache.php`'s default test override).
- The `vitest` config (`vitest.config.js`) already provides `happy-dom` as the test environment; the new auth tests reuse it.
- A new `.env.testing` entry sets `SANCTUM_STATEFUL_DOMAINS=localhost` so the stateful-domain property tests have a deterministic origin to send.

### What is NOT property-tested

- **Latency-bound criteria** (Requirements 4.7 "within 1 second", 5.1 "within 5 seconds"): performance is sensitive to the test runner; we use one example each that fails if the latency budget is exceeded by 5×.
- **Sanctum config (Requirement 3.1) and session config (Requirements 3.2, 3.6, 11.1, 11.4)**: a single example per config value, asserted against `config(...)`. Running 100 iterations adds no value.
- **Hashing failure (2.9, 12.5)**: covered by a deterministic example that mocks `Hash::make` to throw.
- **`Login.vue` static structure (Requirement 8.1)**: one mounted-component example asserting the three labelled inputs.
- **Mount-probe latency portion of Requirement 9.6**: the timeout branch is exercised by stubbing axios; the actual 10-second wait is configured but not slept through in tests.

### Acceptance for the Design Phase

The design is complete when:

- Every Acceptance Criterion in the requirements document is referenced by at least one Correctness Property, an EXAMPLE test, or a SMOKE test (see prework analysis in context).
- The integration with the existing Event CRM Contacts feature is explicit: `auth:sanctum` is wrapped around the existing `apiResource`, the existing `EnforceJsonContentType` and `EnsureJsonRequestBody` middleware are reused, and the existing `bootstrap/app.php` envelope gains exactly three new render closures.
- The Frontend mount sequence is explicit: `auth.probe()` runs before `app.use(router)`, the global `beforeEach` guard handles protected vs login routes, and the response interceptor handles unauthenticated fall-back without coupling to any specific view.
