type: CommandExecution

command: sed -n '846,950p' specs/02-API-ROUTES.md; sed -n '1020,1165p' specs/02-API-ROUTES.md; sed -n '1230,1395p' specs/02-API-ROUTES.md; sed -n '2260,2785p' specs/02-API-ROUTES.md

type: read

stdout:
### 2.3 Payment Endpoint

#### POST /api/storefront/v1/checkouts/{checkoutId}/pay

Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body (Credit Card):**

```json
{
  "payment_method": "credit_card",
  "card_number": "4242424242424242",
  "card_expiry": "12/28",
  "card_cvc": "123",
  "card_holder": "Jane Doe"
}
```

**Request body (PayPal):**

```json
{
  "payment_method": "paypal"
}
```

**Request body (Bank Transfer):**

```json
{
  "payment_method": "bank_transfer"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |
| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |
| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |
| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |

**Success response (Credit Card / PayPal - instant capture):** `200 OK`

```json
{
  "checkout_id": 7,
  "status": "completed",
  "order": {
    "id": 42,
    "order_number": "#1016",
    "status": "paid",
    "financial_status": "paid",
    "payment_method": "credit_card",
    "total_amount": 6545,
    "currency": "EUR"
  }
}
```

**Success response (Bank Transfer - deferred payment):** `200 OK`

```json
{
  "checkout_id": 7,
  "status": "completed",
  "order": {
    "id": 42,
    "order_number": "#1016",
    "status": "pending",
    "financial_status": "pending",
    "payment_method": "bank_transfer",
    "total_amount": 6545,
    "currency": "EUR"
  },
  "bank_transfer_instructions": {
    "bank_name": "Mock Bank AG",
    "iban": "DE89 3704 0044 0532 0130 00",
    "bic": "COBADEFFXXX",
    "reference": "#1016",
    "amount_formatted": "65.45 EUR"
  }
}
```

**Error responses:**
- `404` - Checkout not found
- `409` - Checkout not in a valid state for payment (must be `payment_selected`)
- `422` - Validation errors (missing card fields, invalid payment method)
- `422` - Payment declined (credit card magic numbers)

```json
{
  "message": "Your card was declined.",
  "error_code": "card_declined"
}
```

**Magic card number error codes:**

| Card Number | Error Code | Message |
|---|---|---|

Search products within the current store.

- **Authentication:** None
- **Rate limit:** `search` (30/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |
| `filters` | json | No | URL-encoded JSON object with filter facets. |
| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |

**Filters JSON schema:**

```json
{
  "collection_id": 5,
  "price_min": 1000,
  "price_max": 5000,
  "in_stock": true,
  "tags": ["organic", "cotton"],
  "vendor": "Acme Apparel"
}
```

**Success response:** `200 OK`

```json
{
  "query": "t-shirt",
  "results": [
    {
      "id": 10,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "image_url": "https://cdn.example.com/products/tshirt.jpg",
      "in_stock": true,
      "tags": ["organic", "cotton"]
    }
  ],
  "facets": {
    "vendors": [
      { "value": "Acme Apparel", "count": 12 }
    ],
    "tags": [
      { "value": "organic", "count": 8 },
      { "value": "cotton", "count": 15 }
    ],
    "price_range": {
      "min": 1500,
      "max": 8500
    }
  },
  "pagination": {
    "current_page": 1,
    "per_page": 24,
    "total": 42,
    "last_page": 2
  }
}
```

**Error responses:**
- `400` - Query too long (max 200 characters) or malformed filters
- `422` - Validation error

---

#### GET /api/storefront/v1/search/suggest

Autocomplete suggestions as the user types.

- **Authentication:** None
- **Rate limit:** `search` (30/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |
| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |

**Success response:** `200 OK`

```json
{
  "query": "t-sh",
  "suggestions": [
    {
      "type": "product",
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "image_url": "https://cdn.example.com/products/tshirt-thumb.jpg",
      "price_amount": 2500,
      "currency": "EUR"
    },
    {
      "type": "collection",
      "title": "T-Shirts",
      "handle": "t-shirts",
      "image_url": null
    }
  ]
}
```

**Error responses:**
- `400` - Query too long
- `422` - Validation error

---

### 2.6 Analytics Endpoints

#### POST /api/storefront/v1/analytics/events

Batch submit storefront analytics events.

- **Authentication:** None
- **Rate limit:** `analytics` (60/min per IP)

**Request body:**

```json
{
  "events": [
    {
      "type": "page_view",
      "session_id": "sess_abc123",
      "client_event_id": "evt_001",
      "properties": {
        "url": "/products/classic-t-shirt",
        "referrer": "https://google.com",
        "user_agent": "Mozilla/5.0..."
      },
      "occurred_at": "2026-02-11T10:00:00Z"
    },

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate handle, invalid timezone, etc.)

---

#### POST /api/admin/v1/stores/{storeId}/invites

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

#### GET /api/admin/v1/stores/{storeId}/me

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary
Delete a discount.

**Authentication:** Bearer token with `write-discounts` ability.

**Success response:** `200 OK`

```json
{
  "message": "Discount deleted"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Discount not found

---

### 3.6 Shipping

#### Admin Shipping Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/shipping/zones

List shipping zones with their rates.

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "name": "Germany",
      "countries_json": ["DE"],
      "regions_json": [],
      "rates": [
        {
          "id": 1,
          "name": "Standard Shipping",
          "type": "flat",
          "config_json": {
            "price_amount": 500,
            "currency": "EUR"
          },
          "is_active": true
        },
        {
          "id": 3,
          "name": "Weight-Based Shipping",
          "type": "weight",
          "config_json": {
            "currency": "EUR",
            "tiers": [
              { "min_weight_g": 0, "max_weight_g": 1000, "price_amount": 500 },
              { "min_weight_g": 1001, "max_weight_g": 5000, "price_amount": 800 },
              { "min_weight_g": 5001, "max_weight_g": null, "price_amount": 1200 }
            ]
          },
          "is_active": true
        }
      ]
    }
  ]
}
```

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones

Create a shipping zone.

**Request body:**

```json
{
  "name": "Europe (EU)",
  "countries_json": ["DE", "FR", "NL", "BE", "AT", "IT", "ES"],
  "regions_json": []
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |
| `regions_json` | array of strings | No | Region/province codes for more granular zones. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (overlapping countries with existing zones)

---

#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}

Update a shipping zone.

Same fields as POST.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates

Add a shipping rate to a zone.

**Request body:**

```json
{
  "name": "Express Shipping",
  "type": "flat",
  "config_json": {
    "price_amount": 1200,
    "currency": "EUR"
  },
  "is_active": true
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |
| `config_json` | object | Yes | Schema depends on type. |
| `is_active` | boolean | No | Default: true. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

### 3.7 Tax Settings

#### Admin Tax Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/tax/settings

Get tax configuration for a store.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "mode": "manual",
    "provider": "none",
    "prices_include_tax": true,
    "config_json": {
      "default_tax_rate": 1900,
      "tax_rates": [
        {
          "country_code": "DE",
          "rate": 1900,
          "name": "MwSt",
          "shipping_taxed": true
        },
        {
          "country_code": "AT",
          "rate": 2000,
          "name": "USt",
          "shipping_taxed": true
        }
      ]
    },
    "updated_at": "2026-02-10T14:00:00Z"
  }
}
```

---

#### PUT /api/admin/v1/stores/{storeId}/tax/settings

Update tax configuration.

**Request body:**

```json
{
  "mode": "provider",
  "provider": "stripe_tax",
  "prices_include_tax": false,
  "config_json": {
    "stripe_tax_settings_id": "txr_abc123"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `mode` | string | Yes | One of: `manual`, `provider`. |
| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |
| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |
| `config_json` | object | Yes | Schema depends on mode/provider. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

### 3.8 Themes

#### Admin Themes Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/themes

Upload or install a new theme.

**Request body (multipart/form-data):**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |
| `name` | string | No | Max: 255. Defaults to theme manifest name. |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "store_id": 1,
    "name": "Dawn",
    "version": "1.0.0",
    "status": "draft",
    "published_at": null,
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid theme archive (missing required templates, invalid manifest)

---

#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish

Publish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "published",
    "published_at": "2026-02-11T10:05:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Theme has validation errors preventing publish

---

#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings

Update theme settings (customization values).

**Request body:**

```json
{
  "settings_json": {
    "colors": {
      "primary": "#1a73e8",
      "secondary": "#fbbc04",
      "background": "#ffffff",
      "text": "#202124"
    },
    "typography": {
      "heading_font": "Inter",
      "body_font": "Inter"
    },
    "header": {
      "show_announcement_bar": true,
      "announcement_text": "Free shipping on orders over 50 EUR!"
    }
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `settings_json` | object | Yes | Must conform to the theme's settings schema. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Settings do not conform to theme schema

---

### 3.9 Pages

#### Admin Pages Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/pages

List content pages.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter: `draft`, `published`. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "title": "About Us",
      "handle": "about-us",
      "status": "published",
      "published_at": "2026-02-01T10:00:00Z",
      "created_at": "2026-01-15T10:00:00Z",
      "updated_at": "2026-02-01T10:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 3,
    "last_page": 1
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/pages

Create a content page.

**Request body:**

```json
{
  "title": "Shipping Policy",
  "handle": "shipping-policy",
  "body_html": "
Shipping Policy

We ship worldwide...
",
  "status": "published"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |
| `body_html` | string | No | Max: 65535 |
| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}

Update a content page.

Same fields as POST. Supports partial updates.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}

Delete a content page.

**Authentication:** Bearer token with `write-content` ability.

**Success response:** `200 OK`

```json
{
  "message": "Page deleted"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found

---

### 3.10 Search Admin

#### Admin Search Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/search/reindex

Trigger a full search reindex for the store. Runs asynchronously via a queued job.

**Success response:** `202 Accepted`

```json
{
  "message": "Reindex job queued.",
  "job_id": "job_reindex_abc123",
  "status": "queued"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `409` - Reindex already in progress
- `429` - Rate limited

---

#### GET /api/admin/v1/stores/{storeId}/search/status

Get the current search index status.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "index_status": "ready",
    "last_reindex_at": "2026-02-11T08:00:00Z",
    "last_reindex_duration_seconds": 45,
    "documents_count": 342,
    "pending_updates": 0
  }
}
```

---

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |


text:
Script completed
Wall time 0.1 seconds
Output:

text:
### 2.3 Payment Endpoint

#### POST /api/storefront/v1/checkouts/{checkoutId}/pay

Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body (Credit Card):**

```json
{
  "payment_method": "credit_card",
  "card_number": "4242424242424242",
  "card_expiry": "12/28",
  "card_cvc": "123",
  "card_holder": "Jane Doe"
}
```

**Request body (PayPal):**

```json
{
  "payment_method": "paypal"
}
```

**Request body (Bank Transfer):**

```json
{
  "payment_method": "bank_transfer"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |
| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |
| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |
| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |

**Success response (Credit Card / PayPal - instant capture):** `200 OK`

```json
{
  "checkout_id": 7,
  "status": "completed",
  "order": {
    "id": 42,
    "order_number": "#1016",
    "status": "paid",
    "financial_status": "paid",
    "payment_method": "credit_card",
    "total_amount": 6545,
    "currency": "EUR"
  }
}
```

**Success response (Bank Transfer - deferred payment):** `200 OK`

```json
{
  "checkout_id": 7,
  "status": "completed",
  "order": {
    "id": 42,
    "order_number": "#1016",
    "status": "pending",
    "financial_status": "pending",
    "payment_method": "bank_transfer",
    "total_amount": 6545,
    "currency": "EUR"
  },
  "bank_transfer_instructions": {
    "bank_name": "Mock Bank AG",
    "iban": "DE89 3704 0044 0532 0130 00",
    "bic": "COBADEFFXXX",
    "reference": "#1016",
    "amount_formatted": "65.45 EUR"
  }
}
```

**Error responses:**
- `404` - Checkout not found
- `409` - Checkout not in a valid state for payment (must be `payment_selected`)
- `422` - Validation errors (missing card fields, invalid payment method)
- `422` - Payment declined (credit card magic numbers)

```json
{
  "message": "Your card was declined.",
  "error_code": "card_declined"
}
```

**Magic card number error codes:**

| Card Number | Error Code | Message |
|---|---|---|

Search products within the current store.

- **Authentication:** None
- **Rate limit:** `search` (30/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |
| `filters` | json | No | URL-encoded JSON object with filter facets. |
| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |

**Filters JSON schema:**

```json
{
  "collection_id": 5,
  "price_min": 1000,
  "price_max": 5000,
  "in_stock": true,
  "tags": ["organic", "cotton"],
  "vendor": "Acme Apparel"
}
```

**Success response:** `200 OK`

```json
{
  "query": "t-shirt",
  "results": [
    {
      "id": 10,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "image_url": "https://cdn.example.com/products/tshirt.jpg",
      "in_stock": true,
      "tags": ["organic", "cotton"]
    }
  ],
  "facets": {
    "vendors": [
      { "value": "Acme Apparel", "count": 12 }
    ],
    "tags": [
      { "value": "organic", "count": 8 },
      { "value": "cotton", "count": 15 }
    ],
    "price_range": {
      "min": 1500,
      "max": 8500
    }
  },
  "pagination": {
    "current_page": 1,
    "per_page": 24,
    "total": 42,
    "last_page": 2
  }
}
```

**Error responses:**
- `400` - Query too long (max 200 characters) or malformed filters
- `422` - Validation error

---

#### GET /api/storefront/v1/search/suggest

Autocomplete suggestions as the user types.

- **Authentication:** None
- **Rate limit:** `search` (30/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |
| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |

**Success response:** `200 OK`

```json
{
  "query": "t-sh",
  "suggestions": [
    {
      "type": "product",
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "image_url": "https://cdn.example.com/products/tshirt-thumb.jpg",
      "price_amount": 2500,
      "currency": "EUR"
    },
    {
      "type": "collection",
      "title": "T-Shirts",
      "handle": "t-shirts",
      "image_url": null
    }
  ]
}
```

**Error responses:**
- `400` - Query too long
- `422` - Validation error

---

### 2.6 Analytics Endpoints

#### POST /api/storefront/v1/analytics/events

Batch submit storefront analytics events.

- **Authentication:** None
- **Rate limit:** `analytics` (60/min per IP)

**Request body:**

```json
{
  "events": [
    {
      "type": "page_view",
      "session_id": "sess_abc123",
      "client_event_id": "evt_001",
      "properties": {
        "url": "/products/classic-t-shirt",
        "referrer": "https://google.com",
        "user_agent": "Mozilla/5.0..."
      },
      "occurred_at": "2026-02-11T10:00:00Z"
    },

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate handle, invalid timezone, etc.)

---

#### POST /api/admin/v1/stores/{storeId}/invites

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

#### GET /api/admin/v1/stores/{storeId}/me

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary
Delete a discount.

**Authentication:** Bearer token with `write-discounts` ability.

**Success response:** `200 OK`

```json
{
  "message": "Discount deleted"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Discount not found

---

### 3.6 Shipping

#### Admin Shipping Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/shipping/zones

List shipping zones with their rates.

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "name": "Germany",
      "countries_json": ["DE"],
      "regions_json": [],
      "rates": [
        {
          "id": 1,
          "name": "Standard Shipping",
          "type": "flat",
          "config_json": {
            "price_amount": 500,
            "currency": "EUR"
          },
          "is_active": true
        },
        {
          "id": 3,
          "name": "Weight-Based Shipping",
          "type": "weight",
          "config_json": {
            "currency": "EUR",
            "tiers": [
              { "min_weight_g": 0, "max_weight_g": 1000, "price_amount": 500 },
              { "min_weight_g": 1001, "max_weight_g": 5000, "price_amount": 800 },
              { "min_weight_g": 5001, "max_weight_g": null, "price_amount": 1200 }
            ]
          },
          "is_active": true
        }
      ]
    }
  ]
}
```

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones

Create a shipping zone.

**Request body:**

```json
{
  "name": "Europe (EU)",
  "countries_json": ["DE", "FR", "NL", "BE", "AT", "IT", "ES"],
  "regions_json": []
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |
| `regions_json` | array of strings | No | Region/province codes for more granular zones. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (overlapping countries with existing zones)

---

#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}

Update a shipping zone.

Same fields as POST.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates

Add a shipping rate to a zone.

**Request body:**

```json
{
  "name": "Express Shipping",
  "type": "flat",
  "config_json": {
    "price_amount": 1200,
    "currency": "EUR"
  },
  "is_active": true
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |
| `config_json` | object | Yes | Schema depends on type. |
| `is_active` | boolean | No | Default: true. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

### 3.7 Tax Settings

#### Admin Tax Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/tax/settings

Get tax configuration for a store.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "mode": "manual",
    "provider": "none",
    "prices_include_tax": true,
    "config_json": {
      "default_tax_rate": 1900,
      "tax_rates": [
        {
          "country_code": "DE",
          "rate": 1900,
          "name": "MwSt",
          "shipping_taxed": true
        },
        {
          "country_code": "AT",
          "rate": 2000,
          "name": "USt",
          "shipping_taxed": true
        }
      ]
    },
    "updated_at": "2026-02-10T14:00:00Z"
  }
}
```

---

#### PUT /api/admin/v1/stores/{storeId}/tax/settings

Update tax configuration.

**Request body:**

```json
{
  "mode": "provider",
  "provider": "stripe_tax",
  "prices_include_tax": false,
  "config_json": {
    "stripe_tax_settings_id": "txr_abc123"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `mode` | string | Yes | One of: `manual`, `provider`. |
| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |
| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |
| `config_json` | object | Yes | Schema depends on mode/provider. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

### 3.8 Themes

#### Admin Themes Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/themes

Upload or install a new theme.

**Request body (multipart/form-data):**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |
| `name` | string | No | Max: 255. Defaults to theme manifest name. |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "store_id": 1,
    "name": "Dawn",
    "version": "1.0.0",
    "status": "draft",
    "published_at": null,
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid theme archive (missing required templates, invalid manifest)

---

#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish

Publish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "published",
    "published_at": "2026-02-11T10:05:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Theme has validation errors preventing publish

---

#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings

Update theme settings (customization values).

**Request body:**

```json
{
  "settings_json": {
    "colors": {
      "primary": "#1a73e8",
      "secondary": "#fbbc04",
      "background": "#ffffff",
      "text": "#202124"
    },
    "typography": {
      "heading_font": "Inter",
      "body_font": "Inter"
    },
    "header": {
      "show_announcement_bar": true,
      "announcement_text": "Free shipping on orders over 50 EUR!"
    }
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `settings_json` | object | Yes | Must conform to the theme's settings schema. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Settings do not conform to theme schema

---

### 3.9 Pages

#### Admin Pages Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/pages

List content pages.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter: `draft`, `published`. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "title": "About Us",
      "handle": "about-us",
      "status": "published",
      "published_at": "2026-02-01T10:00:00Z",
      "created_at": "2026-01-15T10:00:00Z",
      "updated_at": "2026-02-01T10:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 3,
    "last_page": 1
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/pages

Create a content page.

**Request body:**

```json
{
  "title": "Shipping Policy",
  "handle": "shipping-policy",
  "body_html": "
Shipping Policy

We ship worldwide...
",
  "status": "published"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |
| `body_html` | string | No | Max: 65535 |
| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}

Update a content page.

Same fields as POST. Supports partial updates.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}

Delete a content page.

**Authentication:** Bearer token with `write-content` ability.

**Success response:** `200 OK`

```json
{
  "message": "Page deleted"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found

---

### 3.10 Search Admin

#### Admin Search Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/search/reindex

Trigger a full search reindex for the store. Runs asynchronously via a queued job.

**Success response:** `202 Accepted`

```json
{
  "message": "Reindex job queued.",
  "job_id": "job_reindex_abc123",
  "status": "queued"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `409` - Reindex already in progress
- `429` - Rate limited

---

#### GET /api/admin/v1/stores/{storeId}/search/status

Get the current search index status.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "index_status": "ready",
    "last_reindex_at": "2026-02-11T08:00:00Z",
    "last_reindex_duration_seconds": 45,
    "documents_count": 342,
    "pending_updates": 0
  }
}
```

---

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |


exec

status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,140p' app/Models/User.php; rg -n \"platform|super|is_\" database/migrations/2026_08_20_000000_create_shop_schema.php app/Models/User.php app/Http/Controllers/Api/PlatformController.php app/Livewire/Admin/Developers/Index.php; sed -n '1,130p' app/Livewire/Admin/Developers/Index.php; sed -n '1,200p' app/Http/Controllers/Api/PlatformController.php; sed -n '1,120p' app/Actions/Fortify/CreateNewUser.php; sed -n '1,220p' config/fortify.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":26000});
text(r.output);


command: sed -n '1,140p' app/Models/User.php; rg -n "platform|super|is_" database/migrations/2026_08_20_000000_create_shop_schema.php app/Models/User.php app/Http/Controllers/Api/PlatformController.php app/Livewire/Admin/Developers/Index.php; sed -n '1,130p' app/Livewire/Admin/Developers/Index.php; sed -n '1,200p' app/Http/Controllers/Api/PlatformController.php; sed -n '1,120p' app/Actions/Fortify/CreateNewUser.php; sed -n '1,220p' config/fortify.php

User.php

cmd: sed -n '1,140p' app/Models/User.php

type: search

cmd: rg -n 'platform|super|is_' database/migrations/2026_08_20_000000_create_shop_schema.php app/Models/User.php app/Http/Controllers/Api/PlatformController.php app/Livewire/Admin/Developers/Index.php

fortify.php

cmd: sed -n '1,220p' config/fortify.php

status: completed

stdout:
<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'password_hash',
        'status',
        'last_login_at',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'password_hash',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'last_login_at' => 'datetime',
        ];
    }

    protected static function booted(): void
    {
        static::saving(function (User $user): void {
            if ($user->isDirty('password')) {
                $user->password_hash = $user->password;
            } elseif ($user->isDirty('password_hash')) {
                $user->password = $user->password_hash;
            }
        });
    }

    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        $pivot = $this->stores()->where('stores.id', $store->getKey())->first()?->pivot;

        return $pivot?->role instanceof StoreUserRole ? $pivot->role : ($pivot?->role ? StoreUserRole::tryFrom($pivot->role) : null);
    }

    public function canManageStore(Store $store): bool
    {
        return in_array($this->roleForStore($store), [StoreUserRole::Owner, StoreUserRole::Admin], true);
    }

    /**
     * Get the user's initials
     */
    public function initials(): string
    {
        return Str::of($this->name)
            ->explode(' ')
            ->take(2)
            ->map(fn ($word) => Str::substr($word, 0, 1))
            ->implode('');
    }
}
app/Livewire/Admin/Developers/Index.php:48:        $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
database/migrations/2026_08_20_000000_create_shop_schema.php:40:            $table->boolean('is_primary')->default(false);
database/migrations/2026_08_20_000000_create_shop_schema.php:42:            $table->index(['store_id', 'is_primary']);
database/migrations/2026_08_20_000000_create_shop_schema.php:117:            $table->boolean('is_default')->default(false);
database/migrations/2026_08_20_000000_create_shop_schema.php:122:            $table->index(['product_id', 'is_default']);
database/migrations/2026_08_20_000000_create_shop_schema.php:279:            $table->boolean('is_active')->default(true);
database/migrations/2026_08_20_000000_create_shop_schema.php:283:            $table->index(['shipping_zone_id', 'is_active']);
database/migrations/2026_08_20_000000_create_shop_schema.php:289:            $table->unsignedInteger('default_rate_basis_points')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php:335:            $table->boolean('is_default')->default(false);
database/migrations/2026_08_20_000000_create_shop_schema.php:337:            $table->index(['customer_id', 'is_default']);
<?php

namespace App\Livewire\Admin\Developers;

use App\Models\WebhookSubscription;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Component;

class Index extends Component
{
    public string $event = 'order.created';

    public string $targetUrl = '';

    public string $tokenName = '';

    public string $tokenExpiresAt = '';

    public string $tokenAbilities = 'read-products,read-orders';

    public ?string $plainTextToken = null;

    public function createWebhook(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate(['event' => ['required', 'string', 'max:100'], 'targetUrl' => ['required', 'url', 'max:2000']]);
        WebhookSubscription::query()->create(['event' => $data['event'], 'event_type' => $data['event'], 'target_url' => $data['targetUrl'], 'signing_secret_encrypted' => Str::random(48), 'status' => 'active']);
        $this->reset('targetUrl');
    }

    public function pause(int $subscriptionId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $subscription = WebhookSubscription::query()->findOrFail($subscriptionId);
        $subscription->update(['status' => $subscription->status === 'active' ? 'paused' : 'active']);
    }

    public function createToken(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate([
            'tokenName' => ['required', 'string', 'max:100'],
            'tokenExpiresAt' => ['nullable', 'date', 'after:today'],
            'tokenAbilities' => ['required', 'string', 'max:1000'],
        ]);
        $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
        $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities'])))));
        abort_if($abilities === [], 422, 'Select at least one token ability.');
        $expiresAt = empty($data['tokenExpiresAt']) ? null : CarbonImmutable::parse($data['tokenExpiresAt']);
        $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken;
        $this->reset(['tokenName', 'tokenExpiresAt']);
    }

    public function revokeToken(int $tokenId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        auth()->user()->tokens()->whereKey($tokenId)->delete();
    }

    public function render(): View
    {
        return view('livewire.admin.developers.index', ['subscriptions' => WebhookSubscription::query()->latest()->get(), 'tokens' => auth()->user()->tokens()->latest()->get()])->layout('layouts.admin');
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\CreateOrganizationRequest;
use App\Http\Requests\CreatePlatformStoreRequest;
use App\Http\Requests\PresignMediaUploadRequest;
use App\Http\Requests\StoreInvitationRequest;
use App\Jobs\ProcessMediaUpload;
use App\Models\Organization;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\Store;
use App\Models\StoreInvitation;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;

class PlatformController extends Controller
{
    public function storeOrganization(CreateOrganizationRequest $request): JsonResponse
    {
        $data = $request->validated();
        $slug = Str::slug($data['name']);
        $suffix = 1;
        while (Organization::query()->where('slug', $slug)->exists()) {
            $slug = Str::slug($data['name']).'-'.$suffix++;
        }

        return response()->json(['data' => Organization::create([...$data, 'slug' => $slug, 'status' => 'active'])], 201);
    }

    public function storeStore(CreatePlatformStoreRequest $request): JsonResponse
    {
        $data = $request->validated();
        $store = Store::create([...$data, 'status' => 'active']);
        $store->users()->syncWithoutDetaching([
            request()->user('sanctum')->getKey() => ['role' => 'owner'],
        ]);

        return response()->json(['data' => $store], 201);
    }

    public function invite(StoreInvitationRequest $request, int $storeId): JsonResponse
    {
        $store = $this->store($storeId);
        $data = $request->validated();
        $user = User::query()->where('email', $data['email'])->first();

        if ($user !== null && $store->users()->whereKey($user->getKey())->exists()) {
            return response()->json(['message' => 'The user is already a member of this store.'], 409);
        }

        $invitation = StoreInvitation::query()->updateOrCreate(
            ['store_id' => $store->getKey(), 'email' => $data['email']],
            ['role' => $data['role'], 'invited_at' => now(), 'expires_at' => now()->addDays(7), 'accepted_at' => null],
        );

        return response()->json(['data' => ['email' => $invitation->email, 'role' => $invitation->role, 'invited_at' => $invitation->invited_at, 'expires_at' => $invitation->expires_at]], 201);
    }

    public function me(int $storeId): JsonResponse
    {
        $store = $this->store($storeId);
        $user = request()->user('sanctum');
        $membership = $user?->stores()->whereKey($store->getKey())->first();
        abort_unless($membership !== null, 403);

        return response()->json(['data' => [
            'user_id' => $user->getKey(),
            'store_id' => $store->getKey(),
            'role' => $membership->pivot->role->value ?? $membership->pivot->role,
            'email' => $user->email,
            'name' => $user->name,
            'permissions' => $this->permissionsFor($membership->pivot->role->value ?? (string) $membership->pivot->role),
        ]]);
    }

    public function presignMediaUpload(PresignMediaUploadRequest $request, int $storeId, int $productId): JsonResponse
    {
        $store = $this->store($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->findOrFail($productId);
        $data = $request->validated();
        $extension = strtolower(pathinfo($data['filename'], PATHINFO_EXTENSION));
        $storageKey = 'stores/'.$store->getKey().'/products/'.$product->getKey().'/media/'.Str::uuid().'.'.$extension;
        $disk = Storage::disk('public');
        $media = ProductMedia::query()->create([
            'product_id' => $product->getKey(),
            'type' => str_starts_with($data['content_type'], 'video/') ? 'video' : 'image',
            'path' => $storageKey,
            'storage_key' => $storageKey,
            'url' => $disk->url($storageKey),
            'mime_type' => $data['content_type'],
            'byte_size' => $data['byte_size'],
            'status' => 'processing',
            'position' => (int) $product->media()->max('position') + 1,
        ]);

        $uploadUrl = $disk->url($storageKey);
        if (method_exists($disk, 'temporaryUploadUrl')) {
            try {
                $uploadUrl = $disk->temporaryUploadUrl($storageKey, now()->addMinutes(10), ['ContentType' => $data['content_type']]);
            } catch (Throwable) {
                // Local development disks do not support presigned uploads.
            }
        }

        if (config('queue.default') !== 'sync') {
            ProcessMediaUpload::dispatch($media)->delay(now()->addMinutes(10));
        }

        return response()->json(['upload_url' => $uploadUrl, 'method' => 'PUT', 'headers' => ['Content-Type' => $data['content_type']], 'storage_key' => $storageKey, 'media_id' => $media->getKey(), 'expires_at' => now()->addMinutes(10)], 201);
    }

    public function completeMediaUpload(int $storeId, int $productId, int $mediaId): JsonResponse
    {
        $store = $this->store($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->findOrFail($productId);
        $media = $product->media()->whereKey($mediaId)->firstOrFail();
        $disk = Storage::disk('public');
        $storageKey = $media->storage_key ?: $media->path;

        abort_unless($storageKey !== null && $disk->exists($storageKey), 409, 'The media upload has not completed.');
        ProcessMediaUpload::dispatch($media);

        return response()->json(['data' => $media->refresh()], 202);
    }

    private function store(int $storeId): Store
    {
        return Store::query()->findOrFail($storeId);
    }

    /** @return array<int, string> */
    private function permissionsFor(string $role): array
    {
        return match ($role) {
            'owner', 'admin' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'write-settings', 'read-customers', 'write-customers', 'read-analytics', 'read-content', 'write-content'],
            'staff' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'read-customers'],
            default => ['read-products', 'read-orders', 'read-customers'],
        };
    }
}
<?php

namespace App\Actions\Fortify;

use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;

class CreateNewUser implements CreatesNewUsers
{
    use PasswordValidationRules, ProfileValidationRules;

    /**
     * Validate and create a newly registered user.
     *
     * @param  array<string, string>  $input
     */
    public function create(array $input): User
    {
        Validator::make($input, [
            ...$this->profileRules(),
            'password' => $this->passwordRules(),
        ])->validate();

        return User::create([
            'name' => $input['name'],
            'email' => $input['email'],
            'password' => $input['password'],
        ]);
    }
}
<?php

use Laravel\Fortify\Features;

return [

    /*
    |--------------------------------------------------------------------------
    | Fortify Guard
    |--------------------------------------------------------------------------
    |
    | Here you may specify which authentication guard Fortify will use while
    | authenticating users. This value should correspond with one of your
    | guards that is already present in your "auth" configuration file.
    |
    */

    'guard' => 'web',

    /*
    |--------------------------------------------------------------------------
    | Fortify Password Broker
    |--------------------------------------------------------------------------
    |
    | Here you may specify which password broker Fortify can use when a user
    | is resetting their password. This configured value should match one
    | of your password brokers setup in your "auth" configuration file.
    |
    */

    'passwords' => 'users',

    /*
    |--------------------------------------------------------------------------
    | Username / Email
    |--------------------------------------------------------------------------
    |
    | This value defines which model attribute should be considered as your
    | application's "username" field. Typically, this might be the email
    | address of the users but you are free to change this value here.
    |
    | Out of the box, Fortify expects forgot password and reset password
    | requests to have a field named 'email'. If the application uses
    | another name for the field you may define it below as needed.
    |
    */

    'username' => 'email',

    'email' => 'email',

    /*
    |--------------------------------------------------------------------------
    | Lowercase Usernames
    |--------------------------------------------------------------------------
    |
    | This value defines whether usernames should be lowercased before saving
    | them in the database, as some database system string fields are case
    | sensitive. You may disable this for your application if necessary.
    |
    */

    'lowercase_usernames' => true,

    /*
    |--------------------------------------------------------------------------
    | Home Path
    |--------------------------------------------------------------------------
    |
    | Here you may configure the path where users will get redirected during
    | authentication or password reset when the operations are successful
    | and the user is authenticated. You are free to change this value.
    |
    */

    'home' => '/dashboard',

    /*
    |--------------------------------------------------------------------------
    | Fortify Routes Prefix / Subdomain
    |--------------------------------------------------------------------------
    |
    | Here you may specify which prefix Fortify will assign to all the routes
    | that it registers with the application. If necessary, you may change
    | subdomain under which all of the Fortify routes will be available.
    |
    */

    'prefix' => '',

    'domain' => null,

    /*
    |--------------------------------------------------------------------------
    | Fortify Routes Middleware
    |--------------------------------------------------------------------------
    |
    | Here you may specify which middleware Fortify will assign to the routes
    | that it registers with the application. If necessary, you may change
    | these middleware but typically this provided default is preferred.
    |
    */

    'middleware' => ['web'],

    /*
    |--------------------------------------------------------------------------
    | Rate Limiting
    |--------------------------------------------------------------------------
    |
    | By default, Fortify will throttle logins to five requests per minute for
    | every email and IP address combination. However, if you would like to
    | specify a custom rate limiter to call then you may specify it here.
    |
    */

    'limiters' => [
        'login' => 'login',
        'two-factor' => 'two-factor',
    ],

    /*
    |--------------------------------------------------------------------------
    | Register View Routes
    |--------------------------------------------------------------------------
    |
    | Here you may specify if the routes returning views should be disabled as
    | you may not need them when building your own application. This may be
    | especially true if you're writing a custom single-page application.
    |
    */

    'views' => true,

    /*
    |--------------------------------------------------------------------------
    | Features
    |--------------------------------------------------------------------------
    |
    | Some of the Fortify features are optional. You may disable the features
    | by removing them from this array. You're free to only remove some of
    | these features, or you can even remove all of these if you need to.
    |
    */

    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        Features::emailVerification(),
        Features::twoFactorAuthentication([
            'confirm' => true,
            'confirmPassword' => true,
            // 'window' => 0
        ]),
    ],

];


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'password_hash',
        'status',
        'last_login_at',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'password_hash',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'last_login_at' => 'datetime',
        ];
    }

    protected static function booted(): void
    {
        static::saving(function (User $user): void {
            if ($user->isDirty('password')) {
                $user->password_hash = $user->password;
            } elseif ($user->isDirty('password_hash')) {
                $user->password = $user->password_hash;
            }
        });
    }

    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        $pivot = $this->stores()->where('stores.id', $store->getKey())->first()?->pivot;

        return $pivot?->role instanceof StoreUserRole ? $pivot->role : ($pivot?->role ? StoreUserRole::tryFrom($pivot->role) : null);
    }

    public function canManageStore(Store $store): bool
    {
        return in_array($this->roleForStore($store), [StoreUserRole::Owner, StoreUserRole::Admin], true);
    }

    /**
     * Get the user's initials
     */
    public function initials(): string
    {
        return Str::of($this->name)
            ->explode(' ')
            ->take(2)
            ->map(fn ($word) => Str::substr($word, 0, 1))
            ->implode('');
    }
}
app/Livewire/Admin/Developers/Index.php:48:        $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
database/migrations/2026_08_20_000000_create_shop_schema.php:40:            $table->boolean('is_primary')->default(false);
database/migrations/2026_08_20_000000_create_shop_schema.php:42:            $table->index(['store_id', 'is_primary']);
database/migrations/2026_08_20_000000_create_shop_schema.php:117:            $table->boolean('is_default')->default(false);
database/migrations/2026_08_20_000000_create_shop_schema.php:122:            $table->index(['product_id', 'is_default']);
database/migrations/2026_08_20_000000_create_shop_schema.php:279:            $table->boolean('is_active')->default(true);
database/migrations/2026_08_20_000000_create_shop_schema.php:283:            $table->index(['shipping_zone_id', 'is_active']);
database/migrations/2026_08_20_000000_create_shop_schema.php:289:            $table->unsignedInteger('default_rate_basis_points')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php:335:            $table->boolean('is_default')->default(false);
database/migrations/2026_08_20_000000_create_shop_schema.php:337:            $table->index(['customer_id', 'is_default']);
<?php

namespace App\Livewire\Admin\Developers;

use App\Models\WebhookSubscription;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Component;

class Index extends Component
{
    public string $event = 'order.created';

    public string $targetUrl = '';

    public string $tokenName = '';

    public string $tokenExpiresAt = '';

    public string $tokenAbilities = 'read-products,read-orders';

    public ?string $plainTextToken = null;

    public function createWebhook(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate(['event' => ['required', 'string', 'max:100'], 'targetUrl' => ['required', 'url', 'max:2000']]);
        WebhookSubscription::query()->create(['event' => $data['event'], 'event_type' => $data['event'], 'target_url' => $data['targetUrl'], 'signing_secret_encrypted' => Str::random(48), 'status' => 'active']);
        $this->reset('targetUrl');
    }

    public function pause(int $subscriptionId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $subscription = WebhookSubscription::query()->findOrFail($subscriptionId);
        $subscription->update(['status' => $subscription->status === 'active' ? 'paused' : 'active']);
    }

    public function createToken(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate([
            'tokenName' => ['required', 'string', 'max:100'],
            'tokenExpiresAt' => ['nullable', 'date', 'after:today'],
            'tokenAbilities' => ['required', 'string', 'max:1000'],
        ]);
        $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
        $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities'])))));
        abort_if($abilities === [], 422, 'Select at least one token ability.');
        $expiresAt = empty($data['tokenExpiresAt']) ? null : CarbonImmutable::parse($data['tokenExpiresAt']);
        $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken;
        $this->reset(['tokenName', 'tokenExpiresAt']);
    }

    public function revokeToken(int $tokenId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        auth()->user()->tokens()->whereKey($tokenId)->delete();
    }

    public function render(): View
    {
        return view('livewire.admin.developers.index', ['subscriptions' => WebhookSubscription::query()->latest()->get(), 'tokens' => auth()->user()->tokens()->latest()->get()])->layout('layouts.admin');
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\CreateOrganizationRequest;
use App\Http\Requests\CreatePlatformStoreRequest;
use App\Http\Requests\PresignMediaUploadRequest;
use App\Http\Requests\StoreInvitationRequest;
use App\Jobs\ProcessMediaUpload;
use App\Models\Organization;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\Store;
use App\Models\StoreInvitation;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;

class PlatformController extends Controller
{
    public function storeOrganization(CreateOrganizationRequest $request): JsonResponse
    {
        $data = $request->validated();
        $slug = Str::slug($data['name']);
        $suffix = 1;
        while (Organization::query()->where('slug', $slug)->exists()) {
            $slug = Str::slug($data['name']).'-'.$suffix++;
        }

        return response()->json(['data' => Organization::create([...$data, 'slug' => $slug, 'status' => 'active'])], 201);
    }

    public function storeStore(CreatePlatformStoreRequest $request): JsonResponse
    {
        $data = $request->validated();
        $store = Store::create([...$data, 'status' => 'active']);
        $store->users()->syncWithoutDetaching([
            request()->user('sanctum')->getKey() => ['role' => 'owner'],
        ]);

        return response()->json(['data' => $store], 201);
    }

    public function invite(StoreInvitationRequest $request, int $storeId): JsonResponse
    {
        $store = $this->store($storeId);
        $data = $request->validated();
        $user = User::query()->where('email', $data['email'])->first();

        if ($user !== null && $store->users()->whereKey($user->getKey())->exists()) {
            return response()->json(['message' => 'The user is already a member of this store.'], 409);
        }

        $invitation = StoreInvitation::query()->updateOrCreate(
            ['store_id' => $store->getKey(), 'email' => $data['email']],
            ['role' => $data['role'], 'invited_at' => now(), 'expires_at' => now()->addDays(7), 'accepted_at' => null],
        );

        return response()->json(['data' => ['email' => $invitation->email, 'role' => $invitation->role, 'invited_at' => $invitation->invited_at, 'expires_at' => $invitation->expires_at]], 201);
    }

    public function me(int $storeId): JsonResponse
    {
        $store = $this->store($storeId);
        $user = request()->user('sanctum');
        $membership = $user?->stores()->whereKey($store->getKey())->first();
        abort_unless($membership !== null, 403);

        return response()->json(['data' => [
            'user_id' => $user->getKey(),
            'store_id' => $store->getKey(),
            'role' => $membership->pivot->role->value ?? $membership->pivot->role,
            'email' => $user->email,
            'name' => $user->name,
            'permissions' => $this->permissionsFor($membership->pivot->role->value ?? (string) $membership->pivot->role),
        ]]);
    }

    public function presignMediaUpload(PresignMediaUploadRequest $request, int $storeId, int $productId): JsonResponse
    {
        $store = $this->store($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->findOrFail($productId);
        $data = $request->validated();
        $extension = strtolower(pathinfo($data['filename'], PATHINFO_EXTENSION));
        $storageKey = 'stores/'.$store->getKey().'/products/'.$product->getKey().'/media/'.Str::uuid().'.'.$extension;
        $disk = Storage::disk('public');
        $media = ProductMedia::query()->create([
            'product_id' => $product->getKey(),
            'type' => str_starts_with($data['content_type'], 'video/') ? 'video' : 'image',
            'path' => $storageKey,
            'storage_key' => $storageKey,
            'url' => $disk->url($storageKey),
            'mime_type' => $data['content_type'],
            'byte_size' => $data['byte_size'],
            'status' => 'processing',
            'position' => (int) $product->media()->max('position') + 1,
        ]);

        $uploadUrl = $disk->url($storageKey);
        if (method_exists($disk, 'temporaryUploadUrl')) {
            try {
                $uploadUrl = $disk->temporaryUploadUrl($storageKey, now()->addMinutes(10), ['ContentType' => $data['content_type']]);
            } catch (Throwable) {
                // Local development disks do not support presigned uploads.
            }
        }

        if (config('queue.default') !== 'sync') {
            ProcessMediaUpload::dispatch($media)->delay(now()->addMinutes(10));
        }

        return response()->json(['upload_url' => $uploadUrl, 'method' => 'PUT', 'headers' => ['Content-Type' => $data['content_type']], 'storage_key' => $storageKey, 'media_id' => $media->getKey(), 'expires_at' => now()->addMinutes(10)], 201);
    }

    public function completeMediaUpload(int $storeId, int $productId, int $mediaId): JsonResponse
    {
        $store = $this->store($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->findOrFail($productId);
        $media = $product->media()->whereKey($mediaId)->firstOrFail();
        $disk = Storage::disk('public');
        $storageKey = $media->storage_key ?: $media->path;

        abort_unless($storageKey !== null && $disk->exists($storageKey), 409, 'The media upload has not completed.');
        ProcessMediaUpload::dispatch($media);

        return response()->json(['data' => $media->refresh()], 202);
    }

    private function store(int $storeId): Store
    {
        return Store::query()->findOrFail($storeId);
    }

    /** @return array<int, string> */
    private function permissionsFor(string $role): array
    {
        return match ($role) {
            'owner', 'admin' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'write-settings', 'read-customers', 'write-customers', 'read-analytics', 'read-content', 'write-content'],
            'staff' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'read-customers'],
            default => ['read-products', 'read-orders', 'read-customers'],
        };
    }
}
<?php

namespace App\Actions\Fortify;

use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;

class CreateNewUser implements CreatesNewUsers
{
    use PasswordValidationRules, ProfileValidationRules;

    /**
     * Validate and create a newly registered user.
     *
     * @param  array<string, string>  $input
     */
    public function create(array $input): User
    {
        Validator::make($input, [
            ...$this->profileRules(),
            'password' => $this->passwordRules(),
        ])->validate();

        return User::create([
            'name' => $input['name'],
            'email' => $input['email'],
            'password' => $input['password'],
        ]);
    }
}
<?php

use Laravel\Fortify\Features;

return [

    /*
    |--------------------------------------------------------------------------
    | Fortify Guard
    |--------------------------------------------------------------------------
    |
    | Here you may specify which authentication guard Fortify will use while
    | authenticating users. This value should correspond with one of your
    | guards that is already present in your "auth" configuration file.
    |
    */

    'guard' => 'web',

    /*
    |--------------------------------------------------------------------------
    | Fortify Password Broker
    |--------------------------------------------------------------------------
    |
    | Here you may specify which password broker Fortify can use when a user
    | is resetting their password. This configured value should match one
    | of your password brokers setup in your "auth" configuration file.
    |
    */

    'passwords' => 'users',

    /*
    |--------------------------------------------------------------------------
    | Username / Email
    |--------------------------------------------------------------------------
    |
    | This value defines which model attribute should be considered as your
    | application's "username" field. Typically, this might be the email
    | address of the users but you are free to change this value here.
    |
    | Out of the box, Fortify expects forgot password and reset password
    | requests to have a field named 'email'. If the application uses
    | another name for the field you may define it below as needed.
    |
    */

    'username' => 'email',

    'email' => 'email',

    /*
    |--------------------------------------------------------------------------
    | Lowercase Usernames
    |--------------------------------------------------------------------------
    |
    | This value defines whether usernames should be lowercased before saving
    | them in the database, as some database system string fields are case
    | sensitive. You may disable this for your application if necessary.
    |
    */

    'lowercase_usernames' => true,

    /*
    |--------------------------------------------------------------------------
    | Home Path
    |--------------------------------------------------------------------------
    |
    | Here you may configure the path where users will get redirected during
    | authentication or password reset when the operations are successful
    | and the user is authenticated. You are free to change this value.
    |
    */

    'home' => '/dashboard',

    /*
    |--------------------------------------------------------------------------
    | Fortify Routes Prefix / Subdomain
    |--------------------------------------------------------------------------
    |
    | Here you may specify which prefix Fortify will assign to all the routes
    | that it registers with the application. If necessary, you may change
    | subdomain under which all of the Fortify routes will be available.
    |
    */

    'prefix' => '',

    'domain' => null,

    /*
    |--------------------------------------------------------------------------
    | Fortify Routes Middleware
    |--------------------------------------------------------------------------
    |
    | Here you may specify which middleware Fortify will assign to the routes
    | that it registers with the application. If necessary, you may change
    | these middleware but typically this provided default is preferred.
    |
    */

    'middleware' => ['web'],

    /*
    |--------------------------------------------------------------------------
    | Rate Limiting
    |--------------------------------------------------------------------------
    |
    | By default, Fortify will throttle logins to five requests per minute for
    | every email and IP address combination. However, if you would like to
    | specify a custom rate limiter to call then you may specify it here.
    |
    */

    'limiters' => [
        'login' => 'login',
        'two-factor' => 'two-factor',
    ],

    /*
    |--------------------------------------------------------------------------
    | Register View Routes
    |--------------------------------------------------------------------------
    |
    | Here you may specify if the routes returning views should be disabled as
    | you may not need them when building your own application. This may be
    | especially true if you're writing a custom single-page application.
    |
    */

    'views' => true,

    /*
    |--------------------------------------------------------------------------
    | Features
    |--------------------------------------------------------------------------
    |
    | Some of the Fortify features are optional. You may disable the features
    | by removing them from this array. You're free to only remove some of
    | these features, or you can even remove all of these if you need to.
    |
    */

    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        Features::emailVerification(),
        Features::twoFactorAuthentication([
            'confirm' => true,
            'confirmPassword' => true,
            // 'window' => 0
        ]),
    ],

];


exec

status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,180p' routes/web.php; sed -n '1,130p' bootstrap/app.php; sed -n '1,160p' app/Auth/StoreScopedPasswordBrokerManager.php; sed -n '1,200p' app/Livewire/Storefront/Account/Auth/ForgotPassword.php; sed -n '1,220p' app/Livewire/Storefront/Account/Auth/ResetPassword.php; sed -n '1,160p' app/Livewire/Admin/Auth/Login.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":30000});
text(r.output);