command: rg -n "support|staff|owner|admin|Role|permission|matrix|Product|Order|Refund|Fulfillment" specs/06-AUTH-AND-SECURITY.md specs/03-ADMIN-UI.md -S

type: search

cmd: rg -n 'support|staff|owner|admin|Role|permission|matrix|Product|Order|Refund|Fulfillment' specs/06-AUTH-AND-SECURITY.md specs/03-ADMIN-UI.md -S

query: support|staff|owner|admin|Role|permission|matrix|Product|Order|Refund|Fulfillment

aggregated_output:
specs/06-AUTH-AND-SECURITY.md: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.
specs/06-AUTH-AND-SECURITY.md:19:| Login route | `GET /admin/login` |
specs/06-AUTH-AND-SECURITY.md:44:        LW-->>B: Redirect to /admin (dashboard)
specs/06-AUTH-AND-SECURITY.md:57:- After login: redirect to `/admin` (the dashboard).
specs/06-AUTH-AND-SECURITY.md:75:    B->>App: GET /admin/forgot-password
specs/06-AUTH-AND-SECURITY.md:77:    B->>App: POST /admin/forgot-password (email)
specs/06-AUTH-AND-SECURITY.md:84:    B->>App: GET /admin/reset-password/{token}
specs/06-AUTH-AND-SECURITY.md:86:    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)
specs/06-AUTH-AND-SECURITY.md:91:        App-->>B: Redirect to /admin/login with success flash
specs/06-AUTH-AND-SECURITY.md:107:- Route: `POST /admin/logout` (via Livewire action or standard route).
specs/06-AUTH-AND-SECURITY.md:108:- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.
specs/06-AUTH-AND-SECURITY.md:197:- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
specs/06-AUTH-AND-SECURITY.md:211:| Provider | `users` (admin users only) |
specs/06-AUTH-AND-SECURITY.md:234:- Tokens are created in the admin panel, under the Developers section.
specs/06-AUTH-AND-SECURITY.md:263:| `manage-platform` | Platform-level management (super-admin only) |
specs/06-AUTH-AND-SECURITY.md:301:### 2.1 Role Enum
specs/06-AUTH-AND-SECURITY.md:303:File: `app/Enums/StoreUserRole.php`
specs/06-AUTH-AND-SECURITY.md:309:| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |
specs/06-AUTH-AND-SECURITY.md:310:| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |
specs/06-AUTH-AND-SECURITY.md:311:| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |
specs/06-AUTH-AND-SECURITY.md:312:| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |
specs/06-AUTH-AND-SECURITY.md:331:| Products | List / View | Y | Y | Y | Y |
specs/06-AUTH-AND-SECURITY.md:332:| Products | Create | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:333:| Products | Update | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:334:| Products | Delete / Archive | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:335:| Products | Restore | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:340:| Orders | List / View | Y | Y | Y | Y |
specs/06-AUTH-AND-SECURITY.md:341:| Orders | Update | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:342:| Orders | Cancel | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:343:| Fulfillments | Create | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:344:| Fulfillments | Update / Cancel | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:345:| Refunds | Create | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:369:| Staff | Update Role | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:375:#### Role Group Shorthand
specs/06-AUTH-AND-SECURITY.md:377:The permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:
specs/06-AUTH-AND-SECURITY.md:379:| Group Name | Roles Included | Used For |
specs/06-AUTH-AND-SECURITY.md:381:| OwnerOnly | Owner | Store deletion, ownership transfer |
specs/06-AUTH-AND-SECURITY.md:382:| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |
specs/06-AUTH-AND-SECURITY.md:384:| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |
specs/06-AUTH-AND-SECURITY.md:388:### 2.3 Role Checking Trait
specs/06-AUTH-AND-SECURITY.md:390:File: `app/Traits/ChecksStoreRole.php`
specs/06-AUTH-AND-SECURITY.md: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. |
specs/06-AUTH-AND-SECURITY.md: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. |
specs/06-AUTH-AND-SECURITY.md:400:| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |
specs/06-AUTH-AND-SECURITY.md: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`.
specs/06-AUTH-AND-SECURITY.md:408:All policies use the `ChecksStoreRole` …25005 chars truncated…       | | Conv Rate |       |
specs/03-ADMIN-UI.md:2176:| Rank | Product     | Units Sold | Revenue  | % of Total          |
specs/03-ADMIN-UI.md:2182:| Source          | Sessions | Orders | Conversion Rate             |
specs/03-ADMIN-UI.md:2199:**KPI tiles:** Same as Dashboard (Total Sales, Orders, AOV, Conversion Rate) but with the additional filters applied.
specs/03-ADMIN-UI.md:2203:**Top Products table:** Extended version with 10-20 rows. Columns: Rank, Product, Units Sold, Revenue, Percentage of Total.
specs/03-ADMIN-UI.md:2205:**Top Referrers table:** Columns: Traffic source, Sessions, Orders, Conversion Rate.
specs/03-ADMIN-UI.md:2211:**Route:** `GET /admin/search/settings`
specs/03-ADMIN-UI.md:2215:**Blade view:** `resources/views/livewire/admin/search/settings.blade.php`
specs/03-ADMIN-UI.md:2333:- First item: "Home" linking to admin dashboard
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/03-ADMIN-UI.md:2346:Reusable card wrapper used throughout the admin:
specs/03-ADMIN-UI.md:2378:| Product created | "Product saved" | success |
specs/03-ADMIN-UI.md:2379:| Product updated | "Product saved" | success |
specs/03-ADMIN-UI.md:2380:| Product archived | "Product archived" | success |
specs/03-ADMIN-UI.md:2385:| Order fulfilled | "Fulfillment created" | success |
specs/03-ADMIN-UI.md:2386:| Refund issued | "Refund issued" | success |
specs/03-ADMIN-UI.md:2402:| `App\Livewire\Admin\Layout\Sidebar` | `livewire.admin.layout.sidebar` | Layout (all pages) |
specs/03-ADMIN-UI.md:2403:| `App\Livewire\Admin\Layout\TopBar` | `livewire.admin.layout.top-bar` | Layout (all pages) |
specs/03-ADMIN-UI.md:2404:| `App\Livewire\Admin\Dashboard` | `livewire.admin.dashboard` | `GET /admin` |
specs/03-ADMIN-UI.md:2405:| `App\Livewire\Admin\Products\Index` | `livewire.admin.products.index` | `GET /admin/products` |
specs/03-ADMIN-UI.md:2406:| `App\Livewire\Admin\Products\Form` | `livewire.admin.products.form` | `GET /admin/products/create`, `GET /admin/products/{product}/edit` |
specs/03-ADMIN-UI.md:2407:| `App\Livewire\Admin\Collections\Index` | `livewire.admin.collections.index` | `GET /admin/collections` |
specs/03-ADMIN-UI.md:2408:| `App\Livewire\Admin\Collections\Form` | `livewire.admin.collections.form` | `GET /admin/collections/create`, `GET /admin/collections/{collection}/edit` |
specs/03-ADMIN-UI.md:2409:| `App\Livewire\Admin\Inventory\Index` | `livewire.admin.inventory.index` | `GET /admin/inventory` |
specs/03-ADMIN-UI.md:2410:| `App\Livewire\Admin\Orders\Index` | `livewire.admin.orders.index` | `GET /admin/orders` |
specs/03-ADMIN-UI.md:2411:| `App\Livewire\Admin\Orders\Show` | `livewire.admin.orders.show` | `GET /admin/orders/{order}` |
specs/03-ADMIN-UI.md:2412:| `App\Livewire\Admin\Customers\Index` | `livewire.admin.customers.index` | `GET /admin/customers` |
specs/03-ADMIN-UI.md:2413:| `App\Livewire\Admin\Customers\Show` | `livewire.admin.customers.show` | `GET /admin/customers/{customer}` |
specs/03-ADMIN-UI.md:2414:| `App\Livewire\Admin\Discounts\Index` | `livewire.admin.discounts.index` | `GET /admin/discounts` |
specs/03-ADMIN-UI.md:2415:| `App\Livewire\Admin\Discounts\Form` | `livewire.admin.discounts.form` | `GET /admin/discounts/create`, `GET /admin/discounts/{discount}/edit` |
specs/03-ADMIN-UI.md:2416:| `App\Livewire\Admin\Settings\General` | `livewire.admin.settings.general` | `GET /admin/settings` |
specs/03-ADMIN-UI.md:2417:| `App\Livewire\Admin\Settings\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |
specs/03-ADMIN-UI.md:2418:| `App\Livewire\Admin\Settings\Shipping` | `livewire.admin.settings.shipping` | `GET /admin/settings/shipping` |
specs/03-ADMIN-UI.md:2419:| `App\Livewire\Admin\Settings\Taxes` | `livewire.admin.settings.taxes` | `GET /admin/settings/taxes` |
specs/03-ADMIN-UI.md:2420:| `App\Livewire\Admin\Themes\Index` | `livewire.admin.themes.index` | `GET /admin/themes` |
specs/03-ADMIN-UI.md:2421:| `App\Livewire\Admin\Themes\Editor` | `livewire.admin.themes.editor` | `GET /admin/themes/{theme}/editor` |
specs/03-ADMIN-UI.md:2422:| `App\Livewire\Admin\Pages\Index` | `livewire.admin.pages.index` | `GET /admin/pages` |
specs/03-ADMIN-UI.md:2423:| `App\Livewire\Admin\Pages\Form` | `livewire.admin.pages.form` | `GET /admin/pages/create`, `GET /admin/pages/{page}/edit` |
specs/03-ADMIN-UI.md:2424:| `App\Livewire\Admin\Navigation\Index` | `livewire.admin.navigation.index` | `GET /admin/navigation` |
specs/03-ADMIN-UI.md:2425:| `App\Livewire\Admin\Apps\Index` | `livewire.admin.apps.index` | `GET /admin/apps` |
specs/03-ADMIN-UI.md:2426:| `App\Livewire\Admin\Developers\Index` | `livewire.admin.developers.index` | `GET /admin/developers` |
specs/03-ADMIN-UI.md:2427:| `App\Livewire\Admin\Analytics\Index` | `livewire.admin.analytics.index` | `GET /admin/analytics` |
specs/03-ADMIN-UI.md:2428:| `App\Livewire\Admin\Search\Settings` | `livewire.admin.search.settings` | `GET /admin/search/settings` |


output:
Chunk ID: 8f3b71
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 8752
Output:
specs/06-AUTH-AND-SECURITY.md: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.
specs/06-AUTH-AND-SECURITY.md:19:| Login route | `GET /admin/login` |
specs/06-AUTH-AND-SECURITY.md:44:        LW-->>B: Redirect to /admin (dashboard)
specs/06-AUTH-AND-SECURITY.md:57:- After login: redirect to `/admin` (the dashboard).
specs/06-AUTH-AND-SECURITY.md:75:    B->>App: GET /admin/forgot-password
specs/06-AUTH-AND-SECURITY.md:77:    B->>App: POST /admin/forgot-password (email)
specs/06-AUTH-AND-SECURITY.md:84:    B->>App: GET /admin/reset-password/{token}
specs/06-AUTH-AND-SECURITY.md:86:    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)
specs/06-AUTH-AND-SECURITY.md:91:        App-->>B: Redirect to /admin/login with success flash
specs/06-AUTH-AND-SECURITY.md:107:- Route: `POST /admin/logout` (via Livewire action or standard route).
specs/06-AUTH-AND-SECURITY.md:108:- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.
specs/06-AUTH-AND-SECURITY.md:197:- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
specs/06-AUTH-AND-SECURITY.md:211:| Provider | `users` (admin users only) |
specs/06-AUTH-AND-SECURITY.md:234:- Tokens are created in the admin panel, under the Developers section.
specs/06-AUTH-AND-SECURITY.md:263:| `manage-platform` | Platform-level management (super-admin only) |
specs/06-AUTH-AND-SECURITY.md:301:### 2.1 Role Enum
specs/06-AUTH-AND-SECURITY.md:303:File: `app/Enums/StoreUserRole.php`
specs/06-AUTH-AND-SECURITY.md:309:| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |
specs/06-AUTH-AND-SECURITY.md:310:| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |
specs/06-AUTH-AND-SECURITY.md:311:| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |
specs/06-AUTH-AND-SECURITY.md:312:| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |
specs/06-AUTH-AND-SECURITY.md:331:| Products | List / View | Y | Y | Y | Y |
specs/06-AUTH-AND-SECURITY.md:332:| Products | Create | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:333:| Products | Update | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:334:| Products | Delete / Archive | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:335:| Products | Restore | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:340:| Orders | List / View | Y | Y | Y | Y |
specs/06-AUTH-AND-SECURITY.md:341:| Orders | Update | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:342:| Orders | Cancel | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:343:| Fulfillments | Create | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:344:| Fulfillments | Update / Cancel | Y | Y | Y | N |
specs/06-AUTH-AND-SECURITY.md:345:| Refunds | Create | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:369:| Staff | Update Role | Y | Y | N | N |
specs/06-AUTH-AND-SECURITY.md:375:#### Role Group Shorthand
specs/06-AUTH-AND-SECURITY.md:377:The permission matrix reveals three natural groupings that the authorization layer should use as reusable helpers:
specs/06-AUTH-AND-SECURITY.md:379:| Group Name | Roles Included | Used For |
specs/06-AUTH-AND-SECURITY.md:381:| OwnerOnly | Owner | Store deletion, ownership transfer |
specs/06-AUTH-AND-SECURITY.md:382:| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |
specs/06-AUTH-AND-SECURITY.md:384:| AnyRole | Owner, Admin, Staff, Support | Read-only listing/viewing of products, orders, customers, collections, discounts |
specs/06-AUTH-AND-SECURITY.md:388:### 2.3 Role Checking Trait
specs/06-AUTH-AND-SECURITY.md:390:File: `app/Traits/ChecksStoreRole.php`
specs/06-AUTH-AND-SECURITY.md: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. |
specs/06-AUTH-AND-SECURITY.md: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. |
specs/06-AUTH-AND-SECURITY.md:400:| `isAnyRole` | User, store_id (int) | bool | Returns true if the user has any role at all in the store. |
specs/06-AUTH-AND-SECURITY.md: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`.
specs/06-AUTH-AND-SECURITY.md:408:All policies use the `ChecksStoreRole` trait. Laravel 12 auto-discovers policies by convention (`App\Policies\{Model}Policy`), so no manual registration is needed.
specs/06-AUTH-AND-SECURITY.md:415:#### ProductPolicy
specs/06-AUTH-AND-SECURITY.md:417:File: `app/Policies/ProductPolicy.php`
specs/06-AUTH-AND-SECURITY.md:419:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:421:| `viewAny(User)` | No | AnyRole | current_store |
specs/06-AUTH-AND-SECURITY.md:422:| `view(User, Product)` | Yes | AnyRole | product.store_id |
specs/06-AUTH-AND-SECURITY.md:424:| `update(User, Product)` | Yes | OwnerAdminOrStaff | product.store_id |
specs/06-AUTH-AND-SECURITY.md:425:| `delete(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
specs/06-AUTH-AND-SECURITY.md:426:| `archive(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
specs/06-AUTH-AND-SECURITY.md:427:| `restore(User, Product)` | Yes | OwnerOrAdmin | product.store_id |
specs/06-AUTH-AND-SECURITY.md:429:#### OrderPolicy
specs/06-AUTH-AND-SECURITY.md:431:File: `app/Policies/OrderPolicy.php`
specs/06-AUTH-AND-SECURITY.md:433:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:435:| `viewAny(User)` | No | AnyRole | current_store |
specs/06-AUTH-AND-SECURITY.md:436:| `view(User, Order)` | Yes | AnyRole | order.store_id |
specs/06-AUTH-AND-SECURITY.md:437:| `update(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |
specs/06-AUTH-AND-SECURITY.md:438:| `cancel(User, Order)` | Yes | OwnerOrAdmin | order.store_id |
specs/06-AUTH-AND-SECURITY.md:439:| `createFulfillment(User, Order)` | Yes | OwnerAdminOrStaff | order.store_id |
specs/06-AUTH-AND-SECURITY.md:440:| `createRefund(User, Order)` | Yes | OwnerOrAdmin | order.store_id |
specs/06-AUTH-AND-SECURITY.md:446:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:448:| `viewAny(User)` | No | AnyRole | current_store |
specs/06-AUTH-AND-SECURITY.md:449:| `view(User, Collection)` | Yes | AnyRole | collection.store_id |
specs/06-AUTH-AND-SECURITY.md:458:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:460:| `viewAny(User)` | No | AnyRole | current_store |
specs/06-AUTH-AND-SECURITY.md:461:| `view(User, Discount)` | Yes | AnyRole | discount.store_id |
specs/06-AUTH-AND-SECURITY.md:470:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:472:| `viewAny(User)` | No | AnyRole | current_store |
specs/06-AUTH-AND-SECURITY.md:473:| `view(User, Customer)` | Yes | AnyRole | customer.store_id |
specs/06-AUTH-AND-SECURITY.md:480:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:493:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:505:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:511:#### FulfillmentPolicy
specs/06-AUTH-AND-SECURITY.md:513:File: `app/Policies/FulfillmentPolicy.php`
specs/06-AUTH-AND-SECURITY.md:515:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:517:| `create(User, Order)` | Yes (Order) | OwnerAdminOrStaff | order.store_id |
specs/06-AUTH-AND-SECURITY.md:518:| `update(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |
specs/06-AUTH-AND-SECURITY.md:519:| `cancel(User, Fulfillment)` | Yes | OwnerAdminOrStaff | fulfillment -> order.store_id |
specs/06-AUTH-AND-SECURITY.md: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.
specs/06-AUTH-AND-SECURITY.md:523:#### RefundPolicy
specs/06-AUTH-AND-SECURITY.md:525:File: `app/Policies/RefundPolicy.php`
specs/06-AUTH-AND-SECURITY.md:527:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:529:| `create(User, Order)` | Yes (Order) | OwnerOrAdmin | order.store_id |
specs/06-AUTH-AND-SECURITY.md:531:Note: `create` receives the parent Order for the same reason as FulfillmentPolicy.
specs/06-AUTH-AND-SECURITY.md:537:| Method | Receives Model? | Required Roles | Store ID Source |
specs/06-AUTH-AND-SECURITY.md:548:| Gate Name | Required Roles | Purpose |
specs/06-AUTH-AND-SECURITY.md:551:| `manage-staff` | Owner, Admin | Invite, update, remove staff members |
specs/06-AUTH-AND-SECURITY.md:602:| `role.check` | `App\Http\Middleware\CheckStoreRole` | Checks user has required role for current store |
specs/06-AUTH-AND-SECURITY.md:612:| Admin web (authenticated) | `web`, `auth`, `verified`, `store.resolve`, `role.check` | All admin panel pages |
specs/06-AUTH-AND-SECURITY.md:613:| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |
specs/06-AUTH-AND-SECURITY.md: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.
specs/06-AUTH-AND-SECURITY.md: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.)
specs/06-AUTH-AND-SECURITY.md:659:#### CheckStoreRole
specs/06-AUTH-AND-SECURITY.md:661:File: `app/Http/Middleware/CheckStoreRole.php`
specs/06-AUTH-AND-SECURITY.md:665:**Route usage:** Applied as `role.check:owner,admin` or `role.check:owner,admin,staff` (comma-separated role values passed as variadic parameters).
specs/06-AUTH-AND-SECURITY.md:675:    G -- No --> H[Abort 403: Insufficient permissions]
specs/06-AUTH-AND-SECURITY.md:686:5. Convert the middleware parameter strings to `StoreUserRole` enum instances.
specs/06-AUTH-AND-SECURITY.md:687:6. If the user's role is not in the allowed list: abort 403 with message "Insufficient permissions."
specs/06-AUTH-AND-SECURITY.md:739:| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |
specs/06-AUTH-AND-SECURITY.md: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. |
specs/06-AUTH-AND-SECURITY.md:787:| `UpdateProductRequest` | Update product | Same fields as StoreProductRequest but all optional (sometimes rule). |
specs/06-AUTH-AND-SECURITY.md: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. |
specs/06-AUTH-AND-SECURITY.md: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. |
specs/06-AUTH-AND-SECURITY.md:797:| `InviteStaffRequest` | Invite staff member | `email`: required, valid email. `role`: required, in:admin,staff,support (not owner). |
specs/06-AUTH-AND-SECURITY.md: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.
specs/06-AUTH-AND-SECURITY.md:873:| Minimum length | 8 characters (for both admin and customer passwords) |
specs/06-AUTH-AND-SECURITY.md:929:| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |
specs/06-AUTH-AND-SECURITY.md:998:| `user_id` | integer or null | N | ID of the admin user who triggered the event |
specs/06-AUTH-AND-SECURITY.md:1015:| `auth.login` | Successful admin login | user_id, ip, user_agent |
specs/06-AUTH-AND-SECURITY.md:1017:| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |
specs/06-AUTH-AND-SECURITY.md:1026:**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff
specs/06-AUTH-AND-SECURITY.md:1095:| 1 | Session-based auth (admin) | Laravel `web` guard with bcrypt, session regeneration on login | Required |
specs/06-AUTH-AND-SECURITY.md:1098:| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |
specs/06-AUTH-AND-SECURITY.md:1100:| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |
specs/06-AUTH-AND-SECURITY.md:1110:| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |
specs/03-ADMIN-UI.md:3:This document defines every view, component, and interaction for the admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.
specs/03-ADMIN-UI.md:13:The admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.
specs/03-ADMIN-UI.md:17:All admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.
specs/03-ADMIN-UI.md:23:**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`
specs/03-ADMIN-UI.md:47:|  [cube]         Products      |
specs/03-ADMIN-UI.md:53:|  [shopping-bag] Orders        |
specs/03-ADMIN-UI.md:80:Dashboard                        -> admin.dashboard
specs/03-ADMIN-UI.md:81:Products (group label)
specs/03-ADMIN-UI.md:82:  Products                       -> admin.products.index
specs/03-ADMIN-UI.md:83:  Collections                    -> admin.collections.index
specs/03-ADMIN-UI.md:84:  Inventory                      -> admin.inventory.index
specs/03-ADMIN-UI.md:85:Orders
specs/03-ADMIN-UI.md:86:  Orders                         -> admin.orders.index
specs/03-ADMIN-UI.md:88:  Customers                      -> admin.customers.index
specs/03-ADMIN-UI.md:90:  Discounts                      -> admin.discounts.index
specs/03-ADMIN-UI.md:92:  Pages                          -> admin.pages.index
specs/03-ADMIN-UI.md:93:  Navigation                     -> admin.navigation.index
specs/03-ADMIN-UI.md:94:  Themes                         -> admin.themes.index
specs/03-ADMIN-UI.md:95:Analytics                        -> admin.analytics.index
specs/03-ADMIN-UI.md:96:Settings                         -> admin.settings.index
specs/03-ADMIN-UI.md:97:Apps                             -> admin.apps.index
specs/03-ADMIN-UI.md:98:Developers                       -> admin.developers.index
specs/03-ADMIN-UI.md:120:**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`
specs/03-ADMIN-UI.md:148:    - Settings (links to /admin/settings)
specs/03-ADMIN-UI.md:175:**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always "Home" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > "Blue T-Shirt".
specs/03-ADMIN-UI.md:192:Components dispatch toasts after successful actions (e.g., "Product saved successfully.") or on errors ("Something went wrong. Please try again.").
specs/03-ADMIN-UI.md:196:The admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.
specs/03-ADMIN-UI.md:204:**Route:** `GET /admin`
specs/03-ADMIN-UI.md:208:**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`
specs/03-ADMIN-UI.md:219:| `averageOrderValue` | `int` | Average order value in minor units |
specs/03-ADMIN-UI.md:226:| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |
specs/03-ADMIN-UI.md:234:| `loadTopProducts()` | Queries top 5 products by revenue |
specs/03-ADMIN-UI.md:250:|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |
specs/03-ADMIN-UI.md:256:|  | Orders over time                                              |  |
specs/03-ADMIN-UI.md:262:|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |
specs/03-ADMIN-UI.md:288:### Orders Chart
specs/03-ADMIN-UI.md:291:- A Flux heading (size lg): "Orders over time"
specs/03-ADMIN-UI.md:296:### Top Products Table
specs/03-ADMIN-UI.md:300:- A plain table with columns: Product Title, Units Sold, Revenue
specs/03-ADMIN-UI.md:337:## 3. Products List
specs/03-ADMIN-UI.md:339:**Route:** `GET /admin/products`
specs/03-ADMIN-UI.md:341:**Livewire component:** `App\Livewire\Admin\Products\Index`
specs/03-ADMIN-UI.md:343:**Blade view:** `resources/views/livewire/admin/products/index.blade.php`
specs/03-ADMIN-UI.md:351:| `typeFilter` | `string` | Product type filter |
specs/03-ADMIN-UI.md:378:| Products                                    [+ Add product]       |
specs/03-ADMIN-UI.md:402:| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |
specs/03-ADMIN-UI.md:405:| Type | No | Product type text or "-" |
specs/03-ADMIN-UI.md:432:| Body text | "This will archive X product(s). Products with orders cannot be permanently deleted." |
specs/03-ADMIN-UI.md:464:## 4. Product Create/Edit
specs/03-ADMIN-UI.md:466:**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`
specs/03-ADMIN-UI.md:468:**Livewire component:** `App\Livewire\Admin\Products\Form`
specs/03-ADMIN-UI.md:470:**Blade view:** `resources/views/livewire/admin/products/form.blade.php`
specs/03-ADMIN-UI.md:476:| `product` | `?Product` | The product being edited, null for create |
specs/03-ADMIN-UI.md:477:| `title` | `string` | Product title |
specs/03-ADMIN-UI.md:478:| `descriptionHtml` | `string` | Product description (HTML) |
specs/03-ADMIN-UI.md:481:| `productType` | `string` | Product type string |
specs/03-ADMIN-UI.md:493:| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |
specs/03-ADMIN-UI.md:504:| `deleteProduct()` | Archives the product (with confirmation modal) |
specs/03-ADMIN-UI.md:544:|  |                                    |  |  Product type:    |
specs/03-ADMIN-UI.md:601:- Drag-to-reorder supported via Livewire sortable or Alpine.js Sortable
specs/03-ADMIN-UI.md:641:#### Product Organization Card
specs/03-ADMIN-UI.md:643:- Product type: Flux input, placeholder "T-Shirts"
specs/03-ADMIN-UI.md:665:| Body text | "This product will be archived. Products with existing orders cannot be permanently removed." |
specs/03-ADMIN-UI.md:667:| Confirm button | Danger variant, calls `deleteProduct` |
specs/03-ADMIN-UI.md:704:**Route:** `GET /admin/collections`
specs/03-ADMIN-UI.md:708:**Blade view:** `resources/views/livewire/admin/collections/index.blade.php`
specs/03-ADMIN-UI.md:728:Same pattern as Products list:
specs/03-ADMIN-UI.md:738:| Products count | Number of products in collection |
specs/03-ADMIN-UI.md:746:**Route:** `GET /admin/collections/create` and `GET /admin/collections/{collection}/edit`
specs/03-ADMIN-UI.md:750:**Blade view:** `resources/views/livewire/admin/collections/form.blade.php`
specs/03-ADMIN-UI.md:762:| `assignedProductIds` | `array<int>` | Ordered list of product IDs in this collection |
specs/03-ADMIN-UI.md:767:| `addProduct(int $productId)` | Adds a product to the collection |
specs/03-ADMIN-UI.md:768:| `removeProduct(int $productId)` | Removes a product from the collection |
specs/03-ADMIN-UI.md:769:| `reorderProducts(array $order)` | Updates product positions |
specs/03-ADMIN-UI.md:773:| `searchResults` | Products matching `productSearch` that are not already assigned |
specs/03-ADMIN-UI.md:774:| `assignedProducts` | Ordered collection of assigned Product models |
specs/03-ADMIN-UI.md:808:- Products assignment section:
specs/03-ADMIN-UI.md:821:**Route:** `GET /admin/inventory`
specs/03-ADMIN-UI.md:825:**Blade view:** `resources/views/livewire/admin/inventory/index.blade.php`
specs/03-ADMIN-UI.md:846:| Product | Variant | SKU     | On Hand | Reserved | Policy | Act  |
specs/03-ADMIN-UI.md:858:- Table columns: Product, Variant, SKU, On Hand (inline-editable number input), Reserved, Policy (shown as Flux badge: "deny" or "continue"), Actions
specs/03-ADMIN-UI.md:862:## 7. Orders List
specs/03-ADMIN-UI.md:864:**Route:** `GET /admin/orders`
specs/03-ADMIN-UI.md:866:**Livewire component:** `App\Livewire\Admin\Orders\Index`
specs/03-ADMIN-UI.md:868:**Blade view:** `resources/views/livewire/admin/orders/index.blade.php`
specs/03-ADMIN-UI.md:893:| Orders                                                            |
specs/03-ADMIN-UI.md:897:| [All] [Pending] [Paid] [Fulfilled] [Cancelled] [Refunded]       |
specs/03-ADMIN-UI.md:899:| Order #  | Date           | Customer  | Payment   | Fulfill | Total  |
specs/03-ADMIN-UI.md:910:A horizontal row of tab-style buttons (plain Tailwind, not Flux component). Options: All, Pending, Paid, Fulfilled, Cancelled, Refunded. The active tab gets a bottom border and bold text. Inactive tabs use muted text color.
specs/03-ADMIN-UI.md:916:| Order number | Link to order detail (e.g., "#1001"), uses Livewire navigate |
specs/03-ADMIN-UI.md:920:| Fulfillment status | Flux badge: unfulfilled=zinc, partial=yellow, fulfilled=green |
specs/03-ADMIN-UI.md:936:## 8. Order Detail
specs/03-ADMIN-UI.md:938:**Route:** `GET /admin/orders/{order}`
specs/03-ADMIN-UI.md:940:**Livewire component:** `App\Livewire\Admin\Orders\Show`
specs/03-ADMIN-UI.md:942:**Blade view:** `resources/views/livewire/admin/orders/show.blade.php`
specs/03-ADMIN-UI.md:948:| `order` | `Order` | The order model (eager loaded with lines, payments, fulfillments, customer) |
specs/03-ADMIN-UI.md:954:| `refundReason` | `string` | Refund reason text |
specs/03-ADMIN-UI.md:959:| `mount(Order $order)` | Loads order with all relationships |
specs/03-ADMIN-UI.md:960:| `confirmPayment()` | Confirms bank transfer payment received (transitions financial_status to `paid`, commits inventory, dispatches `OrderPaid`) |
specs/03-ADMIN-UI.md:961:| `createFulfillment()` | Creates a fulfillment record for selected lines (enforces fulfillment guard) |
specs/03-ADMIN-UI.md:964:| `createRefund()` | Creates a refund for selected lines or custom amount |
specs/03-ADMIN-UI.md:965:| `openFulfillmentModal()` | Opens the fulfillment modal |
specs/03-ADMIN-UI.md:966:| `openRefundModal()` | Opens the refund modal |
specs/03-ADMIN-UI.md:977:| [Create fulfillment]  [Refund]               | > View customer    |
specs/03-ADMIN-UI.md:980:| o  Order placed - Jan 15, 2:30 PM           | 123 Main St        |
specs/03-ADMIN-UI.md:987:| IMG | Product      | Qty | Unit $ | Total   | Springfield, IL    |
specs/03-ADMIN-UI.md:1009:#### Order Heading
specs/03-ADMIN-UI.md:1010:- Order number as a large heading
specs/03-ADMIN-UI.md:1012:- Fulfillment status badge (color mapped: fulfilled=green, default=zinc)
specs/03-ADMIN-UI.md:1017:- "Confirm payment" button (primary, shown only when `payment_method = bank_transfer` AND `financial_status = pending`). On click, calls `confirmPayment()` which transitions the order to `paid`, commits inventory, and dispatches `OrderPaid`.
specs/03-ADMIN-UI.md:1019:- "Refund" button (ghost variant, shown when order is paid or partially_refunded)
specs/03-ADMIN-UI.md:1021:#### Fulfillment Guard
specs/03-ADMIN-UI.md:1032:- Events include: Order placed, Payment received, Fulfillment created, Shipped, Delivered, Refunded (as applicable)
specs/03-ADMIN-UI.md:1034:#### Order Lines Table
specs/03-ADMIN-UI.md:1039:| Product | Title and SKU |
specs/03-ADMIN-UI.md:1044:#### Order Summary
specs/03-ADMIN-UI.md:1057:#### Fulfillment Cards
specs/03-ADMIN-UI.md:1060:- Fulfillment status badge (pending=zinc, shipped=blue, delivered=green)
specs/03-ADMIN-UI.md:1084:### Fulfillment Modal
specs/03-ADMIN-UI.md:1100:### Refund Modal
specs/03-ADMIN-UI.md:1106:| Heading | "Refund order" |
specs/03-ADMIN-UI.md:1119:| Order number heading | `heading` |
specs/03-ADMIN-UI.md:1138:**Route:** `GET /admin/customers`
specs/03-ADMIN-UI.md:1142:**Blade view:** `resources/views/livewire/admin/customers/index.blade.php`
specs/03-ADMIN-UI.md:1158:- Table columns: Name, Email, Orders count, Total spent (formatted), Created date
specs/03-ADMIN-UI.md:1163:**Route:** `GET /admin/customers/{customer}`
specs/03-ADMIN-UI.md:1167:**Blade view:** `resources/views/livewire/admin/customers/show.blade.php`
specs/03-ADMIN-UI.md:1199:| Order # | Date      | Status | Total         | 456 Corp Ave       |
specs/03-ADMIN-UI.md:1209:- Order history table: columns Order #, Date, Status, Total. Each order links to order detail. Paginated.
specs/03-ADMIN-UI.md:1237:**Route:** `GET /admin/discounts`
specs/03-ADMIN-UI.md:1241:**Blade view:** `resources/views/livewire/admin/discounts/index.blade.php`
specs/03-ADMIN-UI.md:1272:**Route:** `GET /admin/discounts/create` and `GET /admin/discounts/{discount}/edit`
specs/03-ADMIN-UI.md:1276:**Blade view:** `resources/views/livewire/admin/discounts/form.blade.php`
specs/03-ADMIN-UI.md:1288:| `specificProductIds` | `array<int>` | Specific products the discount applies to |
specs/03-ADMIN-UI.md:1302:| `addProduct(int $productId)` | Adds product to specific products list |
specs/03-ADMIN-UI.md:1303:| `removeProduct(int $productId)` | Removes product |
specs/03-ADMIN-UI.md:1382:**Route:** `GET /admin/settings`
specs/03-ADMIN-UI.md:1386:**Blade view:** `resources/views/livewire/admin/settings/general.blade.php`
specs/03-ADMIN-UI.md:1423:Domains is a tab within the Settings page (`/admin/settings`), not a separate route. The Settings page has tabs: General, Domains, Shipping, Taxes, Checkout, Notifications.
specs/03-ADMIN-UI.md:1427:**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`
specs/03-ADMIN-UI.md:1435:| `newType` | `string` | Type for new domain: `storefront`, `admin`, `api` |
specs/03-ADMIN-UI.md:1451:| Type | Flux badge: storefront/admin/api |
specs/03-ADMIN-UI.md:1473:**Route:** `GET /admin/settings/shipping`
specs/03-ADMIN-UI.md:1477:**Blade view:** `resources/views/livewire/admin/settings/shipping.blade.php`
specs/03-ADMIN-UI.md:1590:**Route:** `GET /admin/settings/taxes`
specs/03-ADMIN-UI.md:1594:**Blade view:** `resources/views/livewire/admin/settings/taxes.blade.php`
specs/03-ADMIN-UI.md:1659:**Route:** `GET /admin/themes`
specs/03-ADMIN-UI.md:1663:**Blade view:** `resources/views/livewire/admin/themes/index.blade.php`
specs/03-ADMIN-UI.md:1716:**Route:** `GET /admin/themes/{theme}/editor`
specs/03-ADMIN-UI.md:1720:**Blade view:** `resources/views/livewire/admin/themes/editor.blade.php`
specs/03-ADMIN-UI.md:1753:| Products   |  |  preview rendered      | |  Logo text:          |
specs/03-ADMIN-UI.md:1768:**Full-width layout** (no standard admin shell padding). Three-panel layout:
specs/03-ADMIN-UI.md:1807:**Route:** `GET /admin/pages`
specs/03-ADMIN-UI.md:1811:**Blade view:** `resources/views/livewire/admin/pages/index.blade.php`
specs/03-ADMIN-UI.md:1832:**Route:** `GET /admin/pages/create` and `GET /admin/pages/{page}/edit`
specs/03-ADMIN-UI.md:1836:**Blade view:** `resources/views/livewire/admin/pages/form.blade.php`
specs/03-ADMIN-UI.md:1871:**Route:** `GET /admin/navigation`
specs/03-ADMIN-UI.md:1875:**Blade view:** `resources/views/livewire/admin/navigation/index.blade.php`
specs/03-ADMIN-UI.md:1898:| `reorderItems(array $order)` | Updates item positions (supports nesting) |
specs/03-ADMIN-UI.md:1918:| | [drag] Products           collection: All    [edit] [trash]  | |
specs/03-ADMIN-UI.md:1949:- Type select: Custom link, Page, Collection, Product (live-bound)
specs/03-ADMIN-UI.md:1952:  - **Page/Collection/Product:** Resource select dropdown populated with available resources of that type
specs/03-ADMIN-UI.md:1959:**Route:** `GET /admin/apps`
specs/03-ADMIN-UI.md:1963:**Blade view:** `resources/views/livewire/admin/apps/index.blade.php`
specs/03-ADMIN-UI.md:2001:- Scopes granted (list of permission strings)
specs/03-ADMIN-UI.md:2011:**Route:** `GET /admin/developers`
specs/03-ADMIN-UI.md:2015:**Blade view:** `resources/views/livewire/admin/developers/index.blade.php`
specs/03-ADMIN-UI.md:2124:**Route:** `GET /admin/analytics`
specs/03-ADMIN-UI.md:2128:**Blade view:** `resources/views/livewire/admin/analytics/index.blade.php`
specs/03-ADMIN-UI.md:2140:| `ordersCount` | `int` | Orders count |
specs/03-ADMIN-UI.md:2141:| `averageOrderValue` | `int` | AOV |
specs/03-ADMIN-UI.md:2144:| `topProducts` | `Collection` | Top products by revenue |
specs/03-ADMIN-UI.md:2168:| | Total Sales| | Orders     | | AOV        | | Conv Rate |       |
specs/03-ADMIN-UI.md:2176:| Rank | Product     | Units Sold | Revenue  | % of Total          |
specs/03-ADMIN-UI.md:2182:| Source          | Sessions | Orders | Conversion Rate             |
specs/03-ADMIN-UI.md:2199:**KPI tiles:** Same as Dashboard (Total Sales, Orders, AOV, Conversion Rate) but with the additional filters applied.
specs/03-ADMIN-UI.md:2203:**Top Products table:** Extended version with 10-20 rows. Columns: Rank, Product, Units Sold, Revenue, Percentage of Total.
specs/03-ADMIN-UI.md:2205:**Top Referrers table:** Columns: Traffic source, Sessions, Orders, Conversion Rate.
specs/03-ADMIN-UI.md:2211:**Route:** `GET /admin/search/settings`
specs/03-ADMIN-UI.md:2215:**Blade view:** `resources/views/livewire/admin/search/settings.blade.php`
specs/03-ADMIN-UI.md:2333:- First item: "Home" linking to admin dashboard
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/03-ADMIN-UI.md:2346:Reusable card wrapper used throughout the admin:
specs/03-ADMIN-UI.md:2378:| Product created | "Product saved" | success |
specs/03-ADMIN-UI.md:2379:| Product updated | "Product saved" | success |
specs/03-ADMIN-UI.md:2380:| Product archived | "Product archived" | success |
specs/03-ADMIN-UI.md:2385:| Order fulfilled | "Fulfillment created" | success |
specs/03-ADMIN-UI.md:2386:| Refund issued | "Refund issued" | success |
specs/03-ADMIN-UI.md:2402:| `App\Livewire\Admin\Layout\Sidebar` | `livewire.admin.layout.sidebar` | Layout (all pages) |
specs/03-ADMIN-UI.md:2403:| `App\Livewire\Admin\Layout\TopBar` | `livewire.admin.layout.top-bar` | Layout (all pages) |
specs/03-ADMIN-UI.md:2404:| `App\Livewire\Admin\Dashboard` | `livewire.admin.dashboard` | `GET /admin` |
specs/03-ADMIN-UI.md:2405:| `App\Livewire\Admin\Products\Index` | `livewire.admin.products.index` | `GET /admin/products` |
specs/03-ADMIN-UI.md:2406:| `App\Livewire\Admin\Products\Form` | `livewire.admin.products.form` | `GET /admin/products/create`, `GET /admin/products/{product}/edit` |
specs/03-ADMIN-UI.md:2407:| `App\Livewire\Admin\Collections\Index` | `livewire.admin.collections.index` | `GET /admin/collections` |
specs/03-ADMIN-UI.md:2408:| `App\Livewire\Admin\Collections\Form` | `livewire.admin.collections.form` | `GET /admin/collections/create`, `GET /admin/collections/{collection}/edit` |
specs/03-ADMIN-UI.md:2409:| `App\Livewire\Admin\Inventory\Index` | `livewire.admin.inventory.index` | `GET /admin/inventory` |
specs/03-ADMIN-UI.md:2410:| `App\Livewire\Admin\Orders\Index` | `livewire.admin.orders.index` | `GET /admin/orders` |
specs/03-ADMIN-UI.md:2411:| `App\Livewire\Admin\Orders\Show` | `livewire.admin.orders.show` | `GET /admin/orders/{order}` |
specs/03-ADMIN-UI.md:2412:| `App\Livewire\Admin\Customers\Index` | `livewire.admin.customers.index` | `GET /admin/customers` |
specs/03-ADMIN-UI.md:2413:| `App\Livewire\Admin\Customers\Show` | `livewire.admin.customers.show` | `GET /admin/customers/{customer}` |
specs/03-ADMIN-UI.md:2414:| `App\Livewire\Admin\Discounts\Index` | `livewire.admin.discounts.index` | `GET /admin/discounts` |
specs/03-ADMIN-UI.md:2415:| `App\Livewire\Admin\Discounts\Form` | `livewire.admin.discounts.form` | `GET /admin/discounts/create`, `GET /admin/discounts/{discount}/edit` |
specs/03-ADMIN-UI.md:2416:| `App\Livewire\Admin\Settings\General` | `livewire.admin.settings.general` | `GET /admin/settings` |
specs/03-ADMIN-UI.md:2417:| `App\Livewire\Admin\Settings\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |
specs/03-ADMIN-UI.md:2418:| `App\Livewire\Admin\Settings\Shipping` | `livewire.admin.settings.shipping` | `GET /admin/settings/shipping` |
specs/03-ADMIN-UI.md:2419:| `App\Livewire\Admin\Settings\Taxes` | `livewire.admin.settings.taxes` | `GET /admin/settings/taxes` |
specs/03-ADMIN-UI.md:2420:| `App\Livewire\Admin\Themes\Index` | `livewire.admin.themes.index` | `GET /admin/themes` |
specs/03-ADMIN-UI.md:2421:| `App\Livewire\Admin\Themes\Editor` | `livewire.admin.themes.editor` | `GET /admin/themes/{theme}/editor` |
specs/03-ADMIN-UI.md:2422:| `App\Livewire\Admin\Pages\Index` | `livewire.admin.pages.index` | `GET /admin/pages` |
specs/03-ADMIN-UI.md:2423:| `App\Livewire\Admin\Pages\Form` | `livewire.admin.pages.form` | `GET /admin/pages/create`, `GET /admin/pages/{page}/edit` |
specs/03-ADMIN-UI.md:2424:| `App\Livewire\Admin\Navigation\Index` | `livewire.admin.navigation.index` | `GET /admin/navigation` |
specs/03-ADMIN-UI.md:2425:| `App\Livewire\Admin\Apps\Index` | `livewire.admin.apps.index` | `GET /admin/apps` |
specs/03-ADMIN-UI.md:2426:| `App\Livewire\Admin\Developers\Index` | `livewire.admin.developers.index` | `GET /admin/developers` |
specs/03-ADMIN-UI.md:2427:| `App\Livewire\Admin\Analytics\Index` | `livewire.admin.analytics.index` | `GET /admin/analytics` |
specs/03-ADMIN-UI.md:2428:| `App\Livewire\Admin\Search\Settings` | `livewire.admin.search.settings` | `GET /admin/search/settings` |


cmd: sed -n '1,260p' app/Services/WebhookService.php && sed -n '1,230p' app/Livewire/Admin/Developers/Index.php && sed -n '1,240p' app/Enums/WebhookEventType.php


cmd: sed -n '1,220p' tests/Feature/Api/AdminCatalogApiTest.php && sed -n '1,220p' tests/Feature/Api/AdminPlatformApiTest.php


cmd: sed -n '1,220p' app/Http/Requests/Api/Admin/V1/StoreProductRequest.php && sed -n '1,220p' app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php


command: sed -n '1,220p' tests/Feature/Api/AdminCatalogApiTest.php && sed -n '1,220p' tests/Feature/Api/AdminPlatformApiTest.php

AdminCatalogApiTest.php

type: read

AdminPlatformApiTest.php

aggregated_output:
<?php

use App\Enums\MediaStatus;
use App\Enums\ProductStatus;
use App\Models\Collection as ProductCollection;
use App\Models\Customer;
use App\Models\CustomerAddress;
use App\Models\Order;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminCatalogApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminCatalogApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminCatalogApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Catalog integration', $abilities);
}

test('admin product api lists and shows store scoped products', function (): void {
    $store = adminCatalogApiStore();
    $product = Product::factory()
        ->withDefaultVariant(3299)
        ->create([
            'store_id' => $store->getKey(),
            'title' => 'Admin API Jacket',
            'handle' => 'admin-api-jacket',
            'vendor' => 'Catalog Test Vendor',
            'tags' => ['outerwear', 'api'],
        ]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();
    Product::factory()
        ->withDefaultVariant()
        ->create([
            'store_id' => Store::factory()->create()->getKey(),
            'title' => 'Other Store Jacket',
        ]);

    $this->getJson("/api/admin/v1/stores/{$store->getKey()}/products")
        ->assertUnauthorized();

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Admin API Jacket')
        ->assertJsonPath('data.0.variants_count', 1)
        ->assertJsonPath('data.0.total_inventory', 50)
        ->assertJsonMissing(['title' => 'Other Store Jacket']);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.title', 'Admin API Jacket')
        ->assertJsonPath('data.variants.0.price_amount', 3299)
        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50);
});

test('admin product api creates updates and archives products', function (): void {
    $store = adminCatalogApiStore();
    $collection = ProductCollection::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'API Summer',
        'handle' => 'api-summer',
    ]);
    $user = adminCatalogApiUser();

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
            'title' => 'API Cotton T-Shirt',
            'handle' => 'api-cotton-t-shirt',
            'description_html' => '
Soft cotton tee.
',
            'vendor' => 'API Apparel',
            'product_type' => 'Shirts',
            'status' => 'active',
            'tags' => ['organic', 'cotton'],
            'options' => [
                ['name' => 'Color', 'position' => 1],
                ['name' => 'Size', 'position' => 2],
            ],
            'variants' => [
                [
                    'sku' => 'API-TEE-BLU-S',
                    'price_amount' => 2500,
                    'compare_at_amount' => 3000,
                    'is_default' => true,
                    'position' => 1,
                    'option_values' => [
                        ['option_name' => 'Color', 'value' => 'Blue'],
                        ['option_name' => 'Size', 'value' => 'Small'],
                    ],
                    'inventory' => [
                        'quantity_on_hand' => 12,
                        'policy' => 'deny',
                    ],
                ],
                [
                    'sku' => 'API-TEE-BLU-M',
                    'price_amount' => 2600,
                    'is_default' => false,
                    'position' => 2,
                    'option_values' => [
                        ['option_name' => 'Color', 'value' => 'Blue'],
                        ['option_name' => 'Size', 'value' => 'Medium'],
                    ],
                    'inventory' => [
                        'quantity_on_hand' => 8,
                        'policy' => 'continue',
                    ],
                ],
            ],
            'collections' => [$collection->getKey()],
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'API Cott…4224 chars truncated…on', $abilities);
}

test('platform api creates organizations and stores for owner sessions', function (): void {
    $user = adminPlatformApiUser();

    $organizationResponse = $this->actingAs($user)
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Platform API Org',
            'billing_email' => 'billing@platform-api.test',
        ])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Platform API Org')
        ->assertJsonPath('data.billing_email', 'billing@platform-api.test');

    $organizationId = $organizationResponse->json('data.id');

    $this->actingAs($user)
        ->postJson('/api/admin/v1/platform/stores', [
            'organization_id' => $organizationId,
            'name' => 'Platform API Store',
            'handle' => 'platform-api-store',
            'default_currency' => 'EUR',
            'default_locale' => 'en',
            'timezone' => 'Europe/Berlin',
        ])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Platform API Store')
        ->assertJsonPath('data.handle', 'platform-api-store')
        ->assertJsonPath('data.status', 'active');

    $store = Store::query()->where('handle', 'platform-api-store')->firstOrFail();

    expect(StoreSettings::query()->whereKey($store->getKey())->exists())->toBeTrue()
        ->and(DB::table('store_users')
            ->where('store_id', $store->getKey())
            ->where('user_id', $user->getKey())
            ->where('role', 'owner')
            ->exists())->toBeTrue();
});

test('platform api rejects non owner sessions and tokens without manage platform ability', function (): void {
    $store = adminPlatformApiStore();
    $staff = User::factory()->create([
        'email' => 'platform-staff@example.test',
    ]);
    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $staff->getKey(),
        'role' => 'staff',
        'created_at' => now(),
    ]);
    $readToken = adminPlatformApiToken($store, ['read-products']);

    $this->actingAs($staff)
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Forbidden Org',
            'billing_email' => 'forbidden@example.test',
        ])
        ->assertForbidden();

    $this->withToken($readToken['plain_text'])
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Forbidden Token Org',
            'billing_email' => 'forbidden-token@example.test',
        ])
        ->assertForbidden();
});

test('platform api accepts manage platform bearer tokens', function (): void {
    $store = adminPlatformApiStore();
    $token = adminPlatformApiToken($store, ['manage-platform']);

    $this->withToken($token['plain_text'])
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Token Platform Org',
            'billing_email' => 'token-platform@example.test',
        ])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Token Platform Org');

    expect($token['token']->refresh()->last_used_at)->not->toBeNull();
});

test('store me and invite endpoints expose membership and enforce role or ability', function (): void {
    $store = adminPlatformApiStore();
    $user = adminPlatformApiUser();
    $staff = User::factory()->create([
        'email' => 'store-staff@example.test',
    ]);
    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $staff->getKey(),
        'role' => 'staff',
        'created_at' => now(),
    ]);
    $readToken = adminPlatformApiToken($store, ['read-products']);
    $manageToken = adminPlatformApiToken($store, ['manage-platform']);

    $this->withToken($readToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'new-staff@example.test',
            'role' => 'staff',
        ])
        ->assertForbidden();

    $this->withToken($manageToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'new-staff@example.test',
            'role' => 'staff',
        ])
        ->assertCreated()
        ->assertJsonPath('data.email', 'new-staff@example.test')
        ->assertJsonPath('data.role', 'staff');

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/me")
        ->assertOk()
        ->assertJsonPath('data.email', 'admin@acme.test')
        ->assertJsonPath('data.role', 'owner')
        ->assertJsonPath('data.permissions.1', 'read-products');

    $this->actingAs($staff)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'new-staff@example.test',
            'role' => 'staff',
        ])
        ->assertForbidden();

    $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'admin@acme.test',
            'role' => 'admin',
        ])
        ->assertStatus(409);
});

status: completed


command: sed -n '1,260p' app/Services/WebhookService.php && sed -n '1,230p' app/Livewire/Admin/Developers/Index.php && sed -n '1,240p' app/Enums/WebhookEventType.php

WebhookEventType.php

aggregated_output:
<?php

namespace App\Services;

use App\Enums\AppInstallationStatus;
use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Jobs\DeliverWebhook;
use App\Models\App as AppModel;
use App\Models\AppInstallation;
use App\Models\OauthToken;
use App\Models\Store;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use Illuminate\Support\Str;
use InvalidArgumentException;

class WebhookService
{
    /**
     * @param  array<string, mixed>  $payload
     */
    public function dispatch(Store $store, string $eventType, array $payload): void
    {
        $event = WebhookEventType::tryFrom($eventType);

        if (! $event instanceof WebhookEventType) {
            throw new InvalidArgumentException("Unsupported webhook event type [{$eventType}].");
        }

        WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('event_type', $event->value)
            ->where('status', WebhookSubscriptionStatus::Active->value)
            ->get()
            ->each(function (WebhookSubscription $subscription) use ($store, $event, $payload): void {
                $delivery = WebhookDelivery::query()->create([
                    'subscription_id' => $subscription->getKey(),
                    'event_id' => (string) Str::uuid(),
                    'attempt_count' => 1,
                    'status' => WebhookDeliveryStatus::Pending,
                ]);

                DeliverWebhook::dispatch(
                    $delivery->getKey(),
                    $event->value,
                    $this->envelope($store, $event, $delivery->event_id, $payload),
                )->onConnection('database')->onQueue('webhooks');
            });
    }

    public function sign(string $payload, string $secret): string
    {
        return hash_hmac('sha256', $payload, $secret);
    }

    public function verify(string $payload, string $signature, string $secret): bool
    {
        return hash_equals($this->sign($payload, $secret), $signature);
    }

    /**
     * @param  list<string>  $abilities
     * @return array{token: OauthToken, plain_text: string}
     */
    public function createApiToken(Store $store, string $name, array $abilities): array
    {
        $app = AppModel::query()->firstOrCreate(
            ['name' => 'Admin API'],
            [
                'status' => 'active',
                'created_at' => now(),
            ],
        );

        $installation = AppInstallation::withoutGlobalScopes()->firstOrCreate(
            [
                'store_id' => $store->getKey(),
                'app_id' => $app->getKey(),
            ],
            [
                'scopes_json' => $abilities,
                'status' => AppInstallationStatus::Active,
                'installed_at' => now(),
            ],
        );

        $plainText = 'shop_'.Str::random(48);
        $token = OauthToken::query()->create([
            'installation_id' => $installation->getKey(),
            'name' => $name,
            'access_token_hash' => hash('sha256', $plainText),
            'refresh_token_hash' => null,
            'abilities_json' => $abilities,
            'expires_at' => now()->addYear(),
            'created_at' => now(),
        ]);

        return [
            'token' => $token,
            'plain_text' => $plainText,
        ];
    }

    public function createSigningSecret(): string
    {
        return 'whsec_'.Str::random(40);
    }

    public function recordSuccess(WebhookDelivery $delivery, int $attemptCount, int $responseCode, string $responseBody): void
    {
        $delivery->forceFill([
            'attempt_count' => $attemptCount,
            'status' => WebhookDeliveryStatus::Success,
            'last_attempt_at' => now(),
            'response_code' => $responseCode,
            'response_body_snippet' => Str::limit($responseBody, 1000, ''),
        ])->save();
    }

    public function recordFailure(WebhookDelivery $delivery, int $attemptCount, ?int $responseCode, ?string $responseBody): void
    {
        $delivery->forceFill([
            'attempt_count' => $attemptCount,
            'status' => WebhookDeliveryStatus::Failed,
            'last_attempt_at' => now(),
            'response_code' => $responseCode,
            'response_body_snippet' => Str::limit((string) $responseBody, 1000, ''),
        ])->save();

        $subscription = $delivery->subscription()->first();

        if ($subscription instanceof WebhookSubscription && $this->hasFiveConsecutiveFailures($subscription)) {
            $subscription->forceFill([
                'status' => WebhookSubscriptionStatus::Paused,
            ])->save();
        }
    }

    public function hasFiveConsecutiveFailures(WebhookSubscription $subscription): bool
    {
        $recentStatuses = $subscription->deliveries()
            ->latest('id')
            ->limit(5)
            ->pluck('status');

        return…3669 chars truncated…], [], [
            'webhookEventType' => 'event type',
            'webhookUrl' => 'endpoint URL',
        ]);

        $webhook = $this->editingWebhookId !== null
            ? $this->webhook($this->editingWebhookId)
            : new WebhookSubscription([
                'store_id' => $this->storeId,
                'signing_secret_encrypted' => $webhooks->createSigningSecret(),
            ]);

        $webhook->forceFill([
            'event_type' => $this->webhookEventType,
            'target_url' => $this->webhookUrl,
            'status' => WebhookSubscriptionStatus::Active,
        ])->save();

        $this->editingWebhookId = null;
        $this->webhookUrl = '';
        $this->modal('webhook-form')->close();
        $this->dispatch('toast', type: 'success', message: __('Webhook saved'));
    }

    public function deleteWebhook(int $webhookId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->webhook($webhookId)->delete();
        $this->dispatch('toast', type: 'success', message: __('Webhook deleted'));
    }

    /**
     * @return Collection<int, OauthToken>
     */
    public function tokens(): Collection
    {
        return OauthToken::query()
            ->with('installation.app')
            ->whereHas('installation', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->latest('created_at')
            ->get();
    }

    /**
     * @return Collection<int, WebhookSubscription>
     */
    public function webhooks(): Collection
    {
        return WebhookSubscription::withoutGlobalScopes()
            ->withCount([
                'deliveries',
                'deliveries as failed_deliveries_count' => fn ($query) => $query->where('status', 'failed'),
            ])
            ->where('store_id', $this->storeId)
            ->orderBy('event_type')
            ->get();
    }

    public function render()
    {
        return view('livewire.admin.developers.index', [
            'tokens' => $this->tokens(),
            'webhooks' => $this->webhooks(),
            'eventTypes' => WebhookEventType::selectable(),
        ])->layout('layouts.app', [
            'title' => __('Developers'),
        ]);
    }

    private function token(int $tokenId): OauthToken
    {
        return OauthToken::query()
            ->whereHas('installation', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->findOrFail($tokenId);
    }

    private function webhook(int $webhookId): WebhookSubscription
    {
        return WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->findOrFail($webhookId);
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->findOrFail($this->storeId);
    }
}
<?php

namespace App\Enums;

enum WebhookEventType: string
{
    case OrderCreated = 'order.created';
    case OrderPaid = 'order.paid';
    case OrderUpdated = 'order.updated';
    case OrderCancelled = 'order.cancelled';
    case OrderFulfilled = 'order.fulfilled';
    case OrderRefunded = 'order.refunded';
    case ProductCreated = 'product.created';
    case ProductUpdated = 'product.updated';
    case ProductDeleted = 'product.deleted';
    case CustomerCreated = 'customer.created';
    case CheckoutCompleted = 'checkout.completed';
    case FulfillmentCreated = 'fulfillment.created';
    case RefundCreated = 'refund.created';

    public function label(): string
    {
        return match ($this) {
            self::OrderCreated => 'Order created',
            self::OrderPaid => 'Order paid',
            self::OrderUpdated => 'Order updated',
            self::OrderCancelled => 'Order cancelled',
            self::OrderFulfilled => 'Order fulfilled',
            self::OrderRefunded => 'Order refunded',
            self::ProductCreated => 'Product created',
            self::ProductUpdated => 'Product updated',
            self::ProductDeleted => 'Product deleted',
            self::CustomerCreated => 'Customer created',
            self::CheckoutCompleted => 'Checkout completed',
            self::FulfillmentCreated => 'Fulfillment created',
            self::RefundCreated => 'Refund created',
        };
    }

    /**
     * @return list<self>
     */
    public static function selectable(): array
    {
        return [
            self::OrderCreated,
            self::OrderUpdated,
            self::OrderCancelled,
            self::ProductCreated,
            self::ProductUpdated,
            self::ProductDeleted,
            self::CustomerCreated,
            self::CheckoutCompleted,
            self::FulfillmentCreated,
            self::RefundCreated,
        ];
    }
}


output:
Chunk ID: fce46f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 3418
Output:
<?php

namespace App\Services;

use App\Enums\AppInstallationStatus;
use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Jobs\DeliverWebhook;
use App\Models\App as AppModel;
use App\Models\AppInstallation;
use App\Models\OauthToken;
use App\Models\Store;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use Illuminate\Support\Str;
use InvalidArgumentException;

class WebhookService
{
    /**
     * @param  array<string, mixed>  $payload
     */
    public function dispatch(Store $store, string $eventType, array $payload): void
    {
        $event = WebhookEventType::tryFrom($eventType);

        if (! $event instanceof WebhookEventType) {
            throw new InvalidArgumentException("Unsupported webhook event type [{$eventType}].");
        }

        WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('event_type', $event->value)
            ->where('status', WebhookSubscriptionStatus::Active->value)
            ->get()
            ->each(function (WebhookSubscription $subscription) use ($store, $event, $payload): void {
                $delivery = WebhookDelivery::query()->create([
                    'subscription_id' => $subscription->getKey(),
                    'event_id' => (string) Str::uuid(),
                    'attempt_count' => 1,
                    'status' => WebhookDeliveryStatus::Pending,
                ]);

                DeliverWebhook::dispatch(
                    $delivery->getKey(),
                    $event->value,
                    $this->envelope($store, $event, $delivery->event_id, $payload),
                )->onConnection('database')->onQueue('webhooks');
            });
    }

    public function sign(string $payload, string $secret): string
    {
        return hash_hmac('sha256', $payload, $secret);
    }

    public function verify(string $payload, string $signature, string $secret): bool
    {
        return hash_equals($this->sign($payload, $secret), $signature);
    }

    /**
     * @param  list<string>  $abilities
     * @return array{token: OauthToken, plain_text: string}
     */
    public function createApiToken(Store $store, string $name, array $abilities): array
    {
        $app = AppModel::query()->firstOrCreate(
            ['name' => 'Admin API'],
            [
                'status' => 'active',
                'created_at' => now(),
            ],
        );

        $installation = AppInstallation::withoutGlobalScopes()->firstOrCreate(
            [
                'store_id' => $store->getKey(),
                'app_id' => $app->getKey(),
            ],
            [
                'scopes_json' => $abilities,
                'status' => AppInstallationStatus::Active,
                'installed_at' => now(),
            ],
        );

        $plainText = 'shop_'.Str::random(48);
        $token = OauthToken::query()->create([
            'installation_id' => $installation->getKey(),
            'name' => $name,
            'access_token_hash' => hash('sha256', $plainText),
            'refresh_token_hash' => null,
            'abilities_json' => $abilities,
            'expires_at' => now()->addYear(),
            'created_at' => now(),
        ]);

        return [
            'token' => $token,
            'plain_text' => $plainText,
        ];
    }

    public function createSigningSecret(): string
    {
        return 'whsec_'.Str::random(40);
    }

    public function recordSuccess(WebhookDelivery $delivery, int $attemptCount, int $responseCode, string $responseBody): void
    {
        $delivery->forceFill([
            'attempt_count' => $attemptCount,
            'status' => WebhookDeliveryStatus::Success,
            'last_attempt_at' => now(),
            'response_code' => $responseCode,
            'response_body_snippet' => Str::limit($responseBody, 1000, ''),
        ])->save();
    }

    public function recordFailure(WebhookDelivery $delivery, int $attemptCount, ?int $responseCode, ?string $responseBody): void
    {
        $delivery->forceFill([
            'attempt_count' => $attemptCount,
            'status' => WebhookDeliveryStatus::Failed,
            'last_attempt_at' => now(),
            'response_code' => $responseCode,
            'response_body_snippet' => Str::limit((string) $responseBody, 1000, ''),
        ])->save();

        $subscription = $delivery->subscription()->first();

        if ($subscription instanceof WebhookSubscription && $this->hasFiveConsecutiveFailures($subscription)) {
            $subscription->forceFill([
                'status' => WebhookSubscriptionStatus::Paused,
            ])->save();
        }
    }

    public function hasFiveConsecutiveFailures(WebhookSubscription $subscription): bool
    {
        $recentStatuses = $subscription->deliveries()
            ->latest('id')
            ->limit(5)
            ->pluck('status');

        return $recentStatuses->count() === 5
            && $recentStatuses->every(fn (WebhookDeliveryStatus|string $status): bool => $status === WebhookDeliveryStatus::Failed || $status === WebhookDeliveryStatus::Failed->value);
    }

    /**
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    private function envelope(Store $store, WebhookEventType $event, string $eventId, array $payload): array
    {
        return [
            'id' => $eventId,
            'api_version' => '2026-05',
            'event_type' => $event->value,
            'store_id' => $store->getKey(),
            'occurred_at' => now()->toISOString(),
            'data' => $payload,
        ];
    }
}
<?php

namespace App\Livewire\Admin\Developers;

use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Models\OauthToken;
use App\Models\Store;
use App\Models\WebhookSubscription;
use App\Services\WebhookService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $newTokenName = '';

    public ?string $generatedToken = null;

    public string $webhookEventType = 'order.created';

    public string $webhookUrl = '';

    public ?int $editingWebhookId = null;

    /**
     * @var list<string>
     */
    public array $tokenAbilities = [
        'read-products',
        'write-products',
        'read-orders',
        'write-orders',
        'read-customers',
        'write-customers',
        'read-analytics',
    ];

    public function mount(): void
    {
        $store = $this->store();

        $this->authorize('update', $store);
        $this->storeId = $store->getKey();
    }

    public function generateToken(WebhookService $webhooks): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->validate([
            'newTokenName' => ['required', 'string', 'max:255'],
        ], [], [
            'newTokenName' => 'token name',
        ]);

        $result = $webhooks->createApiToken($this->scopedStore(), $this->newTokenName, $this->tokenAbilities);

        $this->generatedToken = $result['plain_text'];
        $this->newTokenName = '';
        $this->modal('generate-token')->close();
        $this->dispatch('toast', type: 'success', message: __('API token generated'));
    }

    public function revokeToken(int $tokenId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->token($tokenId)->delete();

        session()->flash('status', __('API token revoked'));
        $this->dispatch('toast', type: 'success', message: __('API token revoked'));
    }

    public function openWebhookModal(?int $webhookId = null): void
    {
        $this->editingWebhookId = $webhookId;

        if ($webhookId !== null) {
            $webhook = $this->webhook($webhookId);
            $this->webhookEventType = $webhook->event_type->value;
            $this->webhookUrl = $webhook->target_url;
        } else {
            $this->webhookEventType = WebhookEventType::OrderCreated->value;
            $this->webhookUrl = '';
        }

        $this->modal('webhook-form')->show();
    }

    public function saveWebhook(WebhookService $webhooks): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->validate([
            'webhookEventType' => ['required', Rule::enum(WebhookEventType::class)],
            'webhookUrl' => ['required', 'url', 'max:2048'],
        ], [], [
            'webhookEventType' => 'event type',
            'webhookUrl' => 'endpoint URL',
        ]);

        $webhook = $this->editingWebhookId !== null
            ? $this->webhook($this->editingWebhookId)
            : new WebhookSubscription([
                'store_id' => $this->storeId,
                'signing_secret_encrypted' => $webhooks->createSigningSecret(),
            ]);

        $webhook->forceFill([
            'event_type' => $this->webhookEventType,
            'target_url' => $this->webhookUrl,
            'status' => WebhookSubscriptionStatus::Active,
        ])->save();

        $this->editingWebhookId = null;
        $this->webhookUrl = '';
        $this->modal('webhook-form')->close();
        $this->dispatch('toast', type: 'success', message: __('Webhook saved'));
    }

    public function deleteWebhook(int $webhookId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->webhook($webhookId)->delete();
        $this->dispatch('toast', type: 'success', message: __('Webhook deleted'));
    }

    /**
     * @return Collection<int, OauthToken>
     */
    public function tokens(): Collection
    {
        return OauthToken::query()
            ->with('installation.app')
            ->whereHas('installation', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->latest('created_at')
            ->get();
    }

    /**
     * @return Collection<int, WebhookSubscription>
     */
    public function webhooks(): Collection
    {
        return WebhookSubscription::withoutGlobalScopes()
            ->withCount([
                'deliveries',
                'deliveries as failed_deliveries_count' => fn ($query) => $query->where('status', 'failed'),
            ])
            ->where('store_id', $this->storeId)
            ->orderBy('event_type')
            ->get();
    }

    public function render()
    {
        return view('livewire.admin.developers.index', [
            'tokens' => $this->tokens(),
            'webhooks' => $this->webhooks(),
            'eventTypes' => WebhookEventType::selectable(),
        ])->layout('layouts.app', [
            'title' => __('Developers'),
        ]);
    }

    private function token(int $tokenId): OauthToken
    {
        return OauthToken::query()
            ->whereHas('installation', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->findOrFail($tokenId);
    }

    private function webhook(int $webhookId): WebhookSubscription
    {
        return WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->findOrFail($webhookId);
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->findOrFail($this->storeId);
    }
}
<?php

namespace App\Enums;

enum WebhookEventType: string
{
    case OrderCreated = 'order.created';
    case OrderPaid = 'order.paid';
    case OrderUpdated = 'order.updated';
    case OrderCancelled = 'order.cancelled';
    case OrderFulfilled = 'order.fulfilled';
    case OrderRefunded = 'order.refunded';
    case ProductCreated = 'product.created';
    case ProductUpdated = 'product.updated';
    case ProductDeleted = 'product.deleted';
    case CustomerCreated = 'customer.created';
    case CheckoutCompleted = 'checkout.completed';
    case FulfillmentCreated = 'fulfillment.created';
    case RefundCreated = 'refund.created';

    public function label(): string
    {
        return match ($this) {
            self::OrderCreated => 'Order created',
            self::OrderPaid => 'Order paid',
            self::OrderUpdated => 'Order updated',
            self::OrderCancelled => 'Order cancelled',
            self::OrderFulfilled => 'Order fulfilled',
            self::OrderRefunded => 'Order refunded',
            self::ProductCreated => 'Product created',
            self::ProductUpdated => 'Product updated',
            self::ProductDeleted => 'Product deleted',
            self::CustomerCreated => 'Customer created',
            self::CheckoutCompleted => 'Checkout completed',
            self::FulfillmentCreated => 'Fulfillment created',
            self::RefundCreated => 'Refund created',
        };
    }

    /**
     * @return list<self>
     */
    public static function selectable(): array
    {
        return [
            self::OrderCreated,
            self::OrderUpdated,
            self::OrderCancelled,
            self::ProductCreated,
            self::ProductUpdated,
            self::ProductDeleted,
            self::CustomerCreated,
            self::CheckoutCompleted,
            self::FulfillmentCreated,
            self::RefundCreated,
        ];
    }
}


output:
Chunk ID: 16fc3e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 3556
Output:
<?php

use App\Enums\MediaStatus;
use App\Enums\ProductStatus;
use App\Models\Collection as ProductCollection;
use App\Models\Customer;
use App\Models\CustomerAddress;
use App\Models\Order;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminCatalogApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminCatalogApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminCatalogApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Catalog integration', $abilities);
}

test('admin product api lists and shows store scoped products', function (): void {
    $store = adminCatalogApiStore();
    $product = Product::factory()
        ->withDefaultVariant(3299)
        ->create([
            'store_id' => $store->getKey(),
            'title' => 'Admin API Jacket',
            'handle' => 'admin-api-jacket',
            'vendor' => 'Catalog Test Vendor',
            'tags' => ['outerwear', 'api'],
        ]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();
    Product::factory()
        ->withDefaultVariant()
        ->create([
            'store_id' => Store::factory()->create()->getKey(),
            'title' => 'Other Store Jacket',
        ]);

    $this->getJson("/api/admin/v1/stores/{$store->getKey()}/products")
        ->assertUnauthorized();

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Admin API Jacket')
        ->assertJsonPath('data.0.variants_count', 1)
        ->assertJsonPath('data.0.total_inventory', 50)
        ->assertJsonMissing(['title' => 'Other Store Jacket']);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.title', 'Admin API Jacket')
        ->assertJsonPath('data.variants.0.price_amount', 3299)
        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50);
});

test('admin product api creates updates and archives products', function (): void {
    $store = adminCatalogApiStore();
    $collection = ProductCollection::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'API Summer',
        'handle' => 'api-summer',
    ]);
    $user = adminCatalogApiUser();

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
            'title' => 'API Cotton T-Shirt',
            'handle' => 'api-cotton-t-shirt',
            'description_html' => '
Soft cotton tee.
',
            'vendor' => 'API Apparel',
            'product_type' => 'Shirts',
            'status' => 'active',
            'tags' => ['organic', 'cotton'],
            'options' => [
                ['name' => 'Color', 'position' => 1],
                ['name' => 'Size', 'position' => 2],
            ],
            'variants' => [
                [
                    'sku' => 'API-TEE-BLU-S',
                    'price_amount' => 2500,
                    'compare_at_amount' => 3000,
                    'is_default' => true,
                    'position' => 1,
                    'option_values' => [
                        ['option_name' => 'Color', 'value' => 'Blue'],
                        ['option_name' => 'Size', 'value' => 'Small'],
                    ],
                    'inventory' => [
                        'quantity_on_hand' => 12,
                        'policy' => 'deny',
                    ],
                ],
                [
                    'sku' => 'API-TEE-BLU-M',
                    'price_amount' => 2600,
                    'is_default' => false,
                    'position' => 2,
                    'option_values' => [
                        ['option_name' => 'Color', 'value' => 'Blue'],
                        ['option_name' => 'Size', 'value' => 'Medium'],
                    ],
                    'inventory' => [
                        'quantity_on_hand' => 8,
                        'policy' => 'continue',
                    ],
                ],
            ],
            'collections' => [$collection->getKey()],
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'API Cotton T-Shirt')
        ->assertJsonPath('data.status', 'active')
        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 12)
        ->assertJsonPath('data.collections.0.handle', 'api-summer');

    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'api-cotton-t-shirt')
        ->firstOrFail();
    $defaultVariant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->where('is_default', true)
        ->firstOrFail();
    $removedVariant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->where('sku', 'API-TEE-BLU-M')
        ->firstOrFail();

    expect($product->status)->toBe(ProductStatus::Active)
        ->and($product->options()->count())->toBe(2)
        ->and($product->variants()->count())->toBe(2);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [
            'title' => 'API Cotton T-Shirt Updated',
            'tags' => ['organic', 'bestseller'],
            'variants' => [
                [
                    'id' => $defaultVariant->getKey(),
                    'sku' => 'API-TEE-BLU-S-UPDATED',
                    'price_amount' => 2700,
                    'is_default' => true,
                    'position' => 1,
                    'option_values' => [
                        ['option_name' => 'Color', 'value' => 'Blue'],
                        ['option_name' => 'Size', 'value' => 'Small'],
                    ],
                    'inventory' => [
                        'quantity_on_hand' => 15,
                    ],
                ],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.title', 'API Cotton T-Shirt Updated')
        ->assertJsonPath('data.variants.0.sku', 'API-TEE-BLU-S-UPDATED')
        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 15);

    $this->assertModelMissing($removedVariant);

    $this->actingAs($user)
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.status', 'archived');

    expect($product->refresh()->status)->toBe(ProductStatus::Archived);
});

test('admin product api presigns media uploads', function (): void {
    $store = adminCatalogApiStore();
    $product = Product::factory()->withDefaultVariant()->create([
        'store_id' => $store->getKey(),
        'title' => 'API Media Product',
    ]);

    $this->actingAs(adminCatalogApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}/media/presign-upload", [
            'filename' => 'front.jpg',
            'content_type' => 'image/jpeg',
            'byte_size' => 245000,
        ])
        ->assertCreated()
        ->assertJsonPath('method', 'PUT')
        ->assertJsonPath('headers.Content-Type', 'image/jpeg')
        ->assertJsonPath('storage_key', fn (string $storageKey): bool => str_starts_with($storageKey, "media/originals/{$product->getKey()}/"))
        ->assertJsonPath('upload_url', fn (string $uploadUrl): bool => $uploadUrl !== '');

    $media = ProductMedia::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();

<?php

use App\Models\Store;
use App\Models\StoreSettings;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminPlatformApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminPlatformApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminPlatformApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Platform integration', $abilities);
}

test('platform api creates organizations and stores for owner sessions', function (): void {
    $user = adminPlatformApiUser();

    $organizationResponse = $this->actingAs($user)
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Platform API Org',
            'billing_email' => 'billing@platform-api.test',
        ])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Platform API Org')
        ->assertJsonPath('data.billing_email', 'billing@platform-api.test');

    $organizationId = $organizationResponse->json('data.id');

    $this->actingAs($user)
        ->postJson('/api/admin/v1/platform/stores', [
            'organization_id' => $organizationId,
            'name' => 'Platform API Store',
            'handle' => 'platform-api-store',
            'default_currency' => 'EUR',
            'default_locale' => 'en',
            'timezone' => 'Europe/Berlin',
        ])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Platform API Store')
        ->assertJsonPath('data.handle', 'platform-api-store')
        ->assertJsonPath('data.status', 'active');

    $store = Store::query()->where('handle', 'platform-api-store')->firstOrFail();

    expect(StoreSettings::query()->whereKey($store->getKey())->exists())->toBeTrue()
        ->and(DB::table('store_users')
            ->where('store_id', $store->getKey())
            ->where('user_id', $user->getKey())
            ->where('role', 'owner')
            ->exists())->toBeTrue();
});

test('platform api rejects non owner sessions and tokens without manage platform ability', function (): void {
    $store = adminPlatformApiStore();
    $staff = User::factory()->create([
        'email' => 'platform-staff@example.test',
    ]);
    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $staff->getKey(),
        'role' => 'staff',
        'created_at' => now(),
    ]);
    $readToken = adminPlatformApiToken($store, ['read-products']);

    $this->actingAs($staff)
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Forbidden Org',
            'billing_email' => 'forbidden@example.test',
        ])
        ->assertForbidden();

    $this->withToken($readToken['plain_text'])
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Forbidden Token Org',
            'billing_email' => 'forbidden-token@example.test',
        ])
        ->assertForbidden();
});

test('platform api accepts manage platform bearer tokens', function (): void {
    $store = adminPlatformApiStore();
    $token = adminPlatformApiToken($store, ['manage-platform']);

    $this->withToken($token['plain_text'])
        ->postJson('/api/admin/v1/platform/organizations', [
            'name' => 'Token Platform Org',
            'billing_email' => 'token-platform@example.test',
        ])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Token Platform Org');

    expect($token['token']->refresh()->last_used_at)->not->toBeNull();
});

test('store me and invite endpoints expose membership and enforce role or ability', function (): void {
    $store = adminPlatformApiStore();
    $user = adminPlatformApiUser();
    $staff = User::factory()->create([
        'email' => 'store-staff@example.test',
    ]);
    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $staff->getKey(),
        'role' => 'staff',
        'created_at' => now(),
    ]);
    $readToken = adminPlatformApiToken($store, ['read-products']);
    $manageToken = adminPlatformApiToken($store, ['manage-platform']);

    $this->withToken($readToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'new-staff@example.test',
            'role' => 'staff',
        ])
        ->assertForbidden();

    $this->withToken($manageToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'new-staff@example.test',
            'role' => 'staff',
        ])
        ->assertCreated()
        ->assertJsonPath('data.email', 'new-staff@example.test')
        ->assertJsonPath('data.role', 'staff');

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/me")
        ->assertOk()
        ->assertJsonPath('data.email', 'admin@acme.test')
        ->assertJsonPath('data.role', 'owner')
        ->assertJsonPath('data.permissions.1', 'read-products');

    $this->actingAs($staff)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'new-staff@example.test',
            'role' => 'staff',
        ])
        ->assertForbidden();

    $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
            'email' => 'admin@acme.test',
            'role' => 'admin',
        ])
        ->assertStatus(409);
});