content:
     1→# 06 - Authentication and Security Specification
     2→
     3→> E-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite
     4→
     5→---
     6→
     7→## 1. Authentication
     8→
     9→The platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.
    10→
    11→### 1.1 Admin Authentication
    12→
    13→| Setting | Value |
    14→|---------|-------|
    15→| Guard name | `web` (default Laravel guard) |
    16→| Provider name | `users` (Eloquent driver) |
    17→| Model | `App\Models\User` |
    18→| Driver | Session (cookie-based) |
    19→| Login route | `GET /admin/login` |
    20→| Login form | Livewire component `Admin\Auth\Login` |
    21→
    22→#### Admin Login Flow
    23→
    24→```mermaid
    25→sequenceDiagram
    26→    participant B as Browser
    27→    participant LW as Livewire Component
    28→    participant Auth as Auth Guard (web)
    29→    participant DB as Database
    30→    participant S as Session
    31→
    32→    B->>LW: Submit email + password
    33→    LW->>LW: Validate input (email required, password required)
    34→    LW->>LW: Check rate limiter (5/min per IP)
    35→    alt Rate limit exceeded
    36→        LW-->>B: Flash "Too many attempts. Try again in X seconds."
    37→    end
    38→    LW->>Auth: attempt(credentials, remember)
    39→    Auth->>DB: SELECT from users WHERE email = ?
    40→    Auth->>Auth: Verify bcrypt hash
    41→    alt Credentials valid
    42→        Auth->>S: Regenerate session ID (prevent fixation)
    43→        Auth-->>LW: Success
    44→        LW-->>B: Redirect to /admin (dashboard)
    45→    else Credentials invalid
    46→        Auth-->>LW: Failure
    47→        LW-->>B: Flash "Invalid credentials" (generic, never reveal which field)
    48→    end
    49→```
    50→
    51→**Requirements:**
    52→
    53→- The login form collects `email` and `password`.
    54→- On successful login, the session MUST be regenerated to prevent session fixation attacks.
    55→- On failed login, only the generic message "Invalid credentials" is shown. The system must not indicate whether the email or password was wrong.
    56→- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.
    57→- After login: redirect to `/admin` (the dashboard).
    58→
    59→#### Remember Me
    60→
    61→- An optional "Remember me" checkbox on the login form.
    62→- When enabled, Laravel sets a long-lived remember token cookie.
    63→- The `remember_token` column on the `users` table stores the hashed token.
    64→- Cookie name follows Laravel default convention: `remember_web_{hash}`.
    65→
    66→#### Admin Password Reset Flow
    67→
    68→```mermaid
    69→sequenceDiagram
    70→    participant B as Browser
    71→    participant App as Application
    72→    participant DB as Database
    73→    participant Mail as Mail Service
    74→
    75→    B->>App: GET /admin/forgot-password
    76→    App-->>B: Render forgot-password form
    77→    B->>App: POST /admin/forgot-password (email)
    78→    App->>DB: Look up user by email
    79→    alt User exists
    80→        App->>DB: Store reset token in password_reset_tokens table
    81→        App->>Mail: Send reset link email
    82→    end
    83→    App-->>B: "If that email exists, we sent a reset link." (generic)
    84→    B->>App: GET /admin/reset-password/{token}
    85→    App-->>B: Render reset-password form
    86→    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)
    87→    App->>DB: Validate token (exists, not expired, matches email)
    88→    alt Valid token
    89→        App->>DB: Update user password (bcrypt hash)
    90→        App->>DB: Delete used token
    91→        App-->>B: Redirect to /admin/login with success flash
    92→    else Invalid/expired token
    93→        App-->>B: Error message
    94→    end
    95→```
    96→
    97→**Requirements:**
    98→
    99→- Uses Laravel's built-in password broker (`users` broker).
   100→- Reset tokens stored in the `password_reset_tokens` table.
   101→- Token expiration: 60 minutes (configurable in `config/auth.php` under `passwords.users.expire`).
   102→- Throttle: one reset email per 60 seconds per email address.
   103→- The "forgot password" response must be generic (do not reveal whether email exists).
   104→
   105→#### Admin Logout
   106→
   107→- Route: `POST /admin/logout` (via Livewire action or standard route).
   108→- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.
   109→- The response must not cache.
   110→
   111→#### Session Lifetime
   112→
   113→| Setting | Value |
   114→|---------|-------|
   115→| Lifetime | 120 minutes (configurable via `SESSION_LIFETIME` env var) |
   116→| Expire on close | No (session persists until lifetime expires) |
   117→
   118→---
   119→
   120→### 1.2 Customer Authentication
   121→
   122→| Setting | Value |
   123→|---------|-------|
   124→| Guard name | `customer` (custom guard) |
   125→| Provider name | `customers` (Eloquent driver) |
   126→| Model | `App\Models\Customer` |
   127→| Driver | Session (cookie-based) |
   128→| Login route | `GET /account/login` |
   129→| Registration route | `GET /account/register` |
   130→
   131→#### Customer Login Flow
   132→
   133→```mermaid
   134→sequenceDiagram
   135→    participant B as Browser
   136→    participant LW as Livewire Component
   137→    participant Auth as Auth Guard (customer)
   138→    participant Store as ResolveStore Middleware
   139→    participant DB as Database
   140→
   141→    Note over Store: Store already resolved from hostname
   142→    B->>LW: Submit email + password
   143→    LW->>LW: Validate input
   144→    LW->>LW: Check rate limiter (5/min per IP)
   145→    LW->>Auth: attempt(credentials + store_id, remember)
   146→    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?
   147→    Auth->>Auth: Verify bcrypt hash
   148→    alt Valid
   149→        Auth-->>LW: Success (regenerate session)
   150→        LW-->>B: Redirect to /account (or intended URL)
   151→    else Invalid
   152→        Auth-->>LW: Failure
   153→        LW-->>B: Flash "Invalid credentials"
   154→    end
   155→```
   156→
   157→**Requirements:**
   158→
   159→- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.
   160→- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.
   161→- The same email address can exist in different stores (multi-tenant isolation).
   162→- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.
   163→- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).
   164→
   165→#### Customer Registration Flow
   166→
   167→```mermaid
   168→flowchart TD
   169→    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]
   170→    B --> C{Validate via RegisterCustomerRequest}
   171→    C -- Invalid --> D[Show validation errors]
   172→    C -- Valid --> E[Create Customer record scoped to current store]
   173→    E --> F[Auto-login via customer guard]
   174→    F --> G[Redirect to /account]
   175→```
   176→
   177→**Registration fields:**
   178→
   179→| Field | Type | Required | Validation |
   180→|-------|------|:--------:|------------|
   181→| name | string | Y | max:255 |
   182→| email | string | Y | valid email, unique per store (composite unique on email + store_id) |
   183→| password | string | Y | min:8, confirmed |
   184→| password_confirmation | string | Y | must match password |
   185→| marketing_opt_in | boolean | N | defaults to false |
   186→
   187→#### Customer Store Scoping
   188→
   189→- The `customers` auth provider requires a custom `CustomerUserProvider` class.
   190→- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.
   191→- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).
   192→- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.
   193→
   194→#### Customer Password Reset
   195→
   196→- Uses a separate password broker: `customers` broker.
   197→- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
   198→- Token records include `store_id` to scope resets to the correct store.
   199→- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.
   200→- Token expiration: 60 minutes.
   201→- Throttle: one reset email per 60 seconds.
   202→- Generic response on the "forgot password" form (do not reveal whether email exists in the store).
   203→
   204→---
   205→
   206→### 1.3 API Authentication (Sanctum)
   207→
   208→| Setting | Value |
   209→|---------|-------|
   210→| Guard name | `sanctum` |
   211→| Provider | `users` (admin users only) |
   212→| Token type | Personal access tokens |
   213→| Token prefix | `shop_` |
   214→| Hashing | SHA-256 |
   215→| Default expiration | 1 year |
   216→| Storage | `personal_access_tokens` table |
   217→
   218→#### Token Lifecycle
   219→
   220→```mermaid
   221→stateDiagram-v2
   222→    [*] --> Created: Admin creates token in Developers section
   223→    Created --> Active: Plain-text shown once, hash stored
   224→    Active --> InUse: Token sent as Bearer header
   225→    InUse --> Active: Request processed
   226→    Active --> Revoked: Admin revokes token
   227→    Active --> Expired: Expiration date reached
   228→    Revoked --> [*]: Token row deleted
   229→    Expired --> [*]: Token row deleted (cleanup job)
   230→```
   231→
   232→**Requirements:**
   233→
   234→- Tokens are created in the admin panel, under the Developers section.
   235→- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).
   236→- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.
   237→- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.
   238→- Sanctum middleware validates the hash and resolves the associated user.
   239→- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).
   240→- Revoking a token deletes its row from the `personal_access_tokens` table.
   241→
   242→#### Token Abilities (Scopes)
   243→
   244→| Ability | Description |
   245→|---------|-------------|
   246→| `read-products` | List and view products |
   247→| `write-products` | Create, update, delete products |
   248→| `read-orders` | List and view orders |
   249→| `write-orders` | Update orders, create fulfillments |
   250→| `read-customers` | List and view customers |
   251→| `write-customers` | Update customers |
   252→| `read-collections` | List and view collections |
   253→| `write-collections` | Create, update, delete collections |
   254→| `read-discounts` | List and view discounts |
   255→| `write-discounts` | Create, update, delete discounts |
   256→| `read-analytics` | View analytics data |
   257→| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |
   258→| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |
   259→| `read-themes` | View themes and theme files |
   260→| `write-themes` | Create, update, delete, publish themes |
   261→| `read-content` | View pages and navigation menus |
   262→| `write-content` | Create, update, delete pages and navigation items |
   263→| `manage-platform` | Platform-level management (super-admin only) |
   264→
   265→Each API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.
   266→
   267→---
   268→
   269→### 1.4 Guard Configuration
   270→
   271→File to configure: `config/auth.php`
   272→
   273→#### Guards Data Dictionary
   274→
   275→| Guard Name | Driver | Provider | Purpose |
   276→|------------|--------|----------|---------|
   277→| `web` | session | `users` | Admin panel session auth (default guard) |
   278→| `customer` | session | `customers` | Storefront customer session auth |
   279→| `sanctum` | sanctum | `users` | Admin REST API token auth |
   280→
   281→#### Providers Data Dictionary
   282→
   283→| Provider Name | Driver | Model | Purpose |
   284→|---------------|--------|-------|---------|
   285→| `users` | eloquent | `App\Models\User` | Admin users |
   286→| `customers` | eloquent | `App\Models\Customer` | Storefront customers (store-scoped via custom provider) |
   287→
   288→#### Password Brokers Data Dictionary
   289→
   290→| Broker Name | Provider | Token Table | Expiry | Throttle |
   291→|-------------|----------|-------------|--------|----------|
   292→| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |
   293→| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |
   294→
   295→**Defaults:** The default guard is `web` and the default password broker is `users`.
   296→
   297→---
   298→
   299→## 2. Authorization
   300→
   301→### 2.1 Role Enum
   302→
   303→File: `app/Enums/StoreUserRole.php`
   304→
   305→A backed string enum with four values:
   306→
   307→| Case | Stored Value | Description |
   308→|------|-------------|-------------|
   309→| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |
   310→| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |
   311→| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |
   312→| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |
   313→
   314→**Constraints:**
   315→
   316→- Each store must have exactly one Owner.
   317→- Ownership transfer is a dedicated action (not a simple role change).
   318→- A User can hold different roles in different stores (via the `store_users` pivot table).
   319→
   320→---
   321→
   322→### 2.2 Permission Matrix
   323→
   324→The `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.
   325→
   326→| Resource | Action | Owner | Admin | Staff | Support |
   327→|----------|--------|:-----:|:-----:|:-----:|:-------:|
   328→| Store Settings | View | Y | Y | N | N |
   329→| Store Settings | Update | Y | Y | N | N |
   330→| Store | Delete | Y | N | N | N |
   331→| Products | List / View | Y | Y | Y | Y |
   332→| Products | Create | Y | Y | Y | N |
   333→| Products | Update | Y | Y | Y | N |
   334→| Products | Delete / Archive | Y | Y | N | N |
   335→| Products | Restore | Y | Y | N | N |
   336→| Collections | List / View | Y | Y | Y | Y |
   337→| Collections | Create | Y | Y | Y | N |
   338→| Collections | Update | Y | Y | Y | N |
   339→| Collections | Delete | Y | Y | N | N |
   340→| Orders | List / View | Y | Y | Y | Y |
   341→| Orders | Update | Y | Y | Y | N |
   342→| Orders | Cancel | Y | Y | N | N |
   343→| Fulfillments | Create | Y | Y | Y | N |
   344→| Fulfillments | Update / Cancel | Y | Y | Y | N |
   345→| Refunds | Create | Y | Y | N | N |
   346→| Customers | List / View | Y | Y | Y | Y |
   347→| Customers | Update | Y | Y | Y | N |
   348→| Discounts | List / View | Y | Y | Y | Y |
   349→| Discounts | Create | Y | Y | Y | N |
   350→| Discounts | Update | Y | Y | Y | N |
   351→| Discounts | Delete | Y | Y | N | N |
   352→| Themes | List / View | Y | Y | N | N |
   353→| Themes | Create / Upload | Y | Y | N | N |
   354→| Themes | Customize | Y | Y | N | N |
   355→| Themes | Publish | Y | Y | N | N |
   356→| Themes | Delete | Y | Y | N | N |
   357→| Pages | List / View | Y | Y | Y | N |
   358→| Pages | Create | Y | Y | Y | N |
   359→| Pages | Update | Y | Y | Y | N |
   360→| Pages | Delete | Y | Y | N | N |
   361→| Navigation | List / View | Y | Y | Y | N |
   362→| Navigation | Manage | Y | Y | N | N |
   363→| Apps | List / View | Y | Y | N | N |
   364→| Apps | Install / Remove | Y | Y | N | N |
   365→| Analytics | View | Y | Y | Y | N |
   366→| Developers / API | Manage | Y | Y | N | N |
   367→| Staff | List / View | Y | Y | N | N |
   368→| Staff | Invite | Y | Y | N | N |
   369→| Staff | Update Role | Y | Y | N | N |
   370→| Staff | Remove | Y | Y | N | N |
   371→| Shipping Zones | Manage | Y | Y | N | N |
   372→| Tax Settings | Manage | Y | Y | N | N |
   373→| Search Settings | Manage | Y | Y | N | N |
   374→
   375→#### Role Group Shorthand
   376→
   377→The permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:
   378→
   379→| Group Name | Roles Included | Used For |
   380→|------------|---------------|----------|
   381→| OwnerOnly | Owner | Store deletion, ownership transfer |
   382→| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |
   383→| OwnerAdminOrStaff | Owner, Admin, Staff | Create/update products, collections, discounts, pages, fulfillments, order updates |
   384→| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |
   385→
   386→---
   387→
   388→### 2.3 Role Checking Trait
   389→
   390→File: `app/Traits/ChecksStoreRole.php`
   391→
   392→A reusable trait included in all policies and used by gate definitions. Must provide the following methods:
   393→
   394→| Method | Parameters | Returns | Description |
   395→|--------|-----------|---------|-------------|
   396→| `getStoreRole` | User, store_id (int) | StoreUserRole or null | Looks up the user's role for the given store via the `store_users` relationship. Returns null if the user has no role in that store. |
   397→| `hasRole` | User, store_id (int), roles (array of StoreUserRole) | bool | Returns true if the user's role for the store is in the provided list. Returns false if user has no role. |
   398→| `isOwnerOrAdmin` | User, store_id (int) | bool | Shorthand: checks if role is Owner or Admin. |
   399→| `isOwnerAdminOrStaff` | User, store_id (int) | bool | Shorthand: checks if role is Owner, Admin, or Staff. |
   400→| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |
   401→
   402→**Store ID resolution:** When the policy method receives a model instance (e.g., Product), the store_id comes from the model's `store_id` attribute. When the policy method does not receive a model (e.g., `viewAny`, `create`), the store_id is resolved from the container-bound `current_store`.
   403→
   404→---
   405→
   406→### 2.4 Laravel Policies
   407→
   408→All policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\Policies\{Model}Policy`), so no manual registration is needed.
   409→
   410→Every policy method follows this pattern:
   411→1. Determine the `store_id` (from the model if available, otherwise from the resolved current store).
   412→2. Use the appropriate role-checking helper method.
   413→3. Return a boolean.
   414→
   415→#### ProductPolicy
   416→
   417→File: `app/Policies/ProductPolicy.php`
   418→
   419→| Method | Receives Model? | Required Roles | Store ID Source |
   420→|--------|:--------------:|----------------|-----------------|
   421→| `viewAny(User)` | No | AnyRole | current_store |
   422→| `view(User, Product)` | Yes | AnyRole | product.store_id |
   423→| `create(User)` | No | OwnerAdminOrStaff | current_store |
   424→| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |
   425→| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
   426→| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
   427→| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
   428→
   429→#### OrderPolicy
   430→
   431→File: `app/Policies/OrderPolicy.php`
   432→
   433→| Method | Receives Model? | Required Roles | Store ID Source |
   434→|--------|:--------------:|----------------|-----------------|
   435→| `viewAny(User)` | No | AnyRole | current_store |
   436→| `view(User, Order)` | Yes | AnyRole | order.store_id |
   437→| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |
   438→| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |
   439→| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |
   440→| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |
   441→
   442→#### CollectionPolicy
   443→
   444→File: `app/Policies/CollectionPolicy.php`
   445→
   446→| Method | Receives Model? | Required Roles | Store ID Source |
   447→|--------|:--------------:|----------------|-----------------|
   448→| `viewAny(User)` | No | AnyRole | current_store |
   449→| `view(User, Collection)` | Yes | AnyRole | collection.store_id |
   450→| `create(User)` | No | OwnerAdminOrStaff | current_store |
   451→| `update(User, Collection)` | Yes | OwnerAdminOrStaff | collection.store_id |
   452→| `delete(User, Collection)` | Yes | OwnerOrAdmin | collection.store_id |
   453→
   454→#### DiscountPolicy
   455→
   456→File: `app/Policies/DiscountPolicy.php`
   457→
   458→| Method | Receives Model? | Required Roles | Store ID Source |
   459→|--------|:--------------:|----------------|-----------------|
   460→| `viewAny(User)` | No | AnyRole | current_store |
   461→| `view(User, Discount)` | Yes | AnyRole | discount.store_id |
   462→| `create(User)` | No | OwnerAdminOrStaff | current_store |
   463→| `update(User, Discount)` | Yes | OwnerAdminOrStaff | discount.store_id |
   464→| `delete(User, Discount)` | Yes | OwnerOrAdmin | discount.store_id |
   465→
   466→#### CustomerPolicy
   467→
   468→File: `app/Policies/CustomerPolicy.php`
   469→
   470→| Method | Receives Model? | Required Roles | Store ID Source |
   471→|--------|:--------------:|----------------|-----------------|
   472→| `viewAny(User)` | No | AnyRole | current_store |
   473→| `view(User, Customer)` | Yes | AnyRole | customer.store_id |
   474→| `update(User, Customer)` | Yes | OwnerAdminOrStaff | customer.store_id |
   475→
   476→#### ThemePolicy
   477→
   478→File: `app/Policies/ThemePolicy.php`
   479→
   480→| Method | Receives Model? | Required Roles | Store ID Source |
   481→|--------|:--------------:|----------------|-----------------|
   482→| `viewAny(User)` | No | OwnerOrAdmin | current_store |
   483→| `view(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |
   484→| `create(User)` | No | OwnerOrAdmin | current_store |
   485→| `update(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |
   486→| `delete(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |
   487→| `publish(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |
   488→
   489→#### PagePolicy
   490→
   491→File: `app/Policies/PagePolicy.php`
   492→
   493→| Method | Receives Model? | Required Roles | Store ID Source |
   494→|--------|:--------------:|----------------|-----------------|
   495→| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |
   496→| `view(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |
   497→| `create(User)` | No | OwnerAdminOrStaff | current_store |
   498→| `update(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |
   499→| `delete(User, Page)` | Yes | OwnerOrAdmin | page.store_id |
   500→
   501→#### StorePolicy
   502→
   503→File: `app/Policies/StorePolicy.php`
   504→
   505→| Method | Receives Model? | Required Roles | Store ID Source |
   506→|--------|:--------------:|----------------|-----------------|
   507→| `viewSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |
   508→| `updateSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |
   509→| `delete(User, Store)` | Yes | OwnerOnly | store.id |
   510→
   511→#### FulfillmentPolicy
   512→
   513→File: `app/Policies/FulfillmentPolicy.php`
   514→
   515→| Method | Receives Model? | Required Roles | Store ID Source |
   516→|--------|:--------------:|----------------|-----------------|
   517→| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |
   518→| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |
   519→| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |
   520→
   521→Note: `create` receives the parent Order, not a Fulfillment, since the Fulfillment does not exist yet. The `update` and `cancel` methods navigate through the Fulfillment's order relationship to get the store_id.
   522→
   523→#### RefundPolicy
   524→
   525→File: `app/Policies/RefundPolicy.php`
   526→
   527→| Method | Receives Model? | Required Roles | Store ID Source |
   528→|--------|:--------------:|----------------|-----------------|
   529→| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |
   530→
   531→Note: `create` receives the parent Order for the same reason as FulfillmentPolicy.
   532→
   533→#### NavigationMenuPolicy
   534→
   535→File: `app/Policies/NavigationMenuPolicy.php`
   536→
   537→| Method | Receives Model? | Required Roles | Store ID Source |
   538→|--------|:--------------:|----------------|-----------------|
   539→| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |
   540→| `manage(User)` | No | OwnerOrAdmin | current_store |
   541→
   542→---
   543→
   544→### 2.5 Gates (Non-Model Operations)
   545→
   546→Gates are registered in `AppServiceProvider` (or a dedicated `AuthServiceProvider`). Each gate resolves the current store from the container and checks the user's role via the `store_users` relationship.
   547→
   548→| Gate Name | Required Roles | Purpose |
   549→|-----------|---------------|---------|
   550→| `manage-store-settings` | Owner, Admin | Access store settings pages |
   551→| `manage-staff` | Owner, Admin | Invite, update, remove staff members |
   552→| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |
   553→| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |
   554→| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |
   555→| `manage-taxes` | Owner, Admin | Manage tax settings and rates |
   556→| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |
   557→| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |
   558→| `manage-apps` | Owner, Admin | Install and remove third-party apps |
   559→
   560→**Gate resolution pattern (all gates follow this):**
   561→
   562→1. Resolve the current store from the container
   563→2. Look up the user's `store_users` record for that store
   564→3. If no record exists, return false
   565→4. If the user's role is in the gate's required roles list, return true
   566→5. Otherwise return false
   567→
   568→---
   569→
   570→### 2.6 Policy and Gate Usage Patterns
   571→
   572→#### In Livewire Components
   573→
   574→- List views: call `authorize('viewAny', ModelClass)` in the `mount()` lifecycle method.
   575→- Action methods (delete, update, etc.): call `authorize('action', $modelInstance)` at the start of the action, before performing any mutation.
   576→- If authorization fails, Laravel throws `AuthorizationException`, which renders as 403.
   577→
   578→#### In API Controllers
   579→
   580→- Use `$this->authorize('action', $model)` at the start of each controller method.
   581→- Alternatively, use `can` middleware on routes for simpler checks.
   582→- Form Requests also implement `authorize()` to check the relevant policy or gate before validation runs.
   583→
   584→#### In Blade Templates
   585→
   586→- Use `@can('gate-name')` / `@cannot` directives to conditionally show/hide navigation items and action buttons.
   587→- This is purely cosmetic (UI hiding) - the actual authorization check happens server-side in policies/gates.
   588→
   589→---
   590→
   591→## 3. Middleware Stack
   592→
   593→### 3.1 Middleware Registration
   594→
   595→File: `bootstrap/app.php`
   596→
   597→Three custom middleware aliases must be registered:
   598→
   599→| Alias | Class | Purpose |
   600→|-------|-------|---------|
   601→| `store.resolve` | `App\Http\Middleware\ResolveStore` | Resolves current store from hostname or session |
   602→| `role.check` | `App\Http\Middleware\CheckStoreRole` | Checks user has required role for current store |
   603→| `auth:customer` | `App\Http\Middleware\CustomerAuthenticate` | Ensures customer is authenticated via customer guard |
   604→
   605→---
   606→
   607→### 3.2 Route Middleware Groups
   608→
   609→| Route Group | Middleware Stack | Description |
   610→|-------------|-----------------|-------------|
   611→| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |
   612→| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |
   613→| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |
   614→| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |
   615→| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |
   616→| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |
   617→| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |
   618→
   619→---
   620→
   621→### 3.3 Custom Middleware Specifications
   622→
   623→#### ResolveStore
   624→
   625→File: `app/Http/Middleware/ResolveStore.php`
   626→
   627→**Purpose:** Resolves the current store from the request hostname and binds it to the service container.
   628→
   629→```mermaid
   630→flowchart TD
   631→    A[Extract hostname from request] --> B{Route type?}
   632→    B -- Storefront --> C{Match hostname in store_domains table?}
   633→    C -- Yes --> F[Store found]
   634→    C -- No --> E[Abort 404: Store not found]
   635→    F --> G{Store status == suspended?}
   636→    G -- Yes --> H[Abort 503: Store unavailable]
   637→    G -- No --> I[Bind Store to container as 'current_store']
   638→    I --> J[Share Store with all views as 'currentStore']
   639→    J --> K[Continue to next middleware]
   640→    B -- Admin --> L[Read store_id from session 'current_store_id']
   641→    L --> M{User has store_users record?}
   642→    M -- No --> N[Abort 403]
   643→    M -- Yes --> I
   644→```
   645→
   646→**Requirements:**
   647→
   648→Storefront requests: Resolve store from the `store_domains` table by matching the request hostname against `store_domains.hostname`. Cache the hostname-to-store mapping using file cache (tag: `store-domains`).
   649→
   650→Admin requests: Store is determined by session key `current_store_id`, set after login or via the store-switcher in the admin panel. Admin routes do NOT use hostname resolution. The `admin.acme-fashion.test` domain in seeders routes requests to the admin panel, but the actual store context within the admin is determined by the session `current_store_id` after login.
   651→
   652→**Shared steps (after store is resolved):**
   653→
   654→1. If the store's `status` column is `'suspended'`: abort with HTTP 503 and message "This store is currently unavailable." (For admin routes: abort 403 for mutations instead.)
   655→2. Bind the resolved Store model instance to the service container with the key `'current_store'`.
   656→3. Share the store with all Blade views under the variable name `currentStore`.
   657→4. Allow the request to proceed.
   658→
   659→#### CheckStoreRole
   660→
   661→File: `app/Http/Middleware/CheckStoreRole.php`
   662→
   663→**Purpose:** Verifies the authenticated user has one of the specified roles for the current store.
   664→
   665→**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).
   666→
   667→```mermaid
   668→flowchart TD
   669→    A[Resolve current_store from container] --> B[Get authenticated user]
   670→    B --> C[Look up store_users record for user + store]
   671→    C --> D{Record exists?}
   672→    D -- No --> E[Abort 403: No access to this store]
   673→    D -- Yes --> F[Parse allowed roles from middleware parameters]
   674→    F --> G{User's role in allowed list?}
   675→    G -- No --> H[Abort 403: Insufficient permissions]
   676→    G -- Yes --> I[Attach store_user to request attributes]
   677→    I --> J[Continue to next middleware]
   678→```
   679→
   680→**Requirements:**
   681→
   682→1. Resolve the current store from the container.
   683→2. Get the authenticated user from the request.
   684→3. Query the user's `store_users` relationship filtered by `store_id`.
   685→4. If no record exists: abort 403 with message "You do not have access to this store."
   686→5. Convert the middleware parameter strings to `StoreUserRole` enum instances.
   687→6. If the user's role is not in the allowed list: abort 403 with message "Insufficient permissions."
   688→7. Store the resolved `StoreUser` pivot record on the request attributes (key: `store_user`) for downstream use.
   689→8. Allow the request to proceed.
   690→
   691→#### CustomerAuthenticate
   692→
   693→File: `app/Http/Middleware/CustomerAuthenticate.php`
   694→
   695→**Purpose:** Ensures the request is authenticated via the `customer` guard. Redirects unauthenticated customers to login.
   696→
   697→```mermaid
   698→flowchart TD
   699→    A[Check customer guard authentication] --> B{Authenticated?}
   700→    B -- Yes --> C[Continue to next middleware]
   701→    B -- No --> D[Store current URL as intended redirect]
   702→    D --> E[Redirect to /account/login]
   703→```
   704→
   705→**Requirements:**
   706→
   707→1. Check if `Auth::guard('customer')` reports the user as authenticated.
   708→2. If authenticated: allow the request to proceed.
   709→3. If not authenticated: save the current URL in the session as the intended URL (so the customer returns there after login), then redirect to the storefront login route (`/account/login`).
   710→
   711→---
   712→
   713→## 4. Security Controls
   714→
   715→### 4.1 CSRF Protection
   716→
   717→| Context | Mechanism |
   718→|---------|-----------|
   719→| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |
   720→| Livewire actions | Livewire handles CSRF verification automatically on every request |
   721→| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |
   722→| SPA clients | `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie for session-based API calls |
   723→
   724→**Requirements:**
   725→
   726→- CSRF verification is enforced globally on all `web` middleware group routes.
   727→- No routes within the `web` group may be excluded from CSRF verification.
   728→- API routes (under `api` middleware group) are inherently exempt since they use token auth.
   729→
   730→---
   731→
   732→### 4.2 Rate Limiting
   733→
   734→All rate limiters are defined in `bootstrap/app.php` (or `AppServiceProvider::boot()`).
   735→
   736→| Limiter Name | Limit | Keyed By | Applied To |
   737→|-------------|-------|----------|------------|
   738→| `login` | 5 per minute | IP address | Admin login, customer login |
   739→| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |
   740→| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |
   741→| `checkout` | 10 per minute | Session ID | Checkout-related operations |
   742→| `search` | 30 per minute | IP address | Search queries |
   743→| `analytics` | 60 per minute | IP address | Analytics event ingestion |
   744→| `webhooks` | 100 per minute | IP address | Outbound app webhook endpoints (reserved; no inbound payment webhooks - Mock PSP is in-process) |
   745→
   746→**Rate limit response headers** (returned on all API responses by Laravel default):
   747→
   748→| Header | Description |
   749→|--------|-------------|
   750→| `X-RateLimit-Limit` | Maximum requests allowed in the window |
   751→| `X-RateLimit-Remaining` | Requests remaining in the current window |
   752→| `Retry-After` | Seconds until the rate limit resets (only on 429 responses) |
   753→
   754→When a rate limit is exceeded, the response is HTTP 429 Too Many Requests.
   755→
   756→---
   757→
   758→### 4.3 Encryption at Rest
   759→
   760→Encryption algorithm: AES-256-CBC with MAC verification, keyed by the application's `APP_KEY`.
   761→
   762→**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**
   763→
   764→| Model | Column | Cast Type | Purpose |
   765→|-------|--------|-----------|---------|
   766→| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |
   767→| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |
   768→| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |
   769→
   770→**Requirements:**
   771→
   772→- These columns are declared with the `encrypted` (or `encrypted:array`) cast in the model's `casts()` method.
   773→- Data is automatically encrypted before writing to the database and decrypted when reading.
   774→- The encrypted columns store opaque ciphertext (type TEXT in SQLite). They cannot be queried with WHERE clauses at the database level.
   775→
   776→---
   777→
   778→### 4.4 Input Validation
   779→
   780→All write endpoints must use dedicated Form Request classes. Validation rules use string syntax per project convention.
   781→
   782→#### Form Request Inventory
   783→
   784→| Form Request Class | Used By | Key Validation Rules |
   785→|-------------------|---------|---------------------|
   786→| `StoreProductRequest` | Create product | `title`: required, string, max:255. `description_html`: nullable, string. `status`: required, in:active,draft,archived. `vendor`: nullable, string. Price fields: required, integer, min:0. |
   787→| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |
   788→| `StoreCollectionRequest` | Create collection | `title`: required, string, max:255. `type`: required, in:manual,automated. `rules_json`: required_if:type,automated, valid JSON. |
   789→| `UpdateCollectionRequest` | Update collection | Same as StoreCollectionRequest, all optional. |
   790→| `StoreDiscountRequest` | Create discount | `type`: required, in:code,automatic. `code`: required_if:type,code, string, max:50, unique per store (case-insensitive). `value_type`: required, in:fixed,percent,free_shipping. `value_amount`: required_unless:value_type,free_shipping, integer, min:0; when value_type is `percent`, max:100. `minimum_order_amount`: nullable, integer, min:0. `usage_limit`: nullable, integer, min:1. `starts_at`: nullable, date, ISO-8601. `ends_at`: nullable, date, after:starts_at. |
   791→| `UpdateDiscountRequest` | Update discount | Same as StoreDiscountRequest, code unique ignoring self. |
   792→| `SetCheckoutAddressRequest` | Set shipping/billing address | `first_name`, `last_name`, `address1`, `city`, `country_code`: required. `province_code`: required when applicable. `postal_code`: required. `phone`: nullable. |
   793→| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |
   794→| `CreateFulfillmentRequest` | Create fulfillment | `line_item_ids`: required, array of existing line item IDs. `tracking_company`: nullable, string. `tracking_number`: nullable, string. `tracking_url`: nullable, valid URL. |
   795→| `CreateRefundRequest` | Create refund | `line_items`: required, array with `id` and `quantity` per item. `reason`: required, string. `amount`: required, integer, min:0, max:original payment amount. |
   796→| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |
   797→| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |
   798→| `RegisterCustomerRequest` | Customer registration | `name`: required. `email`: required, email, unique per store (composite). `password`: required, min:8, confirmed. |
   799→| `LoginRequest` | Admin/customer login | `email`: required, email. `password`: required, string. |
   800→
   801→**Authorization in Form Requests:**
   802→
   803→Each Form Request's `authorize()` method must check the relevant policy or gate. For example, `StoreProductRequest::authorize()` checks whether the user can create a Product. If authorization fails, the request is rejected with 403 before validation even runs.
   804→
   805→---
   806→
   807→### 4.5 XSS Prevention
   808→
   809→| Context | Strategy |
   810→|---------|----------|
   811→| Standard Blade output | Double-curly-brace syntax auto-escapes all output (Laravel default) |
   812→| Rich HTML output | Unescaped syntax used only for trusted HTML fields: product `description_html`, page `body_html` |
   813→| HTML input sanitization | Use HTMLPurifier (or equivalent) on save, before persisting to database |
   814→| Livewire properties | Auto-escaped by default when rendered in Blade |
   815→| JSON API responses | Laravel escapes JSON output by default |
   816→
   817→#### HTML Sanitization Requirements
   818→
   819→An `SanitizeHtml` action class must be created at `app/Actions/SanitizeHtml.php`.
   820→
   821→**Allowed HTML elements and attributes:**
   822→
   823→| Element | Allowed Attributes |
   824→|---------|-------------------|
   825→| `p`, `br`, `strong`, `em`, `u` | none |
   826→| `ol`, `ul`, `li` | none |
   827→| `a` | `href` |
   828→| `img` | `src`, `alt` |
   829→| `h1` through `h6` | none |
   830→| `blockquote` | none |
   831→| `table`, `thead`, `tbody`, `tr`, `th`, `td` | none |
   832→| `div`, `span` | none |
   833→
   834→All other elements and attributes must be stripped. Empty elements should be removed.
   835→
   836→**Where sanitization is applied:** Before persisting product `description_html` and page `body_html` fields. The sanitization runs during the create and update operations for these models.
   837→
   838→---
   839→
   840→### 4.6 SQL Injection Prevention
   841→
   842→**Requirements:**
   843→
   844→- All database queries must use Eloquent ORM with parameterized bindings (Laravel default behavior).
   845→- No raw query strings may contain unsanitized user input.
   846→- Any use of `whereRaw`, `selectRaw`, or `DB::raw` must use parameter bindings (the `?` placeholder syntax).
   847→- FTS5 (full-text search) queries require special handling: user input must be sanitized before building the MATCH expression.
   848→
   849→#### FTS5 Input Sanitization
   850→
   851→Before using user input in an FTS5 MATCH expression:
   852→
   853→1. Strip all characters that are not Unicode letters, Unicode numbers, or whitespace (remove FTS5 special operators like `*`, `AND`, `OR`, `NOT`, `NEAR`, quotes, parentheses, etc.).
   854→2. Trim leading and trailing whitespace.
   855→3. Pass the sanitized string via a parameterized binding (never concatenate into the query string).
   856→
   857→---
   858→
   859→### 4.7 Mass Assignment Protection
   860→
   861→**Requirements:**
   862→
   863→- Every Eloquent model must define a `$fillable` array listing only the columns that are safe for mass assignment.
   864→- The pattern `$guarded = []` is never permitted in any model.
   865→- All controller and Livewire action code must use `$request->validated()` (not `$request->all()` or `$request->input()`) when passing data to model creation or update methods.
   866→
   867→---
   868→
   869→### 4.8 Password Security
   870→
   871→| Setting | Value |
   872→|---------|-------|
   873→| Minimum length | 8 characters (for both admin and customer passwords) |
   874→| Hashing algorithm | Bcrypt (Laravel default) |
   875→| Password in logs | Never logged - use Laravel's built-in log redaction |
   876→| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |
   877→
   878→**Model requirements:**
   879→
   880→- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.
   881→- Password validation rules must enforce minimum 8 characters and use the `confirmed` rule (requiring a matching `password_confirmation` field).
   882→
   883→**Important:** Both the `User` and `Customer` models use `password_hash` as the database column name instead of Laravel's default `password`. Both models must override `getAuthPassword()`:
   884→
   885→```php
   886→public function getAuthPassword(): string
   887→{
   888→    return $this->password_hash;
   889→}
   890→```
   891→
   892→This is required for `Auth::attempt()` and `Hash::check()` to work correctly with the custom column name.
   893→
   894→---
   895→
   896→## 5. Session Configuration
   897→
   898→File: `config/session.php`
   899→
   900→| Setting | Value | Rationale |
   901→|---------|-------|-----------|
   902→| `driver` | `'file'` | Simple, no external dependencies. Compatible with SQLite-based stack without needing Redis. |
   903→| `lifetime` | 120 (minutes) | Configurable via `SESSION_LIFETIME` env var. |
   904→| `expire_on_close` | `false` | Session persists until lifetime expires, not on browser close. |
   905→| `encrypt` | `true` | Session data encrypted at rest on disk. |
   906→| `cookie` | `'shop_session'` | Custom name to avoid generic fingerprinting. Configurable via `SESSION_COOKIE` env var. |
   907→| `path` | `'/'` | Cookie available across entire domain. |
   908→| `domain` | `null` | Laravel determines from request. |
   909→| `secure` | `true` | HTTPS-only cookies in production. Configurable via `SESSION_SECURE_COOKIE` env var (set to false for local dev). |
   910→| `http_only` | `true` | Cookie inaccessible to JavaScript (mitigates XSS-based cookie theft). |
   911→| `same_site` | `'lax'` | Allows navigation-initiated requests while preventing CSRF from external origins. |
   912→| `partitioned` | `false` | Not needed for this use case. |
   913→
   914→---
   915→
   916→## 6. CORS Configuration
   917→
   918→File: `config/cors.php`
   919→
   920→| Setting | Value | Rationale |
   921→|---------|-------|-----------|
   922→| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |
   923→| `allowed_methods` | `['*']` | All HTTP methods allowed for API. |
   924→| `allowed_origins` | `[FRONTEND_URL env var, default '*']` | Restrict to store domain in production. Wildcard for development. |
   925→| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |
   926→| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |
   927→| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |
   928→| `max_age` | `0` | No preflight caching (conservative default). |
   929→| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |
   930→
   931→---
   932→
   933→## 7. Webhook Signature Verification
   934→
   935→### 7.1 Inbound Payment Webhooks
   936→
   937→> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).
   938→
   939→---
   940→
   941→### 7.2 Platform Webhooks (Outbound)
   942→
   943→Service: `App\Services\WebhookDispatcher`
   944→
   945→When the platform sends webhooks to external subscribers (third-party apps, integrations).
   946→
   947→```mermaid
   948→sequenceDiagram
   949→    participant App as Platform
   950→    participant Sub as Subscriber URL
   951→
   952→    App->>App: Serialize payload as JSON
   953→    App->>App: Generate Unix timestamp
   954→    App->>App: Concatenate: "{timestamp}.{json_body}"
   955→    App->>App: HMAC-SHA256(signing_secret, concatenated_message)
   956→    App->>Sub: POST to subscriber URL
   957→    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json
   958→    Sub-->>App: 200 OK (acknowledged)
   959→```
   960→
   961→**Requirements:**
   962→
   963→1. The payload is serialized as JSON.
   964→2. A Unix timestamp is generated (current time).
   965→3. The message to sign is: `"{timestamp}.{json_body}"` (timestamp, dot, JSON string).
   966→4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).
   967→5. The HTTP request to the subscriber includes three headers:
   968→   - `X-Platform-Signature`: the hex-encoded HMAC digest
   969→   - `X-Platform-Timestamp`: the Unix timestamp as a string
   970→   - `Content-Type`: `application/json`
   971→6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.
   972→
   973→---
   974→
   975→## 8. Audit Logging
   976→
   977→### 8.1 Logging Channel
   978→
   979→A dedicated `audit` channel must be added to `config/logging.php`.
   980→
   981→| Setting | Value |
   982→|---------|-------|
   983→| Driver | `daily` (file-based, daily rotation) |
   984→| Path | `storage/logs/audit.log` |
   985→| Level | `info` |
   986→| Retention | 90 days |
   987→
   988→---
   989→
   990→### 8.2 Log Entry Format
   991→
   992→All audit log entries are structured JSON objects with the following schema:
   993→
   994→| Field | Type | Required | Description |
   995→|-------|------|:--------:|-------------|
   996→| `timestamp` | string (ISO-8601) | Y | When the event occurred |
   997→| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |
   998→| `user_id` | integer or null | N | ID of the admin user who triggered the event |
   999→| `store_id` | integer or null | N | ID of the store context |
  1000→| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |
  1001→| `resource_id` | integer or null | N | ID of the affected resource |
  1002→| `ip` | string | Y | Client IP address |
  1003→| `user_agent` | string | Y | Client user agent string |
  1004→| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |
  1005→| (additional keys) | varies | N | Event-specific extra data |
  1006→
  1007→---
  1008→
  1009→### 8.3 Events to Log
  1010→
  1011→#### Authentication Events
  1012→
  1013→| Event ID | Trigger | Extra Data |
  1014→|----------|---------|------------|
  1015→| `auth.login` | Successful admin login | user_id, ip, user_agent |
  1016→| `auth.logout` | Admin logout | user_id |
  1017→| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |
  1018→| `customer.login` | Successful customer login | customer_id, store_id, ip |
  1019→| `customer.registered` | New customer registration | customer_id, store_id |
  1020→| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |
  1021→
  1022→#### Admin Resource Events
  1023→
  1024→For each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:
  1025→
  1026→**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff
  1027→
  1028→| Event Pattern | Trigger | Extra Data |
  1029→|---------------|---------|------------|
  1030→| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |
  1031→| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |
  1032→| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |
  1033→| `{resource}.restored` | Resource restored from soft-delete | resource_id |
  1034→
  1035→#### Security Events
  1036→
  1037→| Event ID | Trigger | Extra Data |
  1038→|----------|---------|------------|
  1039→| `api_token.created` | New API token issued | token_name, abilities list, user_id |
  1040→| `api_token.revoked` | API token revoked | token_name, user_id |
  1041→| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |
  1042→| `store.settings_changed` | Store settings updated | changed fields with old/new values |
  1043→
  1044→---
  1045→
  1046→### 8.4 Audit Logger Service
  1047→
  1048→File: `app/Services/AuditLogger.php`
  1049→
  1050→A service class that encapsulates audit log writing.
  1051→
  1052→**Method signature:**
  1053→
  1054→| Parameter | Type | Required | Description |
  1055→|-----------|------|:--------:|-------------|
  1056→| `event` | string | Y | Event identifier |
  1057→| `userId` | int or null | N | Acting user ID |
  1058→| `storeId` | int or null | N | Store context ID |
  1059→| `resourceType` | string or null | N | Affected resource type |
  1060→| `resourceId` | int or null | N | Affected resource ID |
  1061→| `extra` | associative array | N | Additional key-value pairs to include in the log entry |
  1062→
  1063→**Behavior:**
  1064→
  1065→1. Write to the `audit` logging channel at the `info` level.
  1066→2. Automatically include `timestamp` (ISO-8601), `ip` (from request), and `user_agent` (from request) in every entry.
  1067→3. Merge any `extra` data into the log context.
  1068→
  1069→---
  1070→
  1071→### 8.5 Integration with Laravel Events
  1072→
  1073→The audit logger hooks into Laravel's built-in authentication events and custom model observers.
  1074→
  1075→#### Auth Event Listeners
  1076→
  1077→Register listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:
  1078→
  1079→| Laravel Event | Audit Event ID | Data Captured |
  1080→|--------------|----------------|---------------|
  1081→| `Illuminate\Auth\Events\Login` | `auth.login` | user_id from event |
  1082→| `Illuminate\Auth\Events\Failed` | `auth.failed_login` | email from credentials (never password) |
  1083→| `Illuminate\Auth\Events\Logout` | `auth.logout` | user_id from event |
  1084→
  1085→#### Model Observers
  1086→
  1087→For each auditable resource model, create an observer (or use a shared trait) that logs `created`, `updated`, and `deleted` events. The `updated` observer should capture the changed fields (dirty attributes) with their original and new values.
  1088→
  1089→---
  1090→
  1091→## 9. Security Checklist Summary
  1092→
  1093→| # | Control | Implementation Approach | Priority |
  1094→|---|---------|------------------------|----------|
  1095→| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |
  1096→| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |
  1097→| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |
  1098→| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |
  1099→| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |
  1100→| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |
  1101→| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |
  1102→| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |
  1103→| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |
  1104→| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |
  1105→| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |
  1106→| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |
  1107→| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |
  1108→| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |
  1109→| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |
  1110→| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |
  1111→

content:
# 06 - Authentication and Security Specification

> E-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite

---

## 1. Authentication

The platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.

### 1.1 Admin Authentication

| Setting | Value |
|---------|-------|
| Guard name | `web` (default Laravel guard) |
| Provider name | `users` (Eloquent driver) |
| Model | `App\Models\User` |
| Driver | Session (cookie-based) |
| Login route | `GET /admin/login` |
| Login form | Livewire component `Admin\Auth\Login` |

#### Admin Login Flow

```mermaid
sequenceDiagram
    participant B as Browser
    participant LW as Livewire Component
    participant Auth as Auth Guard (web)
    participant DB as Database
    participant S as Session

    B->>LW: Submit email + password
    LW->>LW: Validate input (email required, password required)
    LW->>LW: Check rate limiter (5/min per IP)
    alt Rate limit exceeded
        LW-->>B: Flash "Too many attempts. Try again in X seconds."
    end
    LW->>Auth: attempt(credentials, remember)
    Auth->>DB: SELECT from users WHERE email = ?
    Auth->>Auth: Verify bcrypt hash
    alt Credentials valid
        Auth->>S: Regenerate session ID (prevent fixation)
        Auth-->>LW: Success
        LW-->>B: Redirect to /admin (dashboard)
    else Credentials invalid
        Auth-->>LW: Failure
        LW-->>B: Flash "Invalid credentials" (generic, never reveal which field)
    end
```

**Requirements:**

- The login form collects `email` and `password`.
- On successful login, the session MUST be regenerated to prevent session fixation attacks.
- On failed login, only the generic message "Invalid credentials" is shown. The system must not indicate whether the email or password was wrong.
- Rate limiting: maximum 5 attempts per minute, keyed by IP address. Uses the `login` rate limiter.
- After login: redirect to `/admin` (the dashboard).

#### Remember Me

- An optional "Remember me" checkbox on the login form.
- When enabled, Laravel sets a long-lived remember token cookie.
- The `remember_token` column on the `users` table stores the hashed token.
- Cookie name follows Laravel default convention: `remember_web_{hash}`.

#### Admin Password Reset Flow

```mermaid
sequenceDiagram
    participant B as Browser
    participant App as Application
    participant DB as Database
    participant Mail as Mail Service

    B->>App: GET /admin/forgot-password
    App-->>B: Render forgot-password form
    B->>App: POST /admin/forgot-password (email)
    App->>DB: Look up user by email
    alt User exists
        App->>DB: Store reset token in password_reset_tokens table
        App->>Mail: Send reset link email
    end
    App-->>B: "If that email exists, we sent a reset link." (generic)
    B->>App: GET /admin/reset-password/{token}
    App-->>B: Render reset-password form
    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)
    App->>DB: Validate token (exists, not expired, matches email)
    alt Valid token
        App->>DB: Update user password (bcrypt hash)
        App->>DB: Delete used token
        App-->>B: Redirect to /admin/login with success flash
    else Invalid/expired token
        App-->>B: Error message
    end
```

**Requirements:**

- Uses Laravel's built-in password broker (`users` broker).
- Reset tokens stored in the `password_reset_tokens` table.
- Token expiration: 60 minutes (configurable in `config/auth.php` under `passwords.users.expire`).
- Throttle: one reset email per 60 seconds per email address.
- The "forgot password" response must be generic (do not reveal whether email exists).

#### Admin Logout

- Route: `POST /admin/logout` (via Livewire action or standard route).
- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.
- The response must not cache.

#### Session Lifetime

| Setting | Value |
|---------|-------|
| Lifetime | 120 minutes (configurable via `SESSION_LIFETIME` env var) |
| Expire on close | No (session persists until lifetime expires) |

---

### 1.2 Customer Authentication

| Setting | Value |
|---------|-------|
| Guard name | `customer` (custom guard) |
| Provider name | `customers` (Eloquent driver) |
| Model | `App\Models\Customer` |
| Driver | Session (cookie-based) |
| Login route | `GET /account/login` |
| Registration route | `GET /account/register` |

#### Customer Login Flow

```mermaid
sequenceDiagram
    participant B as Browser
    participant LW as Livewire Component
    participant Auth as Auth Guard (customer)
    participant Store as ResolveStore Middleware
    participant DB as Database

    Note over Store: Store already resolved from hostname
    B->>LW: Submit email + password
    LW->>LW: Validate input
    LW->>LW: Check rate limiter (5/min per IP)
    LW->>Auth: attempt(credentials + store_id, remember)
    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?
    Auth->>Auth: Verify bcrypt hash
    alt Valid
        Auth-->>LW: Success (regenerate session)
        LW-->>B: Redirect to /account (or intended URL)
    else Invalid
        Auth-->>LW: Failure
        LW-->>B: Flash "Invalid credentials"
    end
```

**Requirements:**

- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.
- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.
- The same email address can exist in different stores (multi-tenant isolation).
- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.
- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).

#### Customer Registration Flow

```mermaid
flowchart TD
    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]
    B --> C{Validate via RegisterCustomerRequest}
    C -- Invalid --> D[Show validation errors]
    C -- Valid --> E[Create Customer record scoped to current store]
    E --> F[Auto-login via customer guard]
    F --> G[Redirect to /account]
```

**Registration fields:**

| Field | Type | Required | Validation |
|-------|------|:--------:|------------|
| name | string | Y | max:255 |
| email | string | Y | valid email, unique per store (composite unique on email + store_id) |
| password | string | Y | min:8, confirmed |
| password_confirmation | string | Y | must match password |
| marketing_opt_in | boolean | N | defaults to false |

#### Customer Store Scoping

- The `customers` auth provider requires a custom `CustomerUserProvider` class.
- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.
- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).
- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.

#### Customer Password Reset

- Uses a separate password broker: `customers` broker.
- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
- Token records include `store_id` to scope resets to the correct store.
- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.
- Token expiration: 60 minutes.
- Throttle: one reset email per 60 seconds.
- Generic response on the "forgot password" form (do not reveal whether email exists in the store).

---

### 1.3 API Authentication (Sanctum)

| Setting | Value |
|---------|-------|
| Guard name | `sanctum` |
| Provider | `users` (admin users only) |
| Token type | Personal access tokens |
| Token prefix | `shop_` |
| Hashing | SHA-256 |
| Default expiration | 1 year |
| Storage | `personal_access_tokens` table |

#### Token Lifecycle

```mermaid
stateDiagram-v2
    [*] --> Created: Admin creates token in Developers section
    Created --> Active: Plain-text shown once, hash stored
    Active --> InUse: Token sent as Bearer header
    InUse --> Active: Request processed
    Active --> Revoked: Admin revokes token
    Active --> Expired: Expiration date reached
    Revoked --> [*]: Token row deleted
    Expired --> [*]: Token row deleted (cleanup job)
```

**Requirements:**

- Tokens are created in the admin panel, under the Developers section.
- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).
- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.
- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.
- Sanctum middleware validates the hash and resolves the associated user.
- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).
- Revoking a token deletes its row from the `personal_access_tokens` table.

#### Token Abilities (Scopes)

| Ability | Description |
|---------|-------------|
| `read-products` | List and view products |
| `write-products` | Create, update, delete products |
| `read-orders` | List and view orders |
| `write-orders` | Update orders, create fulfillments |
| `read-customers` | List and view customers |
| `write-customers` | Update customers |
| `read-collections` | List and view collections |
| `write-collections` | Create, update, delete collections |
| `read-discounts` | List and view discounts |
| `write-discounts` | Create, update, delete discounts |
| `read-analytics` | View analytics data |
| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |
| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |
| `read-themes` | View themes and theme files |
| `write-themes` | Create, update, delete, publish themes |
| `read-content` | View pages and navigation menus |
| `write-content` | Create, update, delete pages and navigation items |
| `manage-platform` | Platform-level management (super-admin only) |

Each API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.

---

### 1.4 Guard Configuration

File to configure: `config/auth.php`

#### Guards Data Dictionary

| Guard Name | Driver | Provider | Purpose |
|------------|--------|----------|---------|
| `web` | session | `users` | Admin panel session auth (default guard) |
| `customer` | session | `customers` | Storefront customer session auth |
| `sanctum` | sanctum | `users` | Admin REST API token auth |

#### Providers Data Dictionary

| Provider Name | Driver | Model | Purpose |
|---------------|--------|-------|---------|
| `users` | eloquent | `App\Models\User` | Admin users |
| `customers` | eloquent | `App\Models\Customer` | Storefront customers (store-scoped via custom provider) |

#### Password Brokers Data Dictionary

| Broker Name | Provider | Token Table | Expiry | Throttle |
|-------------|----------|-------------|--------|----------|
| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |
| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |

**Defaults:** The default guard is `web` and the default password broker is `users`.

---

## 2. Authorization

### 2.1 Role Enum

File: `app/Enums/StoreUserRole.php`

A backed string enum with four values:

| Case | Stored Value | Description |
|------|-------------|-------------|
| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |
| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |
| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |
| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |

**Constraints:**

- Each store must have exactly one Owner.
- Ownership transfer is a dedicated action (not a simple role change).
- A User can hold different roles in different stores (via the `store_users` pivot table).

---

### 2.2 Permission Matrix

The `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.

| Resource | Action | Owner | Admin | Staff | Support |
|----------|--------|:-----:|:-----:|:-----:|:-------:|
| Store Settings | View | Y | Y | N | N |
| Store Settings | Update | Y | Y | N | N |
| Store | Delete | Y | N | N | N |
| Products | List / View | Y | Y | Y | Y |
| Products | Create | Y | Y | Y | N |
| Products | Update | Y | Y | Y | N |
| Products | Delete / Archive | Y | Y | N | N |
| Products | Restore | Y | Y | N | N |
| Collections | List / View | Y | Y | Y | Y |
| Collections | Create | Y | Y | Y | N |
| Collections | Update | Y | Y | Y | N |
| Collections | Delete | Y | Y | N | N |
| Orders | List / View | Y | Y | Y | Y |
| Orders | Update | Y | Y | Y | N |
| Orders | Cancel | Y | Y | N | N |
| Fulfillments | Create | Y | Y | Y | N |
| Fulfillments | Update / Cancel | Y | Y | Y | N |
| Refunds | Create | Y | Y | N | N |
| Customers | List / View | Y | Y | Y | Y |
| Customers | Update | Y | Y | Y | N |
| Discounts | List / View | Y | Y | Y | Y |
| Discounts | Create | Y | Y | Y | N |
| Discounts | Update | Y | Y | Y | N |
| Discounts | Delete | Y | Y | N | N |
| Themes | List / View | Y | Y | N | N |
| Themes | Create / Upload | Y | Y | N | N |
| Themes | Customize | Y | Y | N | N |
| Themes | Publish | Y | Y | N | N |
| Themes | Delete | Y | Y | N | N |
| Pages | List / View | Y | Y | Y | N |
| Pages | Create | Y | Y | Y | N |
| Pages | Update | Y | Y | Y | N |
| Pages | Delete | Y | Y | N | N |
| Navigation | List / View | Y | Y | Y | N |
| Navigation | Manage | Y | Y | N | N |
| Apps | List / View | Y | Y | N | N |
| Apps | Install / Remove | Y | Y | N | N |
| Analytics | View | Y | Y | Y | N |
| Developers / API | Manage | Y | Y | N | N |
| Staff | List / View | Y | Y | N | N |
| Staff | Invite | Y | Y | N | N |
| Staff | Update Role | Y | Y | N | N |
| Staff | Remove | Y | Y | N | N |
| Shipping Zones | Manage | Y | Y | N | N |
| Tax Settings | Manage | Y | Y | N | N |
| Search Settings | Manage | Y | Y | N | N |

#### Role Group Shorthand

The permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:

| Group Name | Roles Included | Used For |
|------------|---------------|----------|
| OwnerOnly | Owner | Store deletion, ownership transfer |
| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |
| OwnerAdminOrStaff | Owner, Admin, Staff | Create/update products, collections, discounts, pages, fulfillments, order updates |
| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |

---

### 2.3 Role Checking Trait

File: `app/Traits/ChecksStoreRole.php`

A reusable trait included in all policies and used by gate definitions. Must provide the following methods:

| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `getStoreRole` | User, store_id (int) | StoreUserRole or null | Looks up the user's role for the given store via the `store_users` relationship. Returns null if the user has no role in that store. |
| `hasRole` | User, store_id (int), roles (array of StoreUserRole) | bool | Returns true if the user's role for the store is in the provided list. Returns false if user has no role. |
| `isOwnerOrAdmin` | User, store_id (int) | bool | Shorthand: checks if role is Owner or Admin. |
| `isOwnerAdminOrStaff` | User, store_id (int) | bool | Shorthand: checks if role is Owner, Admin, or Staff. |
| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |

**Store ID resolution:** When the policy method receives a model instance (e.g., Product), the store_id comes from the model's `store_id` attribute. When the policy method does not receive a model (e.g., `viewAny`, `create`), the store_id is resolved from the container-bound `current_store`.

---

### 2.4 Laravel Policies

All policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\Policies\{Model}Policy`), so no manual registration is needed.

Every policy method follows this pattern:
1. Determine the `store_id` (from the model if available, otherwise from the resolved current store).
2. Use the appropriate role-checking helper method.
3. Return a boolean.

#### ProductPolicy

File: `app/Policies/ProductPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | AnyRole | current_store |
| `view(User, Product)` | Yes | AnyRole | product.store_id |
| `create(User)` | No | OwnerAdminOrStaff | current_store |
| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |
| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |

#### OrderPolicy

File: `app/Policies/OrderPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | AnyRole | current_store |
| `view(User, Order)` | Yes | AnyRole | order.store_id |
| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |
| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |
| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |
| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |

#### CollectionPolicy

File: `app/Policies/CollectionPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | AnyRole | current_store |
| `view(User, Collection)` | Yes | AnyRole | collection.store_id |
| `create(User)` | No | OwnerAdminOrStaff | current_store |
| `update(User, Collection)` | Yes | OwnerAdminOrStaff | collection.store_id |
| `delete(User, Collection)` | Yes | OwnerOrAdmin | collection.store_id |

#### DiscountPolicy

File: `app/Policies/DiscountPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | AnyRole | current_store |
| `view(User, Discount)` | Yes | AnyRole | discount.store_id |
| `create(User)` | No | OwnerAdminOrStaff | current_store |
| `update(User, Discount)` | Yes | OwnerAdminOrStaff | discount.store_id |
| `delete(User, Discount)` | Yes | OwnerOrAdmin | discount.store_id |

#### CustomerPolicy

File: `app/Policies/CustomerPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | AnyRole | current_store |
| `view(User, Customer)` | Yes | AnyRole | customer.store_id |
| `update(User, Customer)` | Yes | OwnerAdminOrStaff | customer.store_id |

#### ThemePolicy

File: `app/Policies/ThemePolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | OwnerOrAdmin | current_store |
| `view(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |
| `create(User)` | No | OwnerOrAdmin | current_store |
| `update(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |
| `delete(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |
| `publish(User, Theme)` | Yes | OwnerOrAdmin | theme.store_id |

#### PagePolicy

File: `app/Policies/PagePolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |
| `view(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |
| `create(User)` | No | OwnerAdminOrStaff | current_store |
| `update(User, Page)` | Yes | OwnerAdminOrStaff | page.store_id |
| `delete(User, Page)` | Yes | OwnerOrAdmin | page.store_id |

#### StorePolicy

File: `app/Policies/StorePolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |
| `updateSettings(User, Store)` | Yes | OwnerOrAdmin | store.id |
| `delete(User, Store)` | Yes | OwnerOnly | store.id |

#### FulfillmentPolicy

File: `app/Policies/FulfillmentPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |
| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |
| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |

Note: `create` receives the parent Order, not a Fulfillment, since the Fulfillment does not exist yet. The `update` and `cancel` methods navigate through the Fulfillment's order relationship to get the store_id.

#### RefundPolicy

File: `app/Policies/RefundPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |

Note: `create` receives the parent Order for the same reason as FulfillmentPolicy.

#### NavigationMenuPolicy

File: `app/Policies/NavigationMenuPolicy.php`

| Method | Receives Model? | Required Roles | Store ID Source |
|--------|:--------------:|----------------|-----------------|
| `viewAny(User)` | No | OwnerAdminOrStaff | current_store |
| `manage(User)` | No | OwnerOrAdmin | current_store |

---

### 2.5 Gates (Non-Model Operations)

Gates are registered in `AppServiceProvider` (or a dedicated `AuthServiceProvider`). Each gate resolves the current store from the container and checks the user's role via the `store_users` relationship.

| Gate Name | Required Roles | Purpose |
|-----------|---------------|---------|
| `manage-store-settings` | Owner, Admin | Access store settings pages |
| `manage-staff` | Owner, Admin | Invite, update, remove staff members |
| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |
| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |
| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |
| `manage-taxes` | Owner, Admin | Manage tax settings and rates |
| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |
| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |
| `manage-apps` | Owner, Admin | Install and remove third-party apps |

**Gate resolution pattern (all gates follow this):**

1. Resolve the current store from the container
2. Look up the user's `store_users` record for that store
3. If no record exists, return false
4. If the user's role is in the gate's required roles list, return true
5. Otherwise return false

---

### 2.6 Policy and Gate Usage Patterns

#### In Livewire Components

- List views: call `authorize('viewAny', ModelClass)` in the `mount()` lifecycle method.
- Action methods (delete, update, etc.): call `authorize('action', $modelInstance)` at the start of the action, before performing any mutation.
- If authorization fails, Laravel throws `AuthorizationException`, which renders as 403.

#### In API Controllers

- Use `$this->authorize('action', $model)` at the start of each controller method.
- Alternatively, use `can` middleware on routes for simpler checks.
- Form Requests also implement `authorize()` to check the relevant policy or gate before validation runs.

#### In Blade Templates

- Use `@can('gate-name')` / `@cannot` directives to conditionally show/hide navigation items and action buttons.
- This is purely cosmetic (UI hiding) - the actual authorization check happens server-side in policies/gates.

---

## 3. Middleware Stack

### 3.1 Middleware Registration

File: `bootstrap/app.php`

Three custom middleware aliases must be registered:

| Alias | Class | Purpose |
|-------|-------|---------|
| `store.resolve` | `App\Http\Middleware\ResolveStore` | Resolves current store from hostname or session |
| `role.check` | `App\Http\Middleware\CheckStoreRole` | Checks user has required role for current store |
| `auth:customer` | `App\Http\Middleware\CustomerAuthenticate` | Ensures customer is authenticated via customer guard |

---

### 3.2 Route Middleware Groups

| Route Group | Middleware Stack | Description |
|-------------|-----------------|-------------|
| Admin web (auth pages) | `web` | Login, forgot-password, reset-password |
| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |
| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |
| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |
| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |
| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |
| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |

---

### 3.3 Custom Middleware Specifications

#### ResolveStore

File: `app/Http/Middleware/ResolveStore.php`

**Purpose:** Resolves the current store from the request hostname and binds it to the service container.

```mermaid
flowchart TD
    A[Extract hostname from request] --> B{Route type?}
    B -- Storefront --> C{Match hostname in store_domains table?}
    C -- Yes --> F[Store found]
    C -- No --> E[Abort 404: Store not found]
    F --> G{Store status == suspended?}
    G -- Yes --> H[Abort 503: Store unavailable]
    G -- No --> I[Bind Store to container as 'current_store']
    I --> J[Share Store with all views as 'currentStore']
    J --> K[Continue to next middleware]
    B -- Admin --> L[Read store_id from session 'current_store_id']
    L --> M{User has store_users record?}
    M -- No --> N[Abort 403]
    M -- Yes --> I
```

**Requirements:**

Storefront requests: Resolve store from the `store_domains` table by matching the request hostname against `store_domains.hostname`. Cache the hostname-to-store mapping using file cache (tag: `store-domains`).

Admin requests: Store is determined by session key `current_store_id`, set after login or via the store-switcher in the admin panel. Admin routes do NOT use hostname resolution. The `admin.acme-fashion.test` domain in seeders routes requests to the admin panel, but the actual store context within the admin is determined by the session `current_store_id` after login.

**Shared steps (after store is resolved):**

1. If the store's `status` column is `'suspended'`: abort with HTTP 503 and message "This store is currently unavailable." (For admin routes: abort 403 for mutations instead.)
2. Bind the resolved Store model instance to the service container with the key `'current_store'`.
3. Share the store with all Blade views under the variable name `currentStore`.
4. Allow the request to proceed.

#### CheckStoreRole

File: `app/Http/Middleware/CheckStoreRole.php`

**Purpose:** Verifies the authenticated user has one of the specified roles for the current store.

**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).

```mermaid
flowchart TD
    A[Resolve current_store from container] --> B[Get authenticated user]
    B --> C[Look up store_users record for user + store]
    C --> D{Record exists?}
    D -- No --> E[Abort 403: No access to this store]
    D -- Yes --> F[Parse allowed roles from middleware parameters]
    F --> G{User's role in allowed list?}
    G -- No --> H[Abort 403: Insufficient permissions]
    G -- Yes --> I[Attach store_user to request attributes]
    I --> J[Continue to next middleware]
```

**Requirements:**

1. Resolve the current store from the container.
2. Get the authenticated user from the request.
3. Query the user's `store_users` relationship filtered by `store_id`.
4. If no record exists: abort 403 with message "You do not have access to this store."
5. Convert the middleware parameter strings to `StoreUserRole` enum instances.
6. If the user's role is not in the allowed list: abort 403 with message "Insufficient permissions."
7. Store the resolved `StoreUser` pivot record on the request attributes (key: `store_user`) for downstream use.
8. Allow the request to proceed.

#### CustomerAuthenticate

File: `app/Http/Middleware/CustomerAuthenticate.php`

**Purpose:** Ensures the request is authenticated via the `customer` guard. Redirects unauthenticated customers to login.

```mermaid
flowchart TD
    A[Check customer guard authentication] --> B{Authenticated?}
    B -- Yes --> C[Continue to next middleware]
    B -- No --> D[Store current URL as intended redirect]
    D --> E[Redirect to /account/login]
```

**Requirements:**

1. Check if `Auth::guard('customer')` reports the user as authenticated.
2. If authenticated: allow the request to proceed.
3. If not authenticated: save the current URL in the session as the intended URL (so the customer returns there after login), then redirect to the storefront login route (`/account/login`).

---

## 4. Security Controls

### 4.1 CSRF Protection

| Context | Mechanism |
|---------|-----------|
| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |
| Livewire actions | Livewire handles CSRF verification automatically on every request |
| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |
| SPA clients | `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie for session-based API calls |

**Requirements:**

- CSRF verification is enforced globally on all `web` middleware group routes.
- No routes within the `web` group may be excluded from CSRF verification.
- API routes (under `api` middleware group) are inherently exempt since they use token auth.

---

### 4.2 Rate Limiting

All rate limiters are defined in `bootstrap/app.php` (or `AppServiceProvider::boot()`).

| Limiter Name | Limit | Keyed By | Applied To |
|-------------|-------|----------|------------|
| `login` | 5 per minute | IP address | Admin login, customer login |
| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |
| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |
| `checkout` | 10 per minute | Session ID | Checkout-related operations |
| `search` | 30 per minute | IP address | Search queries |
| `analytics` | 60 per minute | IP address | Analytics event ingestion |
| `webhooks` | 100 per minute | IP address | Outbound app webhook endpoints (reserved; no inbound payment webhooks - Mock PSP is in-process) |

**Rate limit response headers** (returned on all API responses by Laravel default):

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests allowed in the window |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
| `Retry-After` | Seconds until the rate limit resets (only on 429 responses) |

When a rate limit is exceeded, the response is HTTP 429 Too Many Requests.

---

### 4.3 Encryption at Rest

Encryption algorithm: AES-256-CBC with MAC verification, keyed by the application's `APP_KEY`.

**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**

| Model | Column | Cast Type | Purpose |
|-------|--------|-----------|---------|
| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |
| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |
| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |

**Requirements:**

- These columns are declared with the `encrypted` (or `encrypted:array`) cast in the model's `casts()` method.
- Data is automatically encrypted before writing to the database and decrypted when reading.
- The encrypted columns store opaque ciphertext (type TEXT in SQLite). They cannot be queried with WHERE clauses at the database level.

---

### 4.4 Input Validation

All write endpoints must use dedicated Form Request classes. Validation rules use string syntax per project convention.

#### Form Request Inventory

| Form Request Class | Used By | Key Validation Rules |
|-------------------|---------|---------------------|
| `StoreProductRequest` | Create product | `title`: required, string, max:255. `description_html`: nullable, string. `status`: required, in:active,draft,archived. `vendor`: nullable, string. Price fields: required, integer, min:0. |
| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |
| `StoreCollectionRequest` | Create collection | `title`: required, string, max:255. `type`: required, in:manual,automated. `rules_json`: required_if:type,automated, valid JSON. |
| `UpdateCollectionRequest` | Update collection | Same as StoreCollectionRequest, all optional. |
| `StoreDiscountRequest` | Create discount | `type`: required, in:code,automatic. `code`: required_if:type,code, string, max:50, unique per store (case-insensitive). `value_type`: required, in:fixed,percent,free_shipping. `value_amount`: required_unless:value_type,free_shipping, integer, min:0; when value_type is `percent`, max:100. `minimum_order_amount`: nullable, integer, min:0. `usage_limit`: nullable, integer, min:1. `starts_at`: nullable, date, ISO-8601. `ends_at`: nullable, date, after:starts_at. |
| `UpdateDiscountRequest` | Update discount | Same as StoreDiscountRequest, code unique ignoring self. |
| `SetCheckoutAddressRequest` | Set shipping/billing address | `first_name`, `last_name`, `address1`, `city`, `country_code`: required. `province_code`: required when applicable. `postal_code`: required. `phone`: nullable. |
| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |
| `CreateFulfillmentRequest` | Create fulfillment | `line_item_ids`: required, array of existing line item IDs. `tracking_company`: nullable, string. `tracking_number`: nullable, string. `tracking_url`: nullable, valid URL. |
| `CreateRefundRequest` | Create refund | `line_items`: required, array with `id` and `quantity` per item. `reason`: required, string. `amount`: required, integer, min:0, max:original payment amount. |
| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |
| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |
| `RegisterCustomerRequest` | Customer registration | `name`: required. `email`: required, email, unique per store (composite). `password`: required, min:8, confirmed. |
| `LoginRequest` | Admin/customer login | `email`: required, email. `password`: required, string. |

**Authorization in Form Requests:**

Each Form Request's `authorize()` method must check the relevant policy or gate. For example, `StoreProductRequest::authorize()` checks whether the user can create a Product. If authorization fails, the request is rejected with 403 before validation even runs.

---

### 4.5 XSS Prevention

| Context | Strategy |
|---------|----------|
| Standard Blade output | Double-curly-brace syntax auto-escapes all output (Laravel default) |
| Rich HTML output | Unescaped syntax used only for trusted HTML fields: product `description_html`, page `body_html` |
| HTML input sanitization | Use HTMLPurifier (or equivalent) on save, before persisting to database |
| Livewire properties | Auto-escaped by default when rendered in Blade |
| JSON API responses | Laravel escapes JSON output by default |

#### HTML Sanitization Requirements

An `SanitizeHtml` action class must be created at `app/Actions/SanitizeHtml.php`.

**Allowed HTML elements and attributes:**

| Element | Allowed Attributes |
|---------|-------------------|
| `p`, `br`, `strong`, `em`, `u` | none |
| `ol`, `ul`, `li` | none |
| `a` | `href` |
| `img` | `src`, `alt` |
| `h1` through `h6` | none |
| `blockquote` | none |
| `table`, `thead`, `tbody`, `tr`, `th`, `td` | none |
| `div`, `span` | none |

All other elements and attributes must be stripped. Empty elements should be removed.

**Where sanitization is applied:** Before persisting product `description_html` and page `body_html` fields. The sanitization runs during the create and update operations for these models.

---

### 4.6 SQL Injection Prevention

**Requirements:**

- All database queries must use Eloquent ORM with parameterized bindings (Laravel default behavior).
- No raw query strings may contain unsanitized user input.
- Any use of `whereRaw`, `selectRaw`, or `DB::raw` must use parameter bindings (the `?` placeholder syntax).
- FTS5 (full-text search) queries require special handling: user input must be sanitized before building the MATCH expression.

#### FTS5 Input Sanitization

Before using user input in an FTS5 MATCH expression:

1. Strip all characters that are not Unicode letters, Unicode numbers, or whitespace (remove FTS5 special operators like `*`, `AND`, `OR`, `NOT`, `NEAR`, quotes, parentheses, etc.).
2. Trim leading and trailing whitespace.
3. Pass the sanitized string via a parameterized binding (never concatenate into the query string).

---

### 4.7 Mass Assignment Protection

**Requirements:**

- Every Eloquent model must define a `$fillable` array listing only the columns that are safe for mass assignment.
- The pattern `$guarded = []` is never permitted in any model.
- All controller and Livewire action code must use `$request->validated()` (not `$request->all()` or `$request->input()`) when passing data to model creation or update methods.

---

### 4.8 Password Security

| Setting | Value |
|---------|-------|
| Minimum length | 8 characters (for both admin and customer passwords) |
| Hashing algorithm | Bcrypt (Laravel default) |
| Password in logs | Never logged - use Laravel's built-in log redaction |
| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |

**Model requirements:**

- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.
- Password validation rules must enforce minimum 8 characters and use the `confirmed` rule (requiring a matching `password_confirmation` field).

**Important:** Both the `User` and `Customer` models use `password_hash` as the database column name instead of Laravel's default `password`. Both models must override `getAuthPassword()`:

```php
public function getAuthPassword(): string
{
    return $this->password_hash;
}
```

This is required for `Auth::attempt()` and `Hash::check()` to work correctly with the custom column name.

---

## 5. Session Configuration

File: `config/session.php`

| Setting | Value | Rationale |
|---------|-------|-----------|
| `driver` | `'file'` | Simple, no external dependencies. Compatible with SQLite-based stack without needing Redis. |
| `lifetime` | 120 (minutes) | Configurable via `SESSION_LIFETIME` env var. |
| `expire_on_close` | `false` | Session persists until lifetime expires, not on browser close. |
| `encrypt` | `true` | Session data encrypted at rest on disk. |
| `cookie` | `'shop_session'` | Custom name to avoid generic fingerprinting. Configurable via `SESSION_COOKIE` env var. |
| `path` | `'/'` | Cookie available across entire domain. |
| `domain` | `null` | Laravel determines from request. |
| `secure` | `true` | HTTPS-only cookies in production. Configurable via `SESSION_SECURE_COOKIE` env var (set to false for local dev). |
| `http_only` | `true` | Cookie inaccessible to JavaScript (mitigates XSS-based cookie theft). |
| `same_site` | `'lax'` | Allows navigation-initiated requests while preventing CSRF from external origins. |
| `partitioned` | `false` | Not needed for this use case. |

---

## 6. CORS Configuration

File: `config/cors.php`

| Setting | Value | Rationale |
|---------|-------|-----------|
| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |
| `allowed_methods` | `['*']` | All HTTP methods allowed for API. |
| `allowed_origins` | `[FRONTEND_URL env var, default '*']` | Restrict to store domain in production. Wildcard for development. |
| `allowed_origins_patterns` | `[]` | No pattern-based origin matching needed. |
| `allowed_headers` | `['*']` | All headers allowed (Authorization, Content-Type, etc.). |
| `exposed_headers` | `['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After']` | Expose rate limit headers so API clients can implement backoff. |
| `max_age` | `0` | No preflight caching (conservative default). |
| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |

---

## 7. Webhook Signature Verification

### 7.1 Inbound Payment Webhooks

> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).

---

### 7.2 Platform Webhooks (Outbound)

Service: `App\Services\WebhookDispatcher`

When the platform sends webhooks to external subscribers (third-party apps, integrations).

```mermaid
sequenceDiagram
    participant App as Platform
    participant Sub as Subscriber URL

    App->>App: Serialize payload as JSON
    App->>App: Generate Unix timestamp
    App->>App: Concatenate: "{timestamp}.{json_body}"
    App->>App: HMAC-SHA256(signing_secret, concatenated_message)
    App->>Sub: POST to subscriber URL
    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json
    Sub-->>App: 200 OK (acknowledged)
```

**Requirements:**

1. The payload is serialized as JSON.
2. A Unix timestamp is generated (current time).
3. The message to sign is: `"{timestamp}.{json_body}"` (timestamp, dot, JSON string).
4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).
5. The HTTP request to the subscriber includes three headers:
   - `X-Platform-Signature`: the hex-encoded HMAC digest
   - `X-Platform-Timestamp`: the Unix timestamp as a string
   - `Content-Type`: `application/json`
6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.

---

## 8. Audit Logging

### 8.1 Logging Channel

A dedicated `audit` channel must be added to `config/logging.php`.

| Setting | Value |
|---------|-------|
| Driver | `daily` (file-based, daily rotation) |
| Path | `storage/logs/audit.log` |
| Level | `info` |
| Retention | 90 days |

---

### 8.2 Log Entry Format

All audit log entries are structured JSON objects with the following schema:

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `timestamp` | string (ISO-8601) | Y | When the event occurred |
| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |
| `user_id` | integer or null | N | ID of the admin user who triggered the event |
| `store_id` | integer or null | N | ID of the store context |
| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |
| `resource_id` | integer or null | N | ID of the affected resource |
| `ip` | string | Y | Client IP address |
| `user_agent` | string | Y | Client user agent string |
| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |
| (additional keys) | varies | N | Event-specific extra data |

---

### 8.3 Events to Log

#### Authentication Events

| Event ID | Trigger | Extra Data |
|----------|---------|------------|
| `auth.login` | Successful admin login | user_id, ip, user_agent |
| `auth.logout` | Admin logout | user_id |
| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |
| `customer.login` | Successful customer login | customer_id, store_id, ip |
| `customer.registered` | New customer registration | customer_id, store_id |
| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |

#### Admin Resource Events

For each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:

**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff

| Event Pattern | Trigger | Extra Data |
|---------------|---------|------------|
| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |
| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |
| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |
| `{resource}.restored` | Resource restored from soft-delete | resource_id |

#### Security Events

| Event ID | Trigger | Extra Data |
|----------|---------|------------|
| `api_token.created` | New API token issued | token_name, abilities list, user_id |
| `api_token.revoked` | API token revoked | token_name, user_id |
| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |
| `store.settings_changed` | Store settings updated | changed fields with old/new values |

---

### 8.4 Audit Logger Service

File: `app/Services/AuditLogger.php`

A service class that encapsulates audit log writing.

**Method signature:**

| Parameter | Type | Required | Description |
|-----------|------|:--------:|-------------|
| `event` | string | Y | Event identifier |
| `userId` | int or null | N | Acting user ID |
| `storeId` | int or null | N | Store context ID |
| `resourceType` | string or null | N | Affected resource type |
| `resourceId` | int or null | N | Affected resource ID |
| `extra` | associative array | N | Additional key-value pairs to include in the log entry |

**Behavior:**

1. Write to the `audit` logging channel at the `info` level.
2. Automatically include `timestamp` (ISO-8601), `ip` (from request), and `user_agent` (from request) in every entry.
3. Merge any `extra` data into the log context.

---

### 8.5 Integration with Laravel Events

The audit logger hooks into Laravel's built-in authentication events and custom model observers.

#### Auth Event Listeners

Register listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:

| Laravel Event | Audit Event ID | Data Captured |
|--------------|----------------|---------------|
| `Illuminate\Auth\Events\Login` | `auth.login` | user_id from event |
| `Illuminate\Auth\Events\Failed` | `auth.failed_login` | email from credentials (never password) |
| `Illuminate\Auth\Events\Logout` | `auth.logout` | user_id from event |

#### Model Observers

For each auditable resource model, create an observer (or use a shared trait) that logs `created`, `updated`, and `deleted` events. The `updated` observer should capture the changed fields (dirty attributes) with their original and new values.

---

## 9. Security Checklist Summary

| # | Control | Implementation Approach | Priority |
|---|---------|------------------------|----------|
| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |
| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |
| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |
| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |
| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |
| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |
| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |
| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |
| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |
| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |
| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |
| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |
| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |
| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |
| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |
| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |