sed -n '1,240p' app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Auth\CustomerUserProvider;
use App\Contracts\PaymentProvider;
use App\Events\CheckoutCompleted;
use App\Events\FulfillmentCreated;
use App\Events\FulfillmentDelivered;
use App\Events\FulfillmentShipped;
use App\Events\OrderCancelled;
use App\Events\OrderCreated;
use App\Events\OrderPaid;
use App\Events\OrderRefunded;
use App\Events\ProductCreated;
use App\Events\ProductDeleted;
use App\Events\ProductUpdated;
use App\Http\Middleware\CheckStoreRole;
use App\Http\Middleware\EnsureUserEmailIsVerified;
use App\Http\Middleware\ResolveStore;
use App\Listeners\DispatchWebhooks;
use App\Models\Product;
use App\Models\Store;
use App\Observers\ProductObserver;
use App\Services\AnalyticsService;
use App\Services\NavigationService;
use App\Services\Payments\MockPaymentProvider;
use App\Services\SearchService;
use App\Services\ThemeSettingsService;
use App\Services\WebhookService;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View as ViewInstance;
use Livewire\Livewire;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(PaymentProvider::class, MockPaymentProvider::class);

        Auth::provider('store_scoped_eloquent', function ($app, array $config): CustomerUserProvider {
            return new CustomerUserProvider($app['hash'], $config['model']);
        });

        $this->app->singleton(ThemeSettingsService::class);
        $this->app->singleton(NavigationService::class);
        $this->app->singleton(SearchService::class);
        $this->app->singleton(AnalyticsService::class);
        $this->app->singleton(WebhookService::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
        $this->configureEventListeners();
        $this->configureLivewireMiddleware();
        $this->configureStorefrontViewData();
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null
        );

        RateLimiter::for('api.storefront', function (Request $request): Limit {
            return Limit::perMinute(120)->by($request->ip());
        });

        RateLimiter::for('checkout', function (Request $request): Limit {
            $sessionId = $request->hasSession() ? $request->session()->getId() : null;

            return Limit::perMinute(10)->by($sessionId ?: $request->ip());
        });

        RateLimiter::for('search', function (Request $request): Limit {
            return Limit::perMinute(30)->by($request->ip());
        });

        RateLimiter::for('analytics', function (Request $request): Limit {
            return Limit::perMinute(60)->by($request->ip());
        });

        RateLimiter::for('webhooks', function (Request $request): Limit {
            return Limit::perMinute(100)->by($request->ip());
        });

        Product::observe(ProductObserver::class);

        Authenticate::redirectUsing(function (Request $request): string {
            if ($request->is('admin*')) {
                return route('admin.login');
            }

            if ($request->is('account*')) {
                return route('account.login');
            }

            return route('login');
        });
    }

    protected function configureStorefrontViewData(): void
    {
        View::composer('layouts.storefront', function (ViewInstance $view): void {
            $store = app()->bound('current_store') ? app('current_store') : null;

            if (! $store instanceof Store) {
                $view->with([
                    'themeSettings' => [],
                    'mainNavigation' => [],
                    'footerNavigation' => [],
                ]);

                return;
            }

            $themeSettings = app(ThemeSettingsService::class)->forStore($store);
            $navigation = app(NavigationService::class);

            $view->with([
                'themeSettings' => $themeSettings,
                'mainNavigation' => $navigation->forHandle($store, data_get($themeSettings, 'header.main_menu', 'main-menu')),
                'footerNavigation' => $navigation->forHandle($store, data_get($themeSettings, 'footer.menu', 'footer-menu')),
            ]);
        });
    }

    protected function configureEventListeners(): void
    {
        foreach ([
            OrderCreated::class,
            OrderPaid::class,
            OrderCancelled::class,
            OrderRefunded::class,
            CheckoutCompleted::class,
            FulfillmentCreated::class,
            FulfillmentShipped::class,
            FulfillmentDelivered::class,
            ProductCreated::class,
            ProductUpdated::class,
            ProductDeleted::class,
        ] as $event) {
            Event::listen($event, DispatchWebhooks::class);
        }
    }

    protected function configureLivewireMiddleware(): void
    {
        Livewire::addPersistentMiddleware([
            EnsureUserEmailIsVerified::class,
            ResolveStore::class,
            CheckStoreRole::class,
        ]);
    }
}


Output:
Chunk ID: 393d45
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1509
Output:
<?php

namespace App\Providers;

use App\Auth\CustomerUserProvider;
use App\Contracts\PaymentProvider;
use App\Events\CheckoutCompleted;
use App\Events\FulfillmentCreated;
use App\Events\FulfillmentDelivered;
use App\Events\FulfillmentShipped;
use App\Events\OrderCancelled;
use App\Events\OrderCreated;
use App\Events\OrderPaid;
use App\Events\OrderRefunded;
use App\Events\ProductCreated;
use App\Events\ProductDeleted;
use App\Events\ProductUpdated;
use App\Http\Middleware\CheckStoreRole;
use App\Http\Middleware\EnsureUserEmailIsVerified;
use App\Http\Middleware\ResolveStore;
use App\Listeners\DispatchWebhooks;
use App\Models\Product;
use App\Models\Store;
use App\Observers\ProductObserver;
use App\Services\AnalyticsService;
use App\Services\NavigationService;
use App\Services\Payments\MockPaymentProvider;
use App\Services\SearchService;
use App\Services\ThemeSettingsService;
use App\Services\WebhookService;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View as ViewInstance;
use Livewire\Livewire;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(PaymentProvider::class, MockPaymentProvider::class);

        Auth::provider('store_scoped_eloquent', function ($app, array $config): CustomerUserProvider {
            return new CustomerUserProvider($app['hash'], $config['model']);
        });

        $this->app->singleton(ThemeSettingsService::class);
        $this->app->singleton(NavigationService::class);
        $this->app->singleton(SearchService::class);
        $this->app->singleton(AnalyticsService::class);
        $this->app->singleton(WebhookService::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
        $this->configureEventListeners();
        $this->configureLivewireMiddleware();
        $this->configureStorefrontViewData();
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null
        );

        RateLimiter::for('api.storefront', function (Request $request): Limit {
            return Limit::perMinute(120)->by($request->ip());
        });

        RateLimiter::for('checkout', function (Request $request): Limit {
            $sessionId = $request->hasSession() ? $request->session()->getId() : null;

            return Limit::perMinute(10)->by($sessionId ?: $request->ip());
        });

        RateLimiter::for('search', function (Request $request): Limit {
            return Limit::perMinute(30)->by($request->ip());
        });

        RateLimiter::for('analytics', function (Request $request): Limit {
            return Limit::perMinute(60)->by($request->ip());
        });

        RateLimiter::for('webhooks', function (Request $request): Limit {
            return Limit::perMinute(100)->by($request->ip());
        });

        Product::observe(ProductObserver::class);

        Authenticate::redirectUsing(function (Request $request): string {
            if ($request->is('admin*')) {
                return route('admin.login');
            }

            if ($request->is('account*')) {
                return route('account.login');
            }

            return route('login');
        });
    }

    protected function configureStorefrontViewData(): void
    {
        View::composer('layouts.storefront', function (ViewInstance $view): void {
            $store = app()->bound('current_store') ? app('current_store') : null;

            if (! $store instanceof Store) {
                $view->with([
                    'themeSettings' => [],
                    'mainNavigation' => [],
                    'footerNavigation' => [],
                ]);

                return;
            }

            $themeSettings = app(ThemeSettingsService::class)->forStore($store);
            $navigation = app(NavigationService::class);

            $view->with([
                'themeSettings' => $themeSettings,
                'mainNavigation' => $navigation->forHandle($store, data_get($themeSettings, 'header.main_menu', 'main-menu')),
                'footerNavigation' => $navigation->forHandle($store, data_get($themeSettings, 'footer.menu', 'footer-menu')),
            ]);
        });
    }

    protected function configureEventListeners(): void
    {
        foreach ([
            OrderCreated::class,
            OrderPaid::class,
            OrderCancelled::class,
            OrderRefunded::class,
            CheckoutCompleted::class,
            FulfillmentCreated::class,
            FulfillmentShipped::class,
            FulfillmentDelivered::class,
            ProductCreated::class,
            ProductUpdated::class,
            ProductDeleted::class,
        ] as $event) {
            Event::listen($event, DispatchWebhooks::class);
        }
    }

    protected function configureLivewireMiddleware(): void
    {
        Livewire::addPersistentMiddleware([
            EnsureUserEmailIsVerified::class,
            ResolveStore::class,
            CheckStoreRole::class,
        ]);
    }
}


 M app/Listeners/DispatchWebhooks.php
 M app/Observers/ProductObserver.php
 M app/Providers/AppServiceProvider.php
 M app/Services/OrderService.php
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T13-33-19-511Z.log
?? app/Actions/SanitizeHtml.php
?? app/Events/CheckoutCompleted.php
?? app/Events/ProductCreated.php
?? app/Events/ProductDeleted.php
?? app/Events/ProductUpdated.php
?? tests/Feature/Security/


Output:
Chunk ID: 3f07cf
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 128
Output:
 M app/Listeners/DispatchWebhooks.php
 M app/Observers/ProductObserver.php
 M app/Providers/AppServiceProvider.php
 M app/Services/OrderService.php
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T13-33-19-511Z.log
?? app/Actions/SanitizeHtml.php
?? app/Events/CheckoutCompleted.php
?? app/Events/ProductCreated.php
?? app/Events/ProductDeleted.php
?? app/Events/ProductUpdated.php
?? tests/Feature/Security/


git diff --stat && git diff -- app/Actions/SanitizeHtml.php app/Providers/AppServiceProvider.php app/Listeners/DispatchWebhooks.php app/Services/OrderService.php app/Observers/ProductObserver.php tests/Feature/Security


git diff --stat && git diff -- app/Actions/SanitizeHtml.php app/Providers/AppServiceProvider.php app/Listeners/DispatchWebhooks.php app/Services/OrderService.php app/Observers/ProductObserver.php tests/Feature/Security

 app/Listeners/DispatchWebhooks.php   | 46 +++++++++++++++++++++++++++++-------
 app/Livewire/Admin/Products/Form.php |  1 +
 app/Observers/ProductObserver.php    | 18 ++++++++++++++
 app/Providers/AppServiceProvider.php | 10 ++++++--
 app/Services/OrderService.php        |  3 +++
 5 files changed, 67 insertions(+), 11 deletions(-)
diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php
index dbd15164..0ba50e44 100644
--- a/app/Listeners/DispatchWebhooks.php
+++ b/app/Listeners/DispatchWebhooks.php
@@ -2,8 +2,8 @@

 namespace App\Listeners;

-use App\Enums\ProductStatus;
 use App\Enums\WebhookEventType;
+use App\Events\CheckoutCompleted;
 use App\Events\FulfillmentCreated;
 use App\Events\FulfillmentDelivered;
 use App\Events\FulfillmentShipped;
@@ -11,7 +11,10 @@
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
 use App\Events\OrderRefunded;
-use App\Events\ProductStatusChanged;
+use App\Events\ProductCreated;
+use App\Events\ProductDeleted;
+use App\Events\ProductUpdated;
+use App\Models\Checkout;
 use App\Models\Fulfillment;
 use App\Models\Order;
 use App\Models\Product;
@@ -32,10 +35,13 @@ public function handle(object $event): void
             $event instanceof OrderPaid => $this->dispatchOrder($event->order, WebhookEventType::OrderPaid),
             $event instanceof OrderCancelled => $this->dispatchOrder($event->order, WebhookEventType::OrderCancelled),
             $event instanceof OrderRefunded => $this->dispatchRefund($event),
+            $event instanceof CheckoutCompleted => $this->dispatchCheckout($event),
             $event instanceof FulfillmentCreated => $this->dispatchFulfillment($event->fulfillment, WebhookEventType::FulfillmentCreated),
             $event instanceof FulfillmentShipped,
             $event instanceof FulfillmentDelivered => $this->dispatchFulfillment($event->fulfillment, WebhookEventType::OrderFulfilled),
-            $event instanceof ProductStatusChanged => $this->dispatchProductStatusChange($event),
+            $event instanceof ProductCreated => $this->dispatchProduct($event->product, WebhookEventType::ProductCreated),
+            $event instanceof ProductUpdated => $this->dispatchProduct($event->product, WebhookEventType::ProductUpdated),
+            $event instanceof ProductDeleted => $this->dispatchProduct($event->product, WebhookEventType::ProductDeleted),
             default => null,
         };
     }
@@ -61,6 +67,14 @@ private function dispatchOrder(Order $order, WebhookEventType $eventType): void
         $this->webhooks->dispatch($this->store($order->store_id), $eventType->value, $this->orderPayload($order));
     }

+    private function dispatchCheckout(CheckoutCompleted $event): void
+    {
+        $payload = $this->checkoutPayload($event->checkout);
+        $payload['order'] = $this->orderPayload($event->order)['order'];
+
+        $this->webhooks->dispatch($this->store($event->checkout->store_id), WebhookEventType::CheckoutCompleted->value, $payload);
+    }
+
     private function dispatchFulfillment(Fulfillment $fulfillment, WebhookEventType $eventType): void
     {
         $order = $fulfillment->order()->withoutGlobalScopes()->firstOrFail();
@@ -77,13 +91,9 @@ private function dispatchFulfillment(Fulfillment $fulfillment, WebhookEventType
         $this->webhooks->dispatch($this->store($order->store_id), $eventType->value, $payload);
     }

-    private function dispatchProductStatusChange(ProductStatusChanged $event): void
+    private function dispatchProduct(Product $product, WebhookEventType $eventType): void
     {
-        $eventType = $event->to === ProductStatus::Archived
-            ? WebhookEventType::ProductDeleted
-            : WebhookEventType::ProductUpdated;
-
-        $this->webhooks->dispatch($this->store($event->product->store_id), $eventType->value, $this->productPayload($event->product));
+        $this->webhooks->dispatch($this->store($product->store_id), $eventType->value, $this->productPayload($product));
     }

     /**
@@ -105,6 +115,24 @@ private function orderPayload(Order $order): array
         ];
     }

+    /**
+     * @return array<string, mixed>
+     */
+    private function checkoutPayload(Checkout $checkout): array
+    {
+        return [
+            'checkout' => [
+                'id' => $checkout->getKey(),
+                'status' => $checkout->status?->value,
+                'payment_method' => $checkout->payment_method,
+                'email' => $checkout->email,
+                'currency' => data_get($checkout->totals_json, 'currency'),
+                'total_amount' => data_get($checkout->totals_json, 'total'),
+                'completed_at' => $checkout->updated_at?->toISOString(),
+            ],
+        ];
+    }
+
     /**
      * @return array<string, mixed>
      */
diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php
index 398597f8..5e9d4d75 100644
--- a/app/Observers/ProductObserver.php
+++ b/app/Observers/ProductObserver.php
@@ -2,6 +2,10 @@

 namespace App\Observers;

+use App\Enums\ProductStatus;
+use App\Events\ProductCreated;
+use App\Events\ProductDeleted;
+use App\Events\ProductUpdated;
 use App\Models\Product;
 use App\Services\SearchService;

@@ -10,20 +14,34 @@ class ProductObserver
     public function created(Product $product): void
     {
         app(SearchService::class)->syncProduct($product);
+
+        ProductCreated::dispatch($product);
     }

     public function updated(Product $product): void
     {
         app(SearchService::class)->syncProduct($product);
+
+        if ($product->status === ProductStatus::Archived && $product->wasChanged('status')) {
+            ProductDeleted::dispatch($product);
+
+            return;
+        }
+
+        ProductUpdated::dispatch($product);
     }

     public function deleted(Product $product): void
     {
         app(SearchService::class)->removeProduct($product->getKey());
+
+        ProductDeleted::dispatch($product);
     }

     public function forceDeleted(Product $product): void
     {
         app(SearchService::class)->removeProduct($product->getKey());
+
+        ProductDeleted::dispatch($product);
     }
 }
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 128d7a1e..ca6e12a1 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -4,6 +4,7 @@

 use App\Auth\CustomerUserProvider;
 use App\Contracts\PaymentProvider;
+use App\Events\CheckoutCompleted;
 use App\Events\FulfillmentCreated;
 use App\Events\FulfillmentDelivered;
 use App\Events\FulfillmentShipped;
@@ -11,7 +12,9 @@
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
 use App\Events\OrderRefunded;
-use App\Events\ProductStatusChanged;
+use App\Events\ProductCreated;
+use App\Events\ProductDeleted;
+use App\Events\ProductUpdated;
 use App\Http\Middleware\CheckStoreRole;
 use App\Http\Middleware\EnsureUserEmailIsVerified;
 use App\Http\Middleware\ResolveStore;
@@ -162,10 +165,13 @@ protected function configureEventListeners(): void
             OrderPaid::class,
             OrderCancelled::class,
             OrderRefunded::class,
+            CheckoutCompleted::class,
             FulfillmentCreated::class,
             FulfillmentShipped::class,
             FulfillmentDelivered::class,
-            ProductStatusChanged::class,
+            ProductCreated::class,
+            ProductUpdated::class,
+            ProductDeleted::class,
         ] as $event) {
             Event::listen($event, DispatchWebhooks::class);
         }
diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php
index 8d5f854f..b2feeaeb 100644
--- a/app/Services/OrderService.php
+++ b/app/Services/OrderService.php
@@ -9,6 +9,7 @@
 use App\Enums\OrderStatus;
 use App\Enums\PaymentMethod;
 use App\Enums\PaymentStatus;
+use App\Events\CheckoutCompleted;
 use App\Events\OrderCancelled;
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
@@ -129,8 +130,10 @@ public function createFromCheckout(Checkout $checkout, array $paymentMethodData
                 }

                 $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
+                $checkout = $checkout->refresh();

                 event(new OrderCreated($order));
+                event(new CheckoutCompleted($checkout, $order));

                 if ($paidImmediately) {
                     event(new OrderPaid($order));


Output:
Chunk ID: f8683c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2127
Output:
 app/Listeners/DispatchWebhooks.php   | 46 +++++++++++++++++++++++++++++-------
 app/Livewire/Admin/Products/Form.php |  1 +
 app/Observers/ProductObserver.php    | 18 ++++++++++++++
 app/Providers/AppServiceProvider.php | 10 ++++++--
 app/Services/OrderService.php        |  3 +++
 5 files changed, 67 insertions(+), 11 deletions(-)
diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php
index dbd15164..0ba50e44 100644
--- a/app/Listeners/DispatchWebhooks.php
+++ b/app/Listeners/DispatchWebhooks.php
@@ -2,8 +2,8 @@

 namespace App\Listeners;

-use App\Enums\ProductStatus;
 use App\Enums\WebhookEventType;
+use App\Events\CheckoutCompleted;
 use App\Events\FulfillmentCreated;
 use App\Events\FulfillmentDelivered;
 use App\Events\FulfillmentShipped;
@@ -11,7 +11,10 @@
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
 use App\Events\OrderRefunded;
-use App\Events\ProductStatusChanged;
+use App\Events\ProductCreated;
+use App\Events\ProductDeleted;
+use App\Events\ProductUpdated;
+use App\Models\Checkout;
 use App\Models\Fulfillment;
 use App\Models\Order;
 use App\Models\Product;
@@ -32,10 +35,13 @@ public function handle(object $event): void
             $event instanceof OrderPaid => $this->dispatchOrder($event->order, WebhookEventType::OrderPaid),
             $event instanceof OrderCancelled => $this->dispatchOrder($event->order, WebhookEventType::OrderCancelled),
             $event instanceof OrderRefunded => $this->dispatchRefund($event),
+            $event instanceof CheckoutCompleted => $this->dispatchCheckout($event),
             $event instanceof FulfillmentCreated => $this->dispatchFulfillment($event->fulfillment, WebhookEventType::FulfillmentCreated),
             $event instanceof FulfillmentShipped,
             $event instanceof FulfillmentDelivered => $this->dispatchFulfillment($event->fulfillment, WebhookEventType::OrderFulfilled),
-            $event instanceof ProductStatusChanged => $this->dispatchProductStatusChange($event),
+            $event instanceof ProductCreated => $this->dispatchProduct($event->product, WebhookEventType::ProductCreated),
+            $event instanceof ProductUpdated => $this->dispatchProduct($event->product, WebhookEventType::ProductUpdated),
+            $event instanceof ProductDeleted => $this->dispatchProduct($event->product, WebhookEventType::ProductDeleted),
             default => null,
         };
     }
@@ -61,6 +67,14 @@ private function dispatchOrder(Order $order, WebhookEventType $eventType): void
         $this->webhooks->dispatch($this->store($order->store_id), $eventType->value, $this->orderPayload($order));
     }

+    private function dispatchCheckout(CheckoutCompleted $event): void
+    {
+        $payload = $this->checkoutPayload($event->checkout);
+        $payload['order'] = $this->orderPayload($event->order)['order'];
+
+        $this->webhooks->dispatch($this->store($event->checkout->store_id), WebhookEventType::CheckoutCompleted->value, $payload);
+    }
+
     private function dispatchFulfillment(Fulfillment $fulfillment, WebhookEventType $eventType): void
     {
         $order = $fulfillment->order()->withoutGlobalScopes()->firstOrFail();
@@ -77,13 +91,9 @@ private function dispatchFulfillment(Fulfillment $fulfillment, WebhookEventType
         $this->webhooks->dispatch($this->store($order->store_id), $eventType->value, $payload);
     }

-    private function dispatchProductStatusChange(ProductStatusChanged $event): void
+    private function dispatchProduct(Product $product, WebhookEventType $eventType): void
     {
-        $eventType = $event->to === ProductStatus::Archived
-            ? WebhookEventType::ProductDeleted
-            : WebhookEventType::ProductUpdated;
-
-        $this->webhooks->dispatch($this->store($event->product->store_id), $eventType->value, $this->productPayload($event->product));
+        $this->webhooks->dispatch($this->store($product->store_id), $eventType->value, $this->productPayload($product));
     }

     /**
@@ -105,6 +115,24 @@ private function orderPayload(Order $order): array
         ];
     }

+    /**
+     * @return array<string, mixed>
+     */
+    private function checkoutPayload(Checkout $checkout): array
+    {
+        return [
+            'checkout' => [
+                'id' => $checkout->getKey(),
+                'status' => $checkout->status?->value,
+                'payment_method' => $checkout->payment_method,
+                'email' => $checkout->email,
+                'currency' => data_get($checkout->totals_json, 'currency'),
+                'total_amount' => data_get($checkout->totals_json, 'total'),
+                'completed_at' => $checkout->updated_at?->toISOString(),
+            ],
+        ];
+    }
+
     /**
      * @return array<string, mixed>
      */
diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php
index 398597f8..5e9d4d75 100644
--- a/app/Observers/ProductObserver.php
+++ b/app/Observers/ProductObserver.php
@@ -2,6 +2,10 @@

 namespace App\Observers;

+use App\Enums\ProductStatus;
+use App\Events\ProductCreated;
+use App\Events\ProductDeleted;
+use App\Events\ProductUpdated;
 use App\Models\Product;
 use App\Services\SearchService;

@@ -10,20 +14,34 @@ class ProductObserver
     public function created(Product $product): void
     {
         app(SearchService::class)->syncProduct($product);
+
+        ProductCreated::dispatch($product);
     }

     public function updated(Product $product): void
     {
         app(SearchService::class)->syncProduct($product);
+
+        if ($product->status === ProductStatus::Archived && $product->wasChanged('status')) {
+            ProductDeleted::dispatch($product);
+
+            return;
+        }
+
+        ProductUpdated::dispatch($product);
     }

     public function deleted(Product $product): void
     {
         app(SearchService::class)->removeProduct($product->getKey());
+
+        ProductDeleted::dispatch($product);
     }

     public function forceDeleted(Product $product): void
     {
         app(SearchService::class)->removeProduct($product->getKey());
+
+        ProductDeleted::dispatch($product);
     }
 }
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 128d7a1e..ca6e12a1 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -4,6 +4,7 @@

 use App\Auth\CustomerUserProvider;
 use App\Contracts\PaymentProvider;
+use App\Events\CheckoutCompleted;
 use App\Events\FulfillmentCreated;
 use App\Events\FulfillmentDelivered;
 use App\Events\FulfillmentShipped;
@@ -11,7 +12,9 @@
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
 use App\Events\OrderRefunded;
-use App\Events\ProductStatusChanged;
+use App\Events\ProductCreated;
+use App\Events\ProductDeleted;
+use App\Events\ProductUpdated;
 use App\Http\Middleware\CheckStoreRole;
 use App\Http\Middleware\EnsureUserEmailIsVerified;
 use App\Http\Middleware\ResolveStore;
@@ -162,10 +165,13 @@ protected function configureEventListeners(): void
             OrderPaid::class,
             OrderCancelled::class,
             OrderRefunded::class,
+            CheckoutCompleted::class,
             FulfillmentCreated::class,
             FulfillmentShipped::class,
             FulfillmentDelivered::class,
-            ProductStatusChanged::class,
+            ProductCreated::class,
+            ProductUpdated::class,
+            ProductDeleted::class,
         ] as $event) {
             Event::listen($event, DispatchWebhooks::class);
         }
diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php
index 8d5f854f..b2feeaeb 100644
--- a/app/Services/OrderService.php
+++ b/app/Services/OrderService.php
@@ -9,6 +9,7 @@
 use App\Enums\OrderStatus;
 use App\Enums\PaymentMethod;
 use App\Enums\PaymentStatus;
+use App\Events\CheckoutCompleted;
 use App\Events\OrderCancelled;
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
@@ -129,8 +130,10 @@ public function createFromCheckout(Checkout $checkout, array $paymentMethodData
                 }

                 $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
+                $checkout = $checkout->refresh();

                 event(new OrderCreated($order));
+                event(new CheckoutCompleted($checkout, $order));

                 if ($paidImmediately) {
                     event(new OrderPaid($order));


sed -n '1,260p' app/Actions/SanitizeHtml.php; find tests/Feature/Security -type f -maxdepth 2 -print -exec sed -n '1,260p' {} \; ; git diff -- app/Livewire/Admin/Products/Form.php


sed -n '1,260p' app/Actions/SanitizeHtml.php; find tests/Feature/Security -type f -maxdepth 2 -print -exec sed -n '1,260p' {} \; ; git diff -- app/Livewire/Admin/Products/Form.php

<?php

namespace App\Actions;

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;

class SanitizeHtml
{
    /**
     * @var array<string, list<string>>
     */
    private const array AllowedElements = [
        'p' => [],
        'br' => [],
        'strong' => [],
        'em' => [],
        'u' => [],
        'ol' => [],
        'ul' => [],
        'li' => [],
        'a' => ['href'],
        'img' => ['src', 'alt'],
        'h1' => [],
        'h2' => [],
        'h3' => [],
        'h4' => [],
        'h5' => [],
        'h6' => [],
        'blockquote' => [],
        'table' => [],
        'thead' => [],
        'tbody' => [],
        'tr' => [],
        'th' => [],
        'td' => [],
        'div' => [],
        'span' => [],
    ];

    /**
     * @var list<string>
     */
    private const array DangerousElements = [
        'base',
        'button',
        'canvas',
        'embed',
        'form',
        'iframe',
        'input',
        'link',
        'math',
        'meta',
        'object',
        'script',
        'select',
        'style',
        'svg',
        'textarea',
    ];

    /**
     * @var list<string>
     */
    private const array EmptyAllowedElements = ['br', 'img'];

    public function __invoke(?string $html): ?string
    {
        if ($html === null) {
            return null;
        }

        $html = trim($html);

        if ($html === '') {
            return '';
        }

        $document = new DOMDocument('1.0', 'UTF-8');
        $previous = libxml_use_internal_errors(true);

        try {
            $document->loadHTML($this->wrapHtml($html), LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
            $root = $this->rootElement($document);

            if (! $root instanceof DOMElement) {
                return '';
            }

            $this->sanitizeChildren($root);
            $this->removeEmptyElements($root);

            return trim($this->innerHtml($root));
        } finally {
            libxml_clear_errors();
            libxml_use_internal_errors($previous);
        }
    }

    private function wrapHtml(string $html): string
    {
        return '<!DOCTYPE html><meta charset="utf-8">
'.$html.'
';
    }

    private function rootElement(DOMDocument $document): ?DOMElement
    {
        $root = (new DOMXPath($document))->query('//*[@id="__sanitize_html_root"]')?->item(0);

        return $root instanceof DOMElement ? $root : null;
    }

    private function sanitizeChildren(DOMNode $parent): void
    {
        foreach ($this->childNodes($parent) as $child) {
            if ($child instanceof DOMElement) {
                $this->sanitizeElement($child);

                continue;
            }

            if ($child->nodeType !== XML_TEXT_NODE) {
                $parent->removeChild($child);
            }
        }
    }

    private function sanitizeElement(DOMElement $element): void
    {
        $tagName = strtolower($element->tagName);

        if (in_array($tagName, self::DangerousElements, true)) {
            $element->parentNode?->removeChild($element);

            return;
        }

        if (! array_key_exists($tagName, self::AllowedElements)) {
            $this->sanitizeChildren($element);
            $this->unwrapElement($element);

            return;
        }

        $this->sanitizeAttributes($element, self::AllowedElements[$tagName]);
        $this->sanitizeChildren($element);
    }

    /**
     * @param  list<string>  $allowedAttributes
     */
    private function sanitizeAttributes(DOMElement $element, array $allowedAttributes): void
    {
        foreach ($this->attributeNames($element) as $attributeName) {
            $normalizedName = strtolower($attributeName);

            if (! in_array($normalizedName, $allowedAttributes, true)) {
                $element->removeAttribute($attributeName);

                continue;
            }

            $value = trim($element->getAttribute($attributeName));

            if ($this->isUrlAttribute($normalizedName) && ! $this->isSafeUrl($normalizedName, $value)) {
                $element->removeAttribute($attributeName);

                continue;
            }

            $element->setAttribute($normalizedName, $value);
        }
    }

    private function isUrlAttribute(string $attributeName): bool
    {
        return in_array($attributeName, ['href', 'src'], true);
    }

    private function isSafeUrl(string $attributeName, string $value): bool
    {
        if ($value === '' || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
            return false;
        }

        $scheme = parse_url($value, PHP_URL_SCHEME);

        if ($scheme === null) {
            return true;
        }

        $allowedSchemes = $attributeName === 'href'
            ? ['http', 'https', 'mailto', 'tel']
            : ['http', 'https'];

        return in_array(strtolower($scheme), $allowedSchemes, true);
    }

    private function unwrapElement(DOMElement $element): void
    {
        $parent = $element->parentNode;

        if (! $parent instanceof DOMNode) {
            return;
        }

        while ($element->firstChild instanceof DOMNode) {
            $parent->insertBefore($element->firstChild, $element);
        }

        $parent->removeChild($element);
    }

    private function removeEmptyElements(DOMNode $parent): void
    {
        foreach ($this->childNodes($parent) as $child) {
            if (! $child instanceof DOMElement) {
                continue;
            }

            $this->removeEmptyElements($child);

            if ($this->isEmptyElement($child)) {
                $child->parentNode?->removeChild($child);
            }
        }
    }

    private function isEmptyElement(DOMElement $element): bool
    {
        $tagName = strtolower($element->tagName);

        if ($tagName === 'br') {
            return false;
        }

        if ($tagName === 'img') {
            return ! $element->hasAttribute('src');
        }

        foreach ($element->childNodes as $child) {
            if ($child instanceof DOMElement) {
                return false;
            }

            if ($child->nodeType === XML_TEXT_NODE && trim((string) $child->textContent) !== '') {
                return false;
            }
        }

        return true;
    }

    /**
     * @return list<DOMNode>
     */
    private function childNodes(DOMNode $node): array
    {
tests/Feature/Security/HtmlSanitizationTest.php
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});
diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php
index 838eded7..58ad99c2 100644
--- a/app/Livewire/Admin/Products/Form.php
+++ b/app/Livewire/Admin/Products/Form.php
@@ -2,6 +2,7 @@

 namespace App\Livewire\Admin\Products;

+use App\Actions\SanitizeHtml;
 use App\Enums\MediaStatus;
 use App\Enums\MediaType;
 use App\Enums\ProductStatus;
@@ -417,7 +418,7 @@ private function productPayload(): array
     {
         return [
             'title' => $this->title,
-            'description_html' => $this->descriptionHtml ?: null,
+            'description_html' => $this->sanitizeHtml($this->descriptionHtml),
             'status' => $this->status,
             'vendor' => $this->vendor ?: null,
             'product_type' => $this->productType ?: null,
@@ -430,6 +431,13 @@ private function productPayload(): array
         ];
     }

+    private function sanitizeHtml(?string $html): ?string
+    {
+        $sanitized = app(SanitizeHtml::class)($html);
+
+        return $sanitized === '' ? null : $sanitized;
+    }
+
     /**
      * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
      */


Output:
Chunk ID: a49d1c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1966
Output:
<?php

namespace App\Actions;

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;

class SanitizeHtml
{
    /**
     * @var array<string, list<string>>
     */
    private const array AllowedElements = [
        'p' => [],
        'br' => [],
        'strong' => [],
        'em' => [],
        'u' => [],
        'ol' => [],
        'ul' => [],
        'li' => [],
        'a' => ['href'],
        'img' => ['src', 'alt'],
        'h1' => [],
        'h2' => [],
        'h3' => [],
        'h4' => [],
        'h5' => [],
        'h6' => [],
        'blockquote' => [],
        'table' => [],
        'thead' => [],
        'tbody' => [],
        'tr' => [],
        'th' => [],
        'td' => [],
        'div' => [],
        'span' => [],
    ];

    /**
     * @var list<string>
     */
    private const array DangerousElements = [
        'base',
        'button',
        'canvas',
        'embed',
        'form',
        'iframe',
        'input',
        'link',
        'math',
        'meta',
        'object',
        'script',
        'select',
        'style',
        'svg',
        'textarea',
    ];

    /**
     * @var list<string>
     */
    private const array EmptyAllowedElements = ['br', 'img'];

    public function __invoke(?string $html): ?string
    {
        if ($html === null) {
            return null;
        }

        $html = trim($html);

        if ($html === '') {
            return '';
        }

        $document = new DOMDocument('1.0', 'UTF-8');
        $previous = libxml_use_internal_errors(true);

        try {
            $document->loadHTML($this->wrapHtml($html), LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
            $root = $this->rootElement($document);

            if (! $root instanceof DOMElement) {
                return '';
            }

            $this->sanitizeChildren($root);
            $this->removeEmptyElements($root);

            return trim($this->innerHtml($root));
        } finally {
            libxml_clear_errors();
            libxml_use_internal_errors($previous);
        }
    }

    private function wrapHtml(string $html): string
    {
        return '<!DOCTYPE html><meta charset="utf-8">
'.$html.'
';
    }

    private function rootElement(DOMDocument $document): ?DOMElement
    {
        $root = (new DOMXPath($document))->query('//*[@id="__sanitize_html_root"]')?->item(0);

        return $root instanceof DOMElement ? $root : null;
    }

    private function sanitizeChildren(DOMNode $parent): void
    {
        foreach ($this->childNodes($parent) as $child) {
            if ($child instanceof DOMElement) {
                $this->sanitizeElement($child);

                continue;
            }

            if ($child->nodeType !== XML_TEXT_NODE) {
                $parent->removeChild($child);
            }
        }
    }

    private function sanitizeElement(DOMElement $element): void
    {
        $tagName = strtolower($element->tagName);

        if (in_array($tagName, self::DangerousElements, true)) {
            $element->parentNode?->removeChild($element);

            return;
        }

        if (! array_key_exists($tagName, self::AllowedElements)) {
            $this->sanitizeChildren($element);
            $this->unwrapElement($element);

            return;
        }

        $this->sanitizeAttributes($element, self::AllowedElements[$tagName]);
        $this->sanitizeChildren($element);
    }

    /**
     * @param  list<string>  $allowedAttributes
     */
    private function sanitizeAttributes(DOMElement $element, array $allowedAttributes): void
    {
        foreach ($this->attributeNames($element) as $attributeName) {
            $normalizedName = strtolower($attributeName);

            if (! in_array($normalizedName, $allowedAttributes, true)) {
                $element->removeAttribute($attributeName);

                continue;
            }

            $value = trim($element->getAttribute($attributeName));

            if ($this->isUrlAttribute($normalizedName) && ! $this->isSafeUrl($normalizedName, $value)) {
                $element->removeAttribute($attributeName);

                continue;
            }

            $element->setAttribute($normalizedName, $value);
        }
    }

    private function isUrlAttribute(string $attributeName): bool
    {
        return in_array($attributeName, ['href', 'src'], true);
    }

    private function isSafeUrl(string $attributeName, string $value): bool
    {
        if ($value === '' || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
            return false;
        }

        $scheme = parse_url($value, PHP_URL_SCHEME);

        if ($scheme === null) {
            return true;
        }

        $allowedSchemes = $attributeName === 'href'
            ? ['http', 'https', 'mailto', 'tel']
            : ['http', 'https'];

        return in_array(strtolower($scheme), $allowedSchemes, true);
    }

    private function unwrapElement(DOMElement $element): void
    {
        $parent = $element->parentNode;

        if (! $parent instanceof DOMNode) {
            return;
        }

        while ($element->firstChild instanceof DOMNode) {
            $parent->insertBefore($element->firstChild, $element);
        }

        $parent->removeChild($element);
    }

    private function removeEmptyElements(DOMNode $parent): void
    {
        foreach ($this->childNodes($parent) as $child) {
            if (! $child instanceof DOMElement) {
                continue;
            }

            $this->removeEmptyElements($child);

            if ($this->isEmptyElement($child)) {
                $child->parentNode?->removeChild($child);
            }
        }
    }

    private function isEmptyElement(DOMElement $element): bool
    {
        $tagName = strtolower($element->tagName);

        if ($tagName === 'br') {
            return false;
        }

        if ($tagName === 'img') {
            return ! $element->hasAttribute('src');
        }

        foreach ($element->childNodes as $child) {
            if ($child instanceof DOMElement) {
                return false;
            }

            if ($child->nodeType === XML_TEXT_NODE && trim((string) $child->textContent) !== '') {
                return false;
            }
        }

        return true;
    }

    /**
     * @return list<DOMNode>
     */
    private function childNodes(DOMNode $node): array
    {
tests/Feature/Security/HtmlSanitizationTest.php
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});
diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php
index 838eded7..58ad99c2 100644
--- a/app/Livewire/Admin/Products/Form.php
+++ b/app/Livewire/Admin/Products/Form.php
@@ -2,6 +2,7 @@

 namespace App\Livewire\Admin\Products;

+use App\Actions\SanitizeHtml;
 use App\Enums\MediaStatus;
 use App\Enums\MediaType;
 use App\Enums\ProductStatus;
@@ -417,7 +418,7 @@ private function productPayload(): array
     {
         return [
             'title' => $this->title,
-            'description_html' => $this->descriptionHtml ?: null,
+            'description_html' => $this->sanitizeHtml($this->descriptionHtml),
             'status' => $this->status,
             'vendor' => $this->vendor ?: null,
             'product_type' => $this->productType ?: null,
@@ -430,6 +431,13 @@ private function productPayload(): array
         ];
     }

+    private function sanitizeHtml(?string $html): ?string
+    {
+        $sanitized = app(SanitizeHtml::class)($html);
+
+        return $sanitized === '' ? null : $sanitized;
+    }
+
     /**
      * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
      */


sed -n '260,520p' app/Actions/SanitizeHtml.php


sed -n '260,520p' app/Actions/SanitizeHtml.php

    {
        return iterator_to_array($node->childNodes);
    }

    /**
     * @return list<string>
     */
    private function attributeNames(DOMElement $element): array
    {
        return collect($element->attributes)
            ->map(fn ($attribute): string => $attribute->nodeName)
            ->all();
    }

    private function innerHtml(DOMElement $element): string
    {
        return collect($element->childNodes)
            ->map(fn (DOMNode $child): string => $element->ownerDocument?->saveHTML($child) ?: '')
            ->implode('');
    }
}


Output:
Chunk ID: 3efed7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 142
Output:
    {
        return iterator_to_array($node->childNodes);
    }

    /**
     * @return list<string>
     */
    private function attributeNames(DOMElement $element): array
    {
        return collect($element->attributes)
            ->map(fn ($attribute): string => $attribute->nodeName)
            ->all();
    }

    private function innerHtml(DOMElement $element): string
    {
        return collect($element->childNodes)
            ->map(fn (DOMNode $child): string => $element->ownerDocument?->saveHTML($child) ?: '')
            ->implode('');
    }
}


I’m editing the admin API auth now: adding a self-contained Sanctum-compatible guard backed by `personal_access_tokens`, switching admin API route middleware to `auth:sanctum`, and tightening `admin.api`/`platform.api` to enforce token abilities plus store roles instead of accepting any session member.


Input:
*** Begin Patch
*** Add File: app/Models/PersonalAccessToken.php
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+class PersonalAccessToken extends Model
+{
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'tokenable_type',
+        'tokenable_id',
+        'name',
+        'token',
+        'abilities',
+        'last_used_at',
+        'expires_at',
+    ];
+
+    /**
+     * @return MorphTo<Model, $this>
+     */
+    public function tokenable(): MorphTo
+    {
+        return $this->morphTo();
+    }
+
+    public function can(string $ability): bool
+    {
+        $abilities = $this->abilities ?? [];
+
+        return in_array('*', $abilities, true) || in_array($ability, $abilities, true);
+    }
+
+    public function isExpired(): bool
+    {
+        return $this->expires_at !== null && $this->expires_at->isPast();
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'abilities' => 'array',
+            'last_used_at' => 'datetime',
+            'expires_at' => 'datetime',
+        ];
+    }
+}
*** End Patch


Success. Updated the following files:
A app/Models/PersonalAccessToken.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class PersonalAccessToken extends Model
{
    /**
     * @var list<string>
     */
    protected $fillable = [
        'tokenable_type',
        'tokenable_id',
        'name',
        'token',
        'abilities',
        'last_used_at',
        'expires_at',
    ];

    /**
     * @return MorphTo<Model, $this>
     */
    public function tokenable(): MorphTo
    {
        return $this->morphTo();
    }

    public function can(string $ability): bool
    {
        $abilities = $this->abilities ?? [];

        return in_array('*', $abilities, true) || in_array($ability, $abilities, true);
    }

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'abilities' => 'array',
            'last_used_at' => 'datetime',
            'expires_at' => 'datetime',
        ];
    }
}


Output: {"output":"Success. Updated the following files:\nA app/Models/PersonalAccessToken.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: config/auth.php
@@
         'customer' => [
             'driver' => 'session',
             'provider' => 'customers',
         ],
+
+        'sanctum' => [
+            'driver' => 'sanctum-compatible',
+            'provider' => 'users',
+        ],
     ],
*** End Patch


Success. Updated the following files:
M config/auth.php


Output: {"output":"Success. Updated the following files:\nM config/auth.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Providers/AppServiceProvider.php
@@
 use App\Listeners\DispatchWebhooks;
+use App\Models\PersonalAccessToken;
 use App\Models\Product;
 use App\Models\Store;
+use App\Models\User;
@@
         Auth::provider('store_scoped_eloquent', function ($app, array $config): CustomerUserProvider {
             return new CustomerUserProvider($app['hash'], $config['model']);
         });
+
+        Auth::viaRequest('sanctum-compatible', function (Request $request): ?User {
+            app()->forgetInstance('sanctum_personal_access_token');
+
+            $plainTextToken = $request->bearerToken();
+
+            if (! is_string($plainTextToken) || $plainTextToken === '') {
+                return null;
+            }
+
+            $token = PersonalAccessToken::query()
+                ->with('tokenable')
+                ->where('token', hash('sha256', $this->plainTokenForHashing($plainTextToken)))
+                ->first();
+
+            if (! $token instanceof PersonalAccessToken || $token->isExpired() || ! $token->tokenable instanceof User) {
+                return null;
+            }
+
+            $request->attributes->set('sanctum_personal_access_token', $token);
+            app()->instance('sanctum_personal_access_token', $token);
+
+            return $token->tokenable;
+        });
@@
     protected function configureLivewireMiddleware(): void
     {
         Livewire::addPersistentMiddleware([
             EnsureUserEmailIsVerified::class,
             ResolveStore::class,
             CheckStoreRole::class,
         ]);
     }
+
+    private function plainTokenForHashing(string $plainTextToken): string
+    {
+        if (str_contains($plainTextToken, '|')) {
+            return (string) str($plainTextToken)->after('|');
+        }
+
+        return $plainTextToken;
+    }
 }
*** End Patch


Success. Updated the following files:
M app/Providers/AppServiceProvider.php

@@ -21,4 +21,6 @@
 use App\Listeners\DispatchWebhooks;
+use App\Models\PersonalAccessToken;
 use App\Models\Product;
 use App\Models\Store;
+use App\Models\User;
 use App\Observers\ProductObserver;
@@ -58,2 +60,26 @@

+        Auth::viaRequest('sanctum-compatible', function (Request $request): ?User {
+            app()->forgetInstance('sanctum_personal_access_token');
+
+            $plainTextToken = $request->bearerToken();
+
+            if (! is_string($plainTextToken) || $plainTextToken === '') {
+                return null;
+            }
+
+            $token = PersonalAccessToken::query()
+                ->with('tokenable')
+                ->where('token', hash('sha256', $this->plainTokenForHashing($plainTextToken)))
+                ->first();
+
+            if (! $token instanceof PersonalAccessToken || $token->isExpired() || ! $token->tokenable instanceof User) {
+                return null;
+            }
+
+            $request->attributes->set('sanctum_personal_access_token', $token);
+            app()->instance('sanctum_personal_access_token', $token);
+
+            return $token->tokenable;
+        });
+
         $this->app->singleton(ThemeSettingsService::class);
@@ -187,2 +213,11 @@
     }
+
+    private function plainTokenForHashing(string $plainTextToken): string
+    {
+        if (str_contains($plainTextToken, '|')) {
+            return (string) str($plainTextToken)->after('|');
+        }
+
+        return $plainTextToken;
+    }
 }


Output: {"output":"Success. Updated the following files:\nM app/Providers/AppServiceProvider.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Services/WebhookService.php
@@
 use App\Jobs\DeliverWebhook;
 use App\Models\App as AppModel;
 use App\Models\AppInstallation;
 use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
@@
-     * @return array{token: OauthToken, plain_text: string}
+     * @return array{token: PersonalAccessToken, plain_text: string}
      */
-    public function createApiToken(Store $store, string $name, array $abilities): array
+    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): 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(),
-            ],
-        );
-
+        $user ??= auth()->user() instanceof User
+            ? auth()->user()
+            : $store->users()
+                ->wherePivot('role', 'owner')
+                ->firstOrFail();
         $plainText = 'shop_'.Str::random(48);
-        $token = OauthToken::query()->create([
-            'installation_id' => $installation->getKey(),
+        $token = PersonalAccessToken::query()->create([
+            'tokenable_type' => $user->getMorphClass(),
+            'tokenable_id' => $user->getKey(),
             'name' => $name,
-            'access_token_hash' => hash('sha256', $plainText),
-            'refresh_token_hash' => null,
-            'abilities_json' => $abilities,
+            'token' => hash('sha256', $plainText),
+            'abilities' => $abilities,
             'expires_at' => now()->addYear(),
             'created_at' => now(),
         ]);
*** End Patch


Success. Updated the following files:
M app/Services/WebhookService.php

@@ -12,3 +12,5 @@
 use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
 use App\Models\WebhookDelivery;
@@ -64,33 +66,18 @@
      * @param  list<string>  $abilities
-     * @return array{token: OauthToken, plain_text: string}
+     * @return array{token: PersonalAccessToken, plain_text: string}
      */
-    public function createApiToken(Store $store, string $name, array $abilities): array
+    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): 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(),
-            ],
-        );
-
+        $user ??= auth()->user() instanceof User
+            ? auth()->user()
+            : $store->users()
+                ->wherePivot('role', 'owner')
+                ->firstOrFail();
         $plainText = 'shop_'.Str::random(48);
-        $token = OauthToken::query()->create([
-            'installation_id' => $installation->getKey(),
+        $token = PersonalAccessToken::query()->create([
+            'tokenable_type' => $user->getMorphClass(),
+            'tokenable_id' => $user->getKey(),
             'name' => $name,
-            'access_token_hash' => hash('sha256', $plainText),
-            'refresh_token_hash' => null,
-            'abilities_json' => $abilities,
+            'token' => hash('sha256', $plainText),
+            'abilities' => $abilities,
             'expires_at' => now()->addYear(),


Output: {"output":"Success. Updated the following files:\nM app/Services/WebhookService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Services/WebhookService.php
@@
-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\PersonalAccessToken;
*** End Patch


@@ -4,3 +4,2 @@

-use App\Enums\AppInstallationStatus;
 use App\Enums\WebhookDeliveryStatus;
@@ -9,5 +8,2 @@
 use App\Jobs\DeliverWebhook;
-use App\Models\App as AppModel;
-use App\Models\AppInstallation;
-use App\Models\OauthToken;
 use App\Models\PersonalAccessToken;


Output: {"output":"Success. Updated the following files:\nM app/Services/WebhookService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Developers/Index.php
@@
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
@@
-        $result = $webhooks->createApiToken($this->scopedStore(), $this->newTokenName, $this->tokenAbilities);
+        $result = $webhooks->createApiToken($this->scopedStore(), $this->newTokenName, $this->tokenAbilities, auth()->user());
@@
-     * @return Collection<int, OauthToken>
+     * @return Collection<int, PersonalAccessToken>
      */
     public function tokens(): Collection
     {
-        return OauthToken::query()
-            ->with('installation.app')
-            ->whereHas('installation', function ($query): void {
-                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
-            })
+        return PersonalAccessToken::query()
+            ->where('tokenable_type', (new \App\Models\User)->getMorphClass())
+            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
             ->latest('created_at')
             ->get();
@@
-    private function token(int $tokenId): OauthToken
+    private function token(int $tokenId): PersonalAccessToken
     {
-        return OauthToken::query()
-            ->whereHas('installation', function ($query): void {
-                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
-            })
+        return PersonalAccessToken::query()
+            ->where('tokenable_type', (new \App\Models\User)->getMorphClass())
+            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
             ->findOrFail($tokenId);
     }
*** End Patch


Success. Updated the following files:
M app/Livewire/Admin/Developers/Index.php

@@ -6,3 +6,3 @@
 use App\Enums\WebhookSubscriptionStatus;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
@@ -64,3 +64,3 @@

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

@@ -138,3 +138,3 @@
     /**
-     * @return Collection<int, OauthToken>
+     * @return Collection<int, PersonalAccessToken>
      */
@@ -142,7 +142,5 @@
     {
-        return OauthToken::query()
-            ->with('installation.app')
-            ->whereHas('installation', function ($query): void {
-                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
-            })
+        return PersonalAccessToken::query()
+            ->where('tokenable_type', (new \App\Models\User)->getMorphClass())
+            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
             ->latest('created_at')
@@ -177,8 +175,7 @@

-    private function token(int $tokenId): OauthToken
+    private function token(int $tokenId): PersonalAccessToken
     {
-        return OauthToken::query()
-            ->whereHas('installation', function ($query): void {
-                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
-            })
+        return PersonalAccessToken::query()
+            ->where('tokenable_type', (new \App\Models\User)->getMorphClass())
+            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
             ->findOrFail($tokenId);


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Developers/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: resources/views/livewire/admin/developers/index.blade.php
@@
-                        App
+                        Type
@@
-                            {{ $token->installation->app->name }}
+                            Admin API
*** End Patch


Success. Updated the following files:
M resources/views/livewire/admin/developers/index.blade.php

@@ -40,3 +40,3 @@
                         Name
-                        App
+                        Type
                         Last used
@@ -50,3 +50,3 @@
                             {{ $token->name ?? 'API token' }}
-                            {{ $token->installation->app->name }}
+                            Admin API
                             {{ $token->last_used_at?->diffForHumans() ?? 'Never' }}


Output: {"output":"Success. Updated the following files:\nM resources/views/livewire/admin/developers/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Http/Middleware/AuthenticateAdminApi.php
@@
-use App\Enums\AppInstallationStatus;
-use App\Models\OauthToken;
+use App\Enums\StoreUserRole;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
@@
-        app()->forgetInstance('admin_api_oauth_token');
         app()->instance('current_store', $store);

-        if ($request->user() !== null) {
-            if ($request->user()->stores()->whereKey($store->getKey())->exists()) {
-                return $next($request);
-            }
-
-            return response()->json(['message' => 'Forbidden.'], 403);
-        }
-
-        $plainTextToken = $request->bearerToken();
-
-        if (! is_string($plainTextToken) || $plainTextToken === '') {
+        $user = $request->user();
+        $token = $request->attributes->get('sanctum_personal_access_token');
+
+        if (! $user instanceof User || ! $token instanceof PersonalAccessToken) {
             return response()->json(['message' => 'Unauthenticated.'], 401);
         }

-        $token = OauthToken::query()
-            ->with('installation')
-            ->where('access_token_hash', hash('sha256', $plainTextToken))
-            ->first();
-
-        if (! $token instanceof OauthToken || $token->isExpired()) {
-            return response()->json(['message' => 'Unauthenticated.'], 401);
-        }
-
-        $installation = $token->installation;
-
-        if ($installation === null
-            || $installation->status !== AppInstallationStatus::Active
-            || (int) $installation->store_id !== $store->getKey()) {
+        if (! $this->userCanAccessStore($user, $store, $abilities)) {
             return response()->json(['message' => 'Forbidden.'], 403);
         }

@@
         $token->forceFill(['last_used_at' => now()])->save();

-        $request->attributes->set('admin_api_oauth_token', $token);
-        app()->instance('admin_api_oauth_token', $token);
-
         return $next($request);
@@
-    private function hasAbilities(OauthToken $token, array $abilities): bool
+    private function hasAbilities(PersonalAccessToken $token, array $abilities): bool
     {
         if ($abilities === []) {
             return true;
         }

-        $tokenAbilities = $token->abilities_json ?? [];
-
-        if (in_array('*', $tokenAbilities, true)) {
-            return true;
-        }
-
         foreach ($abilities as $ability) {
-            if (! in_array($ability, $tokenAbilities, true)) {
+            if (! $token->can($ability)) {
                 return false;
             }
         }

         return true;
     }
+
+    /**
+     * @param  list<string>  $abilities
+     */
+    private function userCanAccessStore(User $user, Store $store, array $abilities): bool
+    {
+        $role = $user->roleForStore($store);
+
+        if (! $role instanceof StoreUserRole) {
+            return false;
+        }
+
+        if ($abilities === []) {
+            return true;
+        }
+
+        foreach ($abilities as $ability) {
+            if (! $this->roleAllowsAbility($role, $ability)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    private function roleAllowsAbility(StoreUserRole $role, string $ability): bool
+    {
+        return match ($ability) {
+            'read-products',
+            'read-orders',
+            'read-customers',
+            'read-collections',
+            'read-discounts' => true,
+            'write-products',
+            'write-collections',
+            'write-discounts',
+            'write-content' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'write-orders' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'read-settings',
+            'write-settings',
+            'write-themes',
+            'read-analytics',
+            'manage-platform' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true),
+            default => false,
+        };
+    }
 }
*** End Patch


Success. Updated the following files:
M app/Http/Middleware/AuthenticateAdminApi.php

@@ -4,5 +4,6 @@

-use App\Enums\AppInstallationStatus;
-use App\Models\OauthToken;
+use App\Enums\StoreUserRole;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
 use Closure;
@@ -22,25 +23,8 @@

-        app()->forgetInstance('admin_api_oauth_token');
         app()->instance('current_store', $store);
-
-        if ($request->user() !== null) {
-            if ($request->user()->stores()->whereKey($store->getKey())->exists()) {
-                return $next($request);
-            }

-            return response()->json(['message' => 'Forbidden.'], 403);
-        }
-
-        $plainTextToken = $request->bearerToken();
-
-        if (! is_string($plainTextToken) || $plainTextToken === '') {
-            return response()->json(['message' => 'Unauthenticated.'], 401);
-        }
-
-        $token = OauthToken::query()
-            ->with('installation')
-            ->where('access_token_hash', hash('sha256', $plainTextToken))
-            ->first();
+        $user = $request->user();
+        $token = $request->attributes->get('sanctum_personal_access_token');

-        if (! $token instanceof OauthToken || $token->isExpired()) {
+        if (! $user instanceof User || ! $token instanceof PersonalAccessToken) {
             return response()->json(['message' => 'Unauthenticated.'], 401);
@@ -48,7 +32,3 @@

-        $installation = $token->installation;
-
-        if ($installation === null
-            || $installation->status !== AppInstallationStatus::Active
-            || (int) $installation->store_id !== $store->getKey()) {
+        if (! $this->userCanAccessStore($user, $store, $abilities)) {
             return response()->json(['message' => 'Forbidden.'], 403);
@@ -62,5 +42,2 @@

-        $request->attributes->set('admin_api_oauth_token', $token);
-        app()->instance('admin_api_oauth_token', $token);
-
         return $next($request);
@@ -82,3 +59,3 @@
      */
-    private function hasAbilities(OauthToken $token, array $abilities): bool
+    private function hasAbilities(PersonalAccessToken $token, array $abilities): bool
     {
@@ -88,5 +65,23 @@

-        $tokenAbilities = $token->abilities_json ?? [];
+        foreach ($abilities as $ability) {
+            if (! $token->can($ability)) {
+                return false;
+            }
+        }
+
+        return true;
+    }

-        if (in_array('*', $tokenAbilities, true)) {
+    /**
+     * @param  list<string>  $abilities
+     */
+    private function userCanAccessStore(User $user, Store $store, array $abilities): bool
+    {
+        $role = $user->roleForStore($store);
+
+        if (! $role instanceof StoreUserRole) {
+            return false;
+        }
+
+        if ($abilities === []) {
             return true;
@@ -95,3 +90,3 @@
         foreach ($abilities as $ability) {
-            if (! in_array($ability, $tokenAbilities, true)) {
+            if (! $this->roleAllowsAbility($role, $ability)) {
                 return false;
@@ -102,2 +97,24 @@
     }
+
+    private function roleAllowsAbility(StoreUserRole $role, string $ability): bool
+    {
+        return match ($ability) {
+            'read-products',
+            'read-orders',
+            'read-customers',
+            'read-collections',
+            'read-discounts' => true,
+            'write-products',
+            'write-collections',
+            'write-discounts',
+            'write-content' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'write-orders' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'read-settings',
+            'write-settings',
+            'write-themes',
+            'read-analytics',
+            'manage-platform' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true),
+            default => false,
+        };
+    }
 }


Output: {"output":"Success. Updated the following files:\nM app/Http/Middleware/AuthenticateAdminApi.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Http/Middleware/AuthenticatePlatformApi.php
@@
-use App\Enums\AppInstallationStatus;
 use App\Enums\StoreUserRole;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\User;
@@
-        app()->forgetInstance('admin_api_oauth_token');
-
         $user = $request->user();
+        $token = $request->attributes->get('sanctum_personal_access_token');

-        if ($user instanceof User) {
-            if ($this->userCanManagePlatform($user)) {
-                return $next($request);
-            }
-
-            return response()->json(['message' => 'Forbidden.'], 403);
-        }
-
-        $plainTextToken = $request->bearerToken();
-
-        if (! is_string($plainTextToken) || $plainTextToken === '') {
+        if (! $user instanceof User || ! $token instanceof PersonalAccessToken) {
             return response()->json(['message' => 'Unauthenticated.'], 401);
         }

-        $token = OauthToken::query()
-            ->with('installation')
-            ->where('access_token_hash', hash('sha256', $plainTextToken))
-            ->first();
-
-        if (! $token instanceof OauthToken || $token->isExpired()) {
-            return response()->json(['message' => 'Unauthenticated.'], 401);
-        }
-
-        $installation = $token->installation;
-
-        if ($installation === null || $installation->status !== AppInstallationStatus::Active) {
+        if (! $this->userCanManagePlatform($user) || ! $token->can('manage-platform')) {
             return response()->json(['message' => 'Forbidden.'], 403);
         }

-        if (! $this->hasManagePlatformAbility($token)) {
-            return response()->json(['message' => 'Forbidden.'], 403);
-        }
-
         $token->forceFill(['last_used_at' => now()])->save();

-        $request->attributes->set('admin_api_oauth_token', $token);
-        app()->instance('admin_api_oauth_token', $token);
-
         return $next($request);
@@
-    private function hasManagePlatformAbility(OauthToken $token): bool
-    {
-        $abilities = $token->abilities_json ?? [];
-
-        return in_array('*', $abilities, true) || in_array('manage-platform', $abilities, true);
-    }
 }
*** End Patch


Success. Updated the following files:
M app/Http/Middleware/AuthenticatePlatformApi.php

@@ -4,5 +4,4 @@

-use App\Enums\AppInstallationStatus;
 use App\Enums\StoreUserRole;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\User;
@@ -21,17 +20,6 @@
     {
-        app()->forgetInstance('admin_api_oauth_token');
-
         $user = $request->user();
+        $token = $request->attributes->get('sanctum_personal_access_token');

-        if ($user instanceof User) {
-            if ($this->userCanManagePlatform($user)) {
-                return $next($request);
-            }
-
-            return response()->json(['message' => 'Forbidden.'], 403);
-        }
-
-        $plainTextToken = $request->bearerToken();
-
-        if (! is_string($plainTextToken) || $plainTextToken === '') {
+        if (! $user instanceof User || ! $token instanceof PersonalAccessToken) {
             return response()->json(['message' => 'Unauthenticated.'], 401);
@@ -39,18 +27,3 @@

-        $token = OauthToken::query()
-            ->with('installation')
-            ->where('access_token_hash', hash('sha256', $plainTextToken))
-            ->first();
-
-        if (! $token instanceof OauthToken || $token->isExpired()) {
-            return response()->json(['message' => 'Unauthenticated.'], 401);
-        }
-
-        $installation = $token->installation;
-
-        if ($installation === null || $installation->status !== AppInstallationStatus::Active) {
-            return response()->json(['message' => 'Forbidden.'], 403);
-        }
-
-        if (! $this->hasManagePlatformAbility($token)) {
+        if (! $this->userCanManagePlatform($user) || ! $token->can('manage-platform')) {
             return response()->json(['message' => 'Forbidden.'], 403);
@@ -59,5 +32,2 @@
         $token->forceFill(['last_used_at' => now()])->save();
-
-        $request->attributes->set('admin_api_oauth_token', $token);
-        app()->instance('admin_api_oauth_token', $token);

@@ -73,8 +43,2 @@

-    private function hasManagePlatformAbility(OauthToken $token): bool
-    {
-        $abilities = $token->abilities_json ?? [];
-
-        return in_array('*', $abilities, true) || in_array('manage-platform', $abilities, true);
-    }
 }


Output: {"output":"Success. Updated the following files:\nM app/Http/Middleware/AuthenticatePlatformApi.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: routes/api.php
@@
 Route::middleware('throttle:60,1')
     ->prefix('admin/v1/platform')
     ->name('api.admin.v1.platform.')
-    ->middleware('platform.api')
+    ->middleware(['auth:sanctum', 'platform.api'])
@@
 Route::middleware('throttle:60,1')
     ->prefix('admin/v1/stores/{store}')
     ->name('api.admin.v1.')
+    ->middleware('auth:sanctum')
     ->group(function (): void {
*** End Patch


Success. Updated the following files:
M routes/api.php

@@ -69,3 +69,3 @@
     ->name('api.admin.v1.platform.')
-    ->middleware('platform.api')
+    ->middleware(['auth:sanctum', 'platform.api'])
     ->group(function (): void {
@@ -78,2 +78,3 @@
     ->name('api.admin.v1.')
+    ->middleware('auth:sanctum')
     ->group(function (): void {


Output: {"output":"Success. Updated the following files:\nM routes/api.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


<subagent_notification>
{"agent_path":"019df445-7588-7653-b9cb-36a06e316e53","status":{"completed":"Implemented missing webhook/domain event coverage.\n\nChanged task files:\n- `app/Events/CheckoutCompleted.php`\n- `app/Events/ProductCreated.php`\n- `app/Events/ProductUpdated.php`\n- `app/Events/ProductDeleted.php`\n- `app/Listeners/DispatchWebhooks.php`\n- `app/Providers/AppServiceProvider.php`\n- `app/Observers/ProductObserver.php`\n- `app/Services/OrderService.php`\n- `tests/Feature/Webhooks/WebhookDeliveryTest.php`\n\nWhat changed:\n- Added domain events for `checkout.completed`, `product.created`, `product.updated`, and `product.deleted`.\n- Registered those events with `DispatchWebhooks`.\n- Dispatch checkout completion after order creation.\n- Dispatch product lifecycle events from the product observer, treating archive as `product.deleted`.\n- Added Pest coverage proving service/domain operations create webhook delivery logs and queue `DeliverWebhook`.\n\nVerification:\n- `php artisan test --compact tests/Feature/Webhooks tests/Feature/Catalog/ProductServiceTest.php tests/Feature/Checkout/CheckoutServiceTest.php`  \n  Result: `21 passed (70 assertions)`\n- `vendor/bin/pint --dirty --format agent`  \n  Result: `pass`\n\nI did not edit `specs/progress.md` or commit. There are unrelated concurrent dirty/untracked files in the worktree, left in place."}}
</subagent_notification>


The webhook worker completed and verified its slice. I’ve started the Sanctum-compatible admin API work locally and will now update tests to the new token contract, then continue through the remaining blockers.


sed -n '220,520p' tests/Feature/Api/AdminCatalogApiTest.php && sed -n '1,260p' tests/Feature/Admin/OrderManagementTest.php && sed -n '1,260p' tests/Feature/Admin/ContentManagementTest.php && sed -n '1,240p' tests/Feature/Admin/ProductMediaManagementTest.php


sed -n '220,520p' tests/Feature/Api/AdminCatalogApiTest.php && sed -n '1,260p' tests/Feature/Admin/OrderManagementTest.php && sed -n '1,260p' tests/Feature/Admin/ContentManagementTest.php && sed -n '1,240p' tests/Feature/Admin/ProductMediaManagementTest.php

ProductMediaManagementTest.php

    expect($media->status)->toBe(MediaStatus::Processing)
        ->and($media->mime_type)->toBe('image/jpeg')
        ->and($media->byte_size)->toBe(245000)
        ->and($media->position)->toBe(0);
});

test('admin customer api lists and shows store scoped customers', function (): void {
    $store = adminCatalogApiStore();
    $customer = Customer::factory()->create([
        'store_id' => $store->getKey(),
        'email' => 'customer-api@example.test',
        'name' => 'Customer API',
        'marketing_opt_in' => true,
    ]);
    CustomerAddress::factory()->default()->create(['customer_id' => $customer->getKey()]);
    Order::factory()->paid()->forCustomer($customer)->create([
        'store_id' => $store->getKey(),
        'order_number' => '#CA-1001',
        'total_amount' => 4400,
    ]);
    Customer::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'email' => 'other-customer@example.test',
    ]);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=customer-api")
        ->assertOk()
        ->assertJsonPath('data.0.email', 'customer-api@example.test')
        ->assertJsonPath('data.0.orders_count', 1)
        ->assertJsonPath('data.0.total_spent_amount', 4400)
        ->assertJsonMissing(['email' => 'other-customer@example.test']);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$customer->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.email', 'customer-api@example.test')
        ->assertJsonPath('data.addresses.0.is_default', true)
        ->assertJsonPath('data.orders.0.order_number', '#CA-1001');
});

test('admin catalog api accepts scoped tokens and enforces abilities', function (): void {
    $store = adminCatalogApiStore();
    Product::factory()->withDefaultVariant()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Visible Product',
    ]);
    Customer::factory()->create([
        'store_id' => $store->getKey(),
        'email' => 'token-customer@example.test',
    ]);
    $productToken = adminCatalogApiToken($store, ['read-products']);
    $writeProductToken = adminCatalogApiToken($store, ['write-products']);
    $customerToken = adminCatalogApiToken($store, ['read-customers']);

    $this->withToken($productToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query=Token Visible")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Visible Product');

    $this->withToken($productToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers")
        ->assertForbidden();

    $this->withToken($productToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
            'title' => 'Token Created Product',
            'variants' => [
                [
                    'sku' => 'TOKEN-CREATED-001',
                    'price_amount' => 1999,
                    'is_default' => true,
                ],
            ],
        ])
        ->assertForbidden();

    $this->withToken($writeProductToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
            'title' => 'Token Created Product',
            'variants' => [
                [
                    'sku' => 'TOKEN-CREATED-001',
                    'price_amount' => 1999,
                    'is_default' => true,
                ],
            ],
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'Token Created Product');

    $this->withToken($customerToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=token-customer")
        ->assertOk()
        ->assertJsonPath('data.0.email', 'token-customer@example.test');

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

test('admin catalog api rejects resources outside the requested store', function (): void {
    $store = adminCatalogApiStore();
    $otherStore = Store::factory()->create();
    $otherProduct = Product::factory()->withDefaultVariant()->create(['store_id' => $otherStore->getKey()]);
    $otherCustomer = Customer::factory()->create(['store_id' => $otherStore->getKey()]);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$otherProduct->getKey()}")
        ->assertNotFound();

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$otherCustomer->getKey()}")
        ->assertNotFound();
});

test('deferred oauth and app ecosystem routes return not implemented', function (): void {
    $store = adminCatalogApiStore();

    $th…18877 chars truncated…;
    $user->stores()->attach($store->getKey(), [
        'role' => StoreUserRole::Admin->value,
    ]);

    return $user;
}

test('admin product form uploads images and queues media processing', function (): void {
    Storage::fake('public');
    Queue::fake([ProcessMediaUpload::class]);

    $store = Store::factory()->create();
    $user = productMediaAdminUser($store);
    $product = Product::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Media Test Product',
    ]);

    app()->instance('current_store', $store);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product])
        ->set('newMedia', [
            UploadedFile::fake()->image('front.jpg', 40, 30),
        ])
        ->call('uploadMedia')
        ->assertHasNoErrors()
        ->assertSee('Media uploaded');

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

    expect($media->status)->toBe(MediaStatus::Processing)
        ->and($media->position)->toBe(0)
        ->and($media->alt_text)->toBe('Media Test Product')
        ->and($media->storage_key)->toStartWith("media/originals/{$product->getKey()}/");

    Storage::disk('public')->assertExists($media->storage_key);

    Queue::assertPushed(ProcessMediaUpload::class, function (ProcessMediaUpload $job) use ($media, $store): bool {
        return $job->productMediaId === $media->getKey()
            && $job->storeId === $store->getKey();
    });
});

test('admin product form attaches selected media when creating a product', function (): void {
    Storage::fake('public');
    Queue::fake([ProcessMediaUpload::class]);

    $store = Store::factory()->create();
    $user = productMediaAdminUser($store);

    app()->instance('current_store', $store);

    Livewire::actingAs($user)
        ->test(ProductForm::class)
        ->set('title', 'Created Media Product')
        ->set('handle', 'created-media-product')
        ->set('variants.0.price', '19.99')
        ->set('variants.0.quantity', 7)
        ->set('newMedia', [
            UploadedFile::fake()->image('created.png', 32, 24),
        ])
        ->call('save')
        ->assertHasNoErrors()
        ->assertSee('Product saved');

    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'created-media-product')
        ->firstOrFail();
    $media = ProductMedia::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();

    Storage::disk('public')->assertExists($media->storage_key);
    Queue::assertPushed(ProcessMediaUpload::class, 1);
});

test('admin product form rejects non image media uploads', function (): void {
    Storage::fake('public');
    Queue::fake([ProcessMediaUpload::class]);

    $store = Store::factory()->create();
    $user = productMediaAdminUser($store);
    $product = Product::factory()->create(['store_id' => $store->getKey()]);

    app()->instance('current_store', $store);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product])
        ->set('newMedia', [
            UploadedFile::fake()->create('notes.txt', 1, 'text/plain'),
        ])
        ->call('uploadMedia')
        ->assertHasErrors(['newMedia.0']);

    expect(ProductMedia::withoutGlobalScopes()->where('product_id', $product->getKey())->count())->toBe(0);

    Queue::assertNothingPushed();
});

test('admin product form updates alt text reorders and deletes media', function (): void {
    Storage::fake('public');

    $store = Store::factory()->create();
    $user = productMediaAdminUser($store);
    $product = Product::factory()->create(['store_id' => $store->getKey()]);

    $media = collect([0, 1, 2])->map(function (int $position) use ($product): ProductMedia {
        $item = ProductMedia::factory()->create([
            'product_id' => $product->getKey(),
            'storage_key' => "media/originals/{$product->getKey()}/{$position}.png",
            'position' => $position,
            'status' => MediaStatus::Ready,
        ]);

        Storage::disk('public')->put($item->storage_key, 'image-bytes');

        return $item;
    });

    app()->instance('current_store', $store);

    $component = Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product])
        ->set('media.1.altText', 'Updated sleeve detail')
        ->call('updateMediaAlt', $media[1]->getKey())
        ->assertHasNoErrors();

    expect($media[1]->refresh()->alt_text)->toBe('Updated sleeve detail');

    $component->call('moveMedia', $media[2]->getKey(), 'up');

    expect($media[0]->refresh()->position)->toBe(0)
        ->and($media[2]->refresh()->position)->toBe(1)
        ->and($media[1]->refresh()->position)->toBe(2);

    $component->call('removeMedia', $media[2]->getKey());

    $this->assertModelMissing($media[2]);
    Storage::disk('public')->assertMissing($media[2]->storage_key);
});