command: git diff -- app/Http/Middleware/ResolveStore.php routes/api.php app/Http/Controllers/Api/StorefrontCartController.php app/Http/Controllers/Api/StorefrontCheckoutController.php database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php app/Models/ThemeSetting.php config/session.php config/cors.php app/Models/WebhookSubscription.php app/Jobs/DeliverWebhook app/Jobs/CleanupAbandonedCarts.php | sed -n '1,360p'

stdout:
diff --git a/app/Http/Controllers/Api/StorefrontCartController.php b/app/Http/Controllers/Api/StorefrontCartController.php
index 8194fedc..44e7eccd 100644
--- a/app/Http/Controllers/Api/StorefrontCartController.php
+++ b/app/Http/Controllers/Api/StorefrontCartController.php
@@ -84,14 +84,14 @@ public function removeLine(Request $request, int $cartId, int $lineId): JsonResp

     private function cart(int $cartId): Cart
     {
-        $cart = Cart::query()->with(['lines.variant.product.media', 'lines.variant.inventory'])->findOrFail($cartId);
+        $cart = Cart::query()
+            ->where('store_id', app('current_store')->getKey())
+            ->with(['lines.variant.product.media', 'lines.variant.inventory'])
+            ->findOrFail($cartId);
         $customerId = request()->user('customer')?->getKey();

         if ($customerId !== null) {
             abort_unless((int) $cart->customer_id === (int) $customerId, 404);
-        } else {
-            $sessionCartIds = array_filter([request()->session()->get('cart_id'), request()->session()->get('cart_id_'.app('current_store')->getKey())]);
-            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
         }

         return $cart;
diff --git a/app/Http/Controllers/Api/StorefrontCheckoutController.php b/app/Http/Controllers/Api/StorefrontCheckoutController.php
index 8e346588..f01c5c74 100644
--- a/app/Http/Controllers/Api/StorefrontCheckoutController.php
+++ b/app/Http/Controllers/Api/StorefrontCheckoutController.php
@@ -24,6 +24,13 @@ public function store(Request $request): JsonResponse
     {
         $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]);
         $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']);
+        $customerId = $request->user('customer')?->getKey();
+        if ($customerId !== null) {
+            abort_unless((int) $cart->customer_id === (int) $customerId, 404);
+        } else {
+            $sessionCartIds = array_filter([$request->session()->get('cart_id'), $request->session()->get('cart_id_'.app('current_store')->getKey())]);
+            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
+        }
         $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer'));
         $this->pricing->calculate($checkout);

@@ -43,9 +50,21 @@ public function show(int $checkoutId): JsonResponse

     public function address(Request $request, int $checkoutId): JsonResponse
     {
-        $data = $request->validate(['shipping_address' => ['required', 'array'], 'billing_address' => ['nullable', 'array'], 'use_shipping_as_billing' => ['nullable', 'boolean']]);
+        $checkout = $this->checkout($checkoutId);
+        $requiresShipping = $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping);
+        $useShippingAsBilling = $request->boolean('use_shipping_as_billing', true);
+        $rules = [
+            'use_shipping_as_billing' => ['sometimes', 'boolean'],
+            ...$this->addressValidationRules('shipping_address', $requiresShipping),
+        ];
+
+        if (! $useShippingAsBilling) {
+            $rules = [...$rules, ...$this->addressValidationRules('billing_address', $requiresShipping)];
+        }
+
+        $data = $request->validate($rules);
         try {
-            $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $data['shipping_address'], $data['billing_address'] ?? null, $data['use_shipping_as_billing'] ?? true);
+            $checkout = $this->checkouts->setAddress($checkout, $data['shipping_address'] ?? [], $data['billing_address'] ?? null, $useShippingAsBilling);
         } catch (\LogicException $exception) {
             return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
         }
@@ -80,9 +99,24 @@ public function applyDiscount(Request $request, int $checkoutId): JsonResponse
         return response()->json($this->payload($checkout->refresh()));
     }

+    public function removeDiscount(int $checkoutId): JsonResponse
+    {
+        $checkout = $this->checkout($checkoutId);
+        $checkout->update(['discount_code' => null]);
+        $this->pricing->calculate($checkout->refresh());
+
+        return response()->json($this->payload($checkout->refresh()));
+    }
+
     public function pay(Request $request, int $checkoutId): JsonResponse
     {
-        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], 'card_number' => ['nullable', 'string'], 'card_expiry' => ['nullable', 'string'], 'card_cvc' => ['nullable', 'string']]);
+        $data = $request->validate([
+            'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'],
+            'card_number' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(?=.*\d)[0-9 ]+$/'],
+            'card_expiry' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'],
+            'card_cvc' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^\d{3,4}$/'],
+            'card_holder' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'max:255'],
+        ]);
         try {
             $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
             $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data);
@@ -128,6 +162,31 @@ private function checkout(int $checkoutId): Checkout
         return $checkout;
     }

+    /**
+     * @return array<string, array<int, string>>
+     */
+    private function addressValidationRules(string $key, bool $required): array
+    {
+        $presence = $required ? 'required' : 'sometimes';
+        $root = $required ? 'required' : 'nullable';
+
+        return [
+            $key => [$root, 'array'],
+            "{$key}.first_name" => [$presence, 'string', 'max:255'],
+            "{$key}.last_name" => [$presence, 'string', 'max:255'],
+            "{$key}.address1" => [$presence, 'string', 'max:500'],
+            "{$key}.address2" => ['sometimes', 'nullable', 'string', 'max:500'],
+            "{$key}.company" => ['sometimes', 'nullable', 'string', 'max:255'],
+            "{$key}.city" => [$presence, 'string', 'max:255'],
+            "{$key}.province" => ['sometimes', 'nullable', 'string', 'max:255'],
+            "{$key}.province_code" => ['sometimes', 'nullable', 'string', 'max:10'],
+            "{$key}.country" => [$presence, 'string', 'max:255'],
+            "{$key}.country_code" => [$presence, 'string', 'regex:/^[A-Z]{2}$/'],
+            "{$key}.postal_code" => [$presence, 'string', 'max:20'],
+            "{$key}.phone" => ['sometimes', 'nullable', 'string', 'max:50'],
+        ];
+    }
+
     /** @return array<string, mixed> */
     private function payload(Checkout $checkout): array
     {
diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php
index 8bcce48a..64ceb204 100644
--- a/app/Http/Middleware/ResolveStore.php
+++ b/app/Http/Middleware/ResolveStore.php
@@ -25,6 +25,11 @@ public function handle(Request $request, Closure $next, string $context = 'store
         if ($this->isPublicAdminAuthRequest($request)) {
             return $next($request);
         }
+
+        if ($this->isAdminApiRequest($request) && $request->user('sanctum') === null) {
+            return $next($request);
+        }
+
         $context = $context === 'storefront' && $this->isAdminRequest($request)
             ? 'admin'
             : $context;
@@ -75,8 +80,13 @@ private function resolveStorefrontStore(Request $request): ?Store

     private function resolveAdminStore(Request $request): ?Store
     {
-        $storeId = $request->session()->get(config('tenancy.admin_session_key', 'current_store_id'));
-        $user = $request->user('web') ?? $request->user();
+        $isApiRequest = $this->isAdminApiRequest($request);
+        $storeId = $isApiRequest
+            ? $request->route('storeId')
+            : $request->session()->get(config('tenancy.admin_session_key', 'current_store_id'));
+        $user = $isApiRequest
+            ? $request->user('sanctum')
+            : ($request->user('web') ?? $request->user());

         if ($storeId === null || $user === null) {
             return null;
@@ -87,6 +97,10 @@ private function resolveAdminStore(Request $request): ?Store

     private function isAdminRequest(Request $request): bool
     {
+        if ($this->isAdminApiRequest($request)) {
+            return true;
+        }
+
         $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/');

         if ($request->is($prefix, $prefix.'/*') || $request->routeIs($prefix.'.*')) {
@@ -96,6 +110,13 @@ private function isAdminRequest(Request $request): bool
         return $request->is('livewire/update') && str_contains((string) $request->headers->get('referer'), '/admin');
     }

+    private function isAdminApiRequest(Request $request): bool
+    {
+        $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/');
+
+        return $request->is('api/'.$prefix, 'api/'.$prefix.'/*');
+    }
+
     private function isPublicAdminAuthRequest(Request $request): bool
     {
         return $request->is('admin/login', 'admin/forgot-password', 'admin/reset-password/*');
diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php
index 3780e0ca..fe86e927 100644
--- a/app/Jobs/CleanupAbandonedCarts.php
+++ b/app/Jobs/CleanupAbandonedCarts.php
@@ -2,18 +2,43 @@

 namespace App\Jobs;

+use App\Enums\CheckoutStatus;
 use App\Models\Cart;
+use App\Models\Checkout;
+use App\Services\InventoryService;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\DB;

 class CleanupAbandonedCarts implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, SerializesModels;

-    public function handle(): void
+    public function handle(InventoryService $inventory): void
     {
-        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->update(['status' => 'abandoned']);
+        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->each(function (Cart $cart) use ($inventory): void {
+            DB::transaction(function () use ($cart, $inventory): void {
+                $cart->load('lines.variant.inventory');
+                $checkouts = Checkout::withoutGlobalScopes()
+                    ->where('cart_id', $cart->getKey())
+                    ->where('status', CheckoutStatus::PaymentSelected->value)
+                    ->lockForUpdate()
+                    ->get();
+
+                foreach ($checkouts as $checkout) {
+                    foreach ($cart->lines as $line) {
+                        if ($line->variant?->inventory !== null) {
+                            $inventory->release($line->variant->inventory, $line->quantity);
+                        }
+                    }
+
+                    $checkout->update(['status' => CheckoutStatus::Expired]);
+                }
+
+                $cart->update(['status' => 'abandoned']);
+            });
+        });
     }
 }
diff --git a/app/Models/ThemeSetting.php b/app/Models/ThemeSetting.php
index 637b24f4..f732d390 100644
--- a/app/Models/ThemeSetting.php
+++ b/app/Models/ThemeSetting.php
@@ -2,16 +2,30 @@

 namespace App\Models;

+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;

 class ThemeSetting extends Model
 {
-    protected $fillable = ['theme_id', 'key', 'value'];
+    /** @use HasFactory<\Database\Factories\ThemeSettingFactory> */
+    use HasFactory;
+
+    protected $primaryKey = 'theme_id';
+
+    public $incrementing = false;
+
+    public const CREATED_AT = null;
+
+    protected $keyType = 'int';
+
+    protected $attributes = ['settings_json' => '{}'];
+
+    protected $fillable = ['theme_id', 'settings_json'];

     protected function casts(): array
     {
-        return ['value' => 'array'];
+        return ['settings_json' => 'array'];
     }

     public function theme(): BelongsTo
diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php
index 0837f11c..0843161a 100644
--- a/app/Models/WebhookSubscription.php
+++ b/app/Models/WebhookSubscription.php
@@ -10,13 +10,13 @@ class WebhookSubscription extends Model
 {
     use BelongsToStore;

-    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'secret_encrypted', 'status', 'consecutive_failures'];
+    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures'];

-    protected $hidden = ['secret_encrypted'];
+    protected $hidden = ['signing_secret_encrypted'];

     protected function casts(): array
     {
-        return ['secret_encrypted' => 'encrypted'];
+        return ['signing_secret_encrypted' => 'encrypted'];
     }

     protected static function booted(): void
diff --git a/config/session.php b/config/session.php
index e6197a0f..914da71b 100644
--- a/config/session.php
+++ b/config/session.php
@@ -1,7 +1,5 @@
 <?php

-use Illuminate\Support\Str;
-
 return [

     /*
@@ -47,7 +45,7 @@
     |
     */

-    'encrypt' => env('SESSION_ENCRYPT', false),
+    'encrypt' => env('SESSION_ENCRYPT', true),

     /*
     |--------------------------------------------------------------------------
@@ -129,7 +127,7 @@

     'cookie' => env(
         'SESSION_COOKIE',
-        Str::slug((string) env('APP_NAME', 'laravel')).'-session'
+        'shop_session'
     ),

     /*
diff --git a/routes/api.php b/routes/api.php
index 280515c0..6c1d1593 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -4,6 +4,8 @@
 use App\Http\Controllers\Api\StorefrontAnalyticsController;
 use App\Http\Controllers\Api\StorefrontCartController;
 use App\Http\Controllers\Api\StorefrontCheckoutController;
+use App\Http\Controllers\Api\StorefrontOrderController;
+use App\Http\Controllers\Api\StorefrontSearchController;
 use Illuminate\Session\Middleware\StartSession;
 use Illuminate\Support\Facades\Route;

@@ -19,11 +21,15 @@
     Route::put('checkouts/{checkoutId}/shipping-method', [StorefrontCheckoutController::class, 'shippingMethod'])->middleware('throttle:checkout');
     Route::put('checkouts/{checkoutId}/payment-method', [StorefrontCheckoutController::class, 'paymentMethod'])->middleware('throttle:checkout');
     Route::post('checkouts/{checkoutId}/apply-discount', [StorefrontCheckoutController::class, 'applyDiscount'])->middleware('throttle:checkout');
+    Route::delete('checkouts/{checkoutId}/discount', [StorefrontCheckoutController::class, 'removeDiscount'])->middleware('throttle:checkout');
     Route::post('checkouts/{checkoutId}/pay', [StorefrontCheckoutController::class, 'pay'])->middleware('throttle:checkout');
-    Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store']);
+    Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show']);
+    Route::get('search', [StorefrontSearchController::class, 'index'])->middleware('throttle:search');
+    Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->middleware('throttle:search');
+    Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store'])->middleware('throttle:analytics');
 });

-Route::prefix('admin/v1/stores/{storeId}')->middleware([StartSession::class, 'auth', 'store.resolve', 'role.check:owner,admin,staff,support', 'throttle:api.admin'])->group(function (): void {
+Route::prefix('admin/v1/stores/{storeId}')->middleware(['auth:sanctum', 'store.resolve', 'role.check:owner,admin,staff,support', 'api.ability', 'throttle:api.admin'])->group(function (): void {
     Route::get('products', [AdminController::class, 'products']);
     Route::post('products', [AdminController::class, 'storeProduct'])->middleware('role.check:owner,admin,staff');
     Route::get('products/{productId}', [AdminController::class, 'showProduct']);
@@ -37,4 +43,25 @@
     Route::get('orders/{orderId}', [AdminController::class, 'showOrder']);
     Route::get('customers', [AdminController::class, 'customers']);
     Route::get('discounts', [AdminController::class, 'discounts']);
+    Route::post('discounts', [AdminController::class, 'storeDiscount'])->middleware('role.check:owner,admin,staff');
+    Route::put('discounts/{discountId}', [AdminController::class, 'updateDiscount'])->middleware('role.check:owner,admin,staff');
+    Route::delete('discounts/{discountId}', [AdminController::class, 'deleteDiscount'])->middleware('role.check:owner,admin');
+    Route::get('shipping/zones', [AdminController::class, 'shippingZones']);
+    Route::post('shipping/zones', [AdminController::class, 'storeShippingZone'])->middleware('role.check:owner,admin');
+    Route::put('shipping/zones/{zoneId}', [AdminController::class, 'updateShippingZone'])->middleware('role.check:owner,admin');
+    Route::post('shipping/zones/{zoneId}/rates', [AdminController::class, 'storeShippingRate'])->middleware('role.check:owner,admin');


text:
Script completed
Wall time 0.1 seconds
Output:

text:
diff --git a/app/Http/Controllers/Api/StorefrontCartController.php b/app/Http/Controllers/Api/StorefrontCartController.php
index 8194fedc..44e7eccd 100644
--- a/app/Http/Controllers/Api/StorefrontCartController.php
+++ b/app/Http/Controllers/Api/StorefrontCartController.php
@@ -84,14 +84,14 @@ public function removeLine(Request $request, int $cartId, int $lineId): JsonResp

     private function cart(int $cartId): Cart
     {
-        $cart = Cart::query()->with(['lines.variant.product.media', 'lines.variant.inventory'])->findOrFail($cartId);
+        $cart = Cart::query()
+            ->where('store_id', app('current_store')->getKey())
+            ->with(['lines.variant.product.media', 'lines.variant.inventory'])
+            ->findOrFail($cartId);
         $customerId = request()->user('customer')?->getKey();

         if ($customerId !== null) {
             abort_unless((int) $cart->customer_id === (int) $customerId, 404);
-        } else {
-            $sessionCartIds = array_filter([request()->session()->get('cart_id'), request()->session()->get('cart_id_'.app('current_store')->getKey())]);
-            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
         }

         return $cart;
diff --git a/app/Http/Controllers/Api/StorefrontCheckoutController.php b/app/Http/Controllers/Api/StorefrontCheckoutController.php
index 8e346588..f01c5c74 100644
--- a/app/Http/Controllers/Api/StorefrontCheckoutController.php
+++ b/app/Http/Controllers/Api/StorefrontCheckoutController.php
@@ -24,6 +24,13 @@ public function store(Request $request): JsonResponse
     {
         $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]);
         $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']);
+        $customerId = $request->user('customer')?->getKey();
+        if ($customerId !== null) {
+            abort_unless((int) $cart->customer_id === (int) $customerId, 404);
+        } else {
+            $sessionCartIds = array_filter([$request->session()->get('cart_id'), $request->session()->get('cart_id_'.app('current_store')->getKey())]);
+            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
+        }
         $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer'));
         $this->pricing->calculate($checkout);

@@ -43,9 +50,21 @@ public function show(int $checkoutId): JsonResponse

     public function address(Request $request, int $checkoutId): JsonResponse
     {
-        $data = $request->validate(['shipping_address' => ['required', 'array'], 'billing_address' => ['nullable', 'array'], 'use_shipping_as_billing' => ['nullable', 'boolean']]);
+        $checkout = $this->checkout($checkoutId);
+        $requiresShipping = $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping);
+        $useShippingAsBilling = $request->boolean('use_shipping_as_billing', true);
+        $rules = [
+            'use_shipping_as_billing' => ['sometimes', 'boolean'],
+            ...$this->addressValidationRules('shipping_address', $requiresShipping),
+        ];
+
+        if (! $useShippingAsBilling) {
+            $rules = [...$rules, ...$this->addressValidationRules('billing_address', $requiresShipping)];
+        }
+
+        $data = $request->validate($rules);
         try {
-            $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $data['shipping_address'], $data['billing_address'] ?? null, $data['use_shipping_as_billing'] ?? true);
+            $checkout = $this->checkouts->setAddress($checkout, $data['shipping_address'] ?? [], $data['billing_address'] ?? null, $useShippingAsBilling);
         } catch (\LogicException $exception) {
             return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
         }
@@ -80,9 +99,24 @@ public function applyDiscount(Request $request, int $checkoutId): JsonResponse
         return response()->json($this->payload($checkout->refresh()));
     }

+    public function removeDiscount(int $checkoutId): JsonResponse
+    {
+        $checkout = $this->checkout($checkoutId);
+        $checkout->update(['discount_code' => null]);
+        $this->pricing->calculate($checkout->refresh());
+
+        return response()->json($this->payload($checkout->refresh()));
+    }
+
     public function pay(Request $request, int $checkoutId): JsonResponse
     {
-        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], 'card_number' => ['nullable', 'string'], 'card_expiry' => ['nullable', 'string'], 'card_cvc' => ['nullable', 'string']]);
+        $data = $request->validate([
+            'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'],
+            'card_number' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(?=.*\d)[0-9 ]+$/'],
+            'card_expiry' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'],
+            'card_cvc' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^\d{3,4}$/'],
+            'card_holder' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'max:255'],
+        ]);
         try {
             $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
             $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data);
@@ -128,6 +162,31 @@ private function checkout(int $checkoutId): Checkout
         return $checkout;
     }

+    /**
+     * @return array<string, array<int, string>>
+     */
+    private function addressValidationRules(string $key, bool $required): array
+    {
+        $presence = $required ? 'required' : 'sometimes';
+        $root = $required ? 'required' : 'nullable';
+
+        return [
+            $key => [$root, 'array'],
+            "{$key}.first_name" => [$presence, 'string', 'max:255'],
+            "{$key}.last_name" => [$presence, 'string', 'max:255'],
+            "{$key}.address1" => [$presence, 'string', 'max:500'],
+            "{$key}.address2" => ['sometimes', 'nullable', 'string', 'max:500'],
+            "{$key}.company" => ['sometimes', 'nullable', 'string', 'max:255'],
+            "{$key}.city" => [$presence, 'string', 'max:255'],
+            "{$key}.province" => ['sometimes', 'nullable', 'string', 'max:255'],
+            "{$key}.province_code" => ['sometimes', 'nullable', 'string', 'max:10'],
+            "{$key}.country" => [$presence, 'string', 'max:255'],
+            "{$key}.country_code" => [$presence, 'string', 'regex:/^[A-Z]{2}$/'],
+            "{$key}.postal_code" => [$presence, 'string', 'max:20'],
+            "{$key}.phone" => ['sometimes', 'nullable', 'string', 'max:50'],
+        ];
+    }
+
     /** @return array<string, mixed> */
     private function payload(Checkout $checkout): array
     {
diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php
index 8bcce48a..64ceb204 100644
--- a/app/Http/Middleware/ResolveStore.php
+++ b/app/Http/Middleware/ResolveStore.php
@@ -25,6 +25,11 @@ public function handle(Request $request, Closure $next, string $context = 'store
         if ($this->isPublicAdminAuthRequest($request)) {
             return $next($request);
         }
+
+        if ($this->isAdminApiRequest($request) && $request->user('sanctum') === null) {
+            return $next($request);
+        }
+
         $context = $context === 'storefront' && $this->isAdminRequest($request)
             ? 'admin'
             : $context;
@@ -75,8 +80,13 @@ private function resolveStorefrontStore(Request $request): ?Store

     private function resolveAdminStore(Request $request): ?Store
     {
-        $storeId = $request->session()->get(config('tenancy.admin_session_key', 'current_store_id'));
-        $user = $request->user('web') ?? $request->user();
+        $isApiRequest = $this->isAdminApiRequest($request);
+        $storeId = $isApiRequest
+            ? $request->route('storeId')
+            : $request->session()->get(config('tenancy.admin_session_key', 'current_store_id'));
+        $user = $isApiRequest
+            ? $request->user('sanctum')
+            : ($request->user('web') ?? $request->user());

         if ($storeId === null || $user === null) {
             return null;
@@ -87,6 +97,10 @@ private function resolveAdminStore(Request $request): ?Store

     private function isAdminRequest(Request $request): bool
     {
+        if ($this->isAdminApiRequest($request)) {
+            return true;
+        }
+
         $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/');

         if ($request->is($prefix, $prefix.'/*') || $request->routeIs($prefix.'.*')) {
@@ -96,6 +110,13 @@ private function isAdminRequest(Request $request): bool
         return $request->is('livewire/update') && str_contains((string) $request->headers->get('referer'), '/admin');
     }

+    private function isAdminApiRequest(Request $request): bool
+    {
+        $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/');
+
+        return $request->is('api/'.$prefix, 'api/'.$prefix.'/*');
+    }
+
     private function isPublicAdminAuthRequest(Request $request): bool
     {
         return $request->is('admin/login', 'admin/forgot-password', 'admin/reset-password/*');
diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php
index 3780e0ca..fe86e927 100644
--- a/app/Jobs/CleanupAbandonedCarts.php
+++ b/app/Jobs/CleanupAbandonedCarts.php
@@ -2,18 +2,43 @@

 namespace App\Jobs;

+use App\Enums\CheckoutStatus;
 use App\Models\Cart;
+use App\Models\Checkout;
+use App\Services\InventoryService;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\DB;

 class CleanupAbandonedCarts implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, SerializesModels;

-    public function handle(): void
+    public function handle(InventoryService $inventory): void
     {
-        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->update(['status' => 'abandoned']);
+        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->each(function (Cart $cart) use ($inventory): void {
+            DB::transaction(function () use ($cart, $inventory): void {
+                $cart->load('lines.variant.inventory');
+                $checkouts = Checkout::withoutGlobalScopes()
+                    ->where('cart_id', $cart->getKey())
+                    ->where('status', CheckoutStatus::PaymentSelected->value)
+                    ->lockForUpdate()
+                    ->get();
+
+                foreach ($checkouts as $checkout) {
+                    foreach ($cart->lines as $line) {
+                        if ($line->variant?->inventory !== null) {
+                            $inventory->release($line->variant->inventory, $line->quantity);
+                        }
+                    }
+
+                    $checkout->update(['status' => CheckoutStatus::Expired]);
+                }
+
+                $cart->update(['status' => 'abandoned']);
+            });
+        });
     }
 }
diff --git a/app/Models/ThemeSetting.php b/app/Models/ThemeSetting.php
index 637b24f4..f732d390 100644
--- a/app/Models/ThemeSetting.php
+++ b/app/Models/ThemeSetting.php
@@ -2,16 +2,30 @@

 namespace App\Models;

+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;

 class ThemeSetting extends Model
 {
-    protected $fillable = ['theme_id', 'key', 'value'];
+    /** @use HasFactory<\Database\Factories\ThemeSettingFactory> */
+    use HasFactory;
+
+    protected $primaryKey = 'theme_id';
+
+    public $incrementing = false;
+
+    public const CREATED_AT = null;
+
+    protected $keyType = 'int';
+
+    protected $attributes = ['settings_json' => '{}'];
+
+    protected $fillable = ['theme_id', 'settings_json'];

     protected function casts(): array
     {
-        return ['value' => 'array'];
+        return ['settings_json' => 'array'];
     }

     public function theme(): BelongsTo
diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php
index 0837f11c..0843161a 100644
--- a/app/Models/WebhookSubscription.php
+++ b/app/Models/WebhookSubscription.php
@@ -10,13 +10,13 @@ class WebhookSubscription extends Model
 {
     use BelongsToStore;

-    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'secret_encrypted', 'status', 'consecutive_failures'];
+    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures'];

-    protected $hidden = ['secret_encrypted'];
+    protected $hidden = ['signing_secret_encrypted'];

     protected function casts(): array
     {
-        return ['secret_encrypted' => 'encrypted'];
+        return ['signing_secret_encrypted' => 'encrypted'];
     }

     protected static function booted(): void
diff --git a/config/session.php b/config/session.php
index e6197a0f..914da71b 100644
--- a/config/session.php
+++ b/config/session.php
@@ -1,7 +1,5 @@
 <?php

-use Illuminate\Support\Str;
-
 return [

     /*
@@ -47,7 +45,7 @@
     |
     */

-    'encrypt' => env('SESSION_ENCRYPT', false),
+    'encrypt' => env('SESSION_ENCRYPT', true),

     /*
     |--------------------------------------------------------------------------
@@ -129,7 +127,7 @@

     'cookie' => env(
         'SESSION_COOKIE',
-        Str::slug((string) env('APP_NAME', 'laravel')).'-session'
+        'shop_session'
     ),

     /*
diff --git a/routes/api.php b/routes/api.php
index 280515c0..6c1d1593 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -4,6 +4,8 @@
 use App\Http\Controllers\Api\StorefrontAnalyticsController;
 use App\Http\Controllers\Api\StorefrontCartController;
 use App\Http\Controllers\Api\StorefrontCheckoutController;
+use App\Http\Controllers\Api\StorefrontOrderController;
+use App\Http\Controllers\Api\StorefrontSearchController;
 use Illuminate\Session\Middleware\StartSession;
 use Illuminate\Support\Facades\Route;

@@ -19,11 +21,15 @@
     Route::put('checkouts/{checkoutId}/shipping-method', [StorefrontCheckoutController::class, 'shippingMethod'])->middleware('throttle:checkout');
     Route::put('checkouts/{checkoutId}/payment-method', [StorefrontCheckoutController::class, 'paymentMethod'])->middleware('throttle:checkout');
     Route::post('checkouts/{checkoutId}/apply-discount', [StorefrontCheckoutController::class, 'applyDiscount'])->middleware('throttle:checkout');
+    Route::delete('checkouts/{checkoutId}/discount', [StorefrontCheckoutController::class, 'removeDiscount'])->middleware('throttle:checkout');
     Route::post('checkouts/{checkoutId}/pay', [StorefrontCheckoutController::class, 'pay'])->middleware('throttle:checkout');
-    Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store']);
+    Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show']);
+    Route::get('search', [StorefrontSearchController::class, 'index'])->middleware('throttle:search');
+    Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->middleware('throttle:search');
+    Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store'])->middleware('throttle:analytics');
 });

-Route::prefix('admin/v1/stores/{storeId}')->middleware([StartSession::class, 'auth', 'store.resolve', 'role.check:owner,admin,staff,support', 'throttle:api.admin'])->group(function (): void {
+Route::prefix('admin/v1/stores/{storeId}')->middleware(['auth:sanctum', 'store.resolve', 'role.check:owner,admin,staff,support', 'api.ability', 'throttle:api.admin'])->group(function (): void {
     Route::get('products', [AdminController::class, 'products']);
     Route::post('products', [AdminController::class, 'storeProduct'])->middleware('role.check:owner,admin,staff');
     Route::get('products/{productId}', [AdminController::class, 'showProduct']);
@@ -37,4 +43,25 @@
     Route::get('orders/{orderId}', [AdminController::class, 'showOrder']);
     Route::get('customers', [AdminController::class, 'customers']);
     Route::get('discounts', [AdminController::class, 'discounts']);
+    Route::post('discounts', [AdminController::class, 'storeDiscount'])->middleware('role.check:owner,admin,staff');
+    Route::put('discounts/{discountId}', [AdminController::class, 'updateDiscount'])->middleware('role.check:owner,admin,staff');
+    Route::delete('discounts/{discountId}', [AdminController::class, 'deleteDiscount'])->middleware('role.check:owner,admin');
+    Route::get('shipping/zones', [AdminController::class, 'shippingZones']);
+    Route::post('shipping/zones', [AdminController::class, 'storeShippingZone'])->middleware('role.check:owner,admin');
+    Route::put('shipping/zones/{zoneId}', [AdminController::class, 'updateShippingZone'])->middleware('role.check:owner,admin');
+    Route::post('shipping/zones/{zoneId}/rates', [AdminController::class, 'storeShippingRate'])->middleware('role.check:owner,admin');


exec

status: completed

input:
const r = await tools.exec_command({cmd:"git diff -- config/cors.php app/Jobs/DeliverWebhook.php app/Models/WebhookSubscription.php database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php app/Providers/AppServiceProvider.php config/logging.php; git status --short | rg 'Theme|Webhook|cors|password|ResolveStore|StorefrontCheckout|Cleanup|AppServiceProvider|routes/api|AdminController'",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:16000});
text(r.output);


type: CommandExecution

command: git diff -- config/cors.php app/Jobs/DeliverWebhook.php app/Models/WebhookSubscription.php database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php app/Providers/AppServiceProvider.php config/logging.php; git status --short | rg 'Theme|Webhook|cors|password|ResolveStore|StorefrontCheckout|Cleanup|AppServiceProvider|routes/api|AdminController'

type: unknown

status: completed

stdout:
diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php
index 4b79186f..e18ec779 100644
--- a/app/Jobs/DeliverWebhook.php
+++ b/app/Jobs/DeliverWebhook.php
@@ -30,15 +30,24 @@ public function handle(WebhookService $webhooks): void
         $this->delivery->load('subscription');
         $payload = json_encode($this->delivery->payload, JSON_THROW_ON_ERROR);
         $subscription = $this->delivery->subscription;
+        $timestamp = (string) now()->timestamp;
         $response = Http::withHeaders([
-            'X-Platform-Signature' => $webhooks->sign($payload, $subscription->secret_encrypted),
+            'Content-Type' => 'application/json',
+            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
             'X-Platform-Event' => $this->delivery->event,
             'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(),
-            'X-Platform-Timestamp' => (string) now()->timestamp,
+            'X-Platform-Timestamp' => $timestamp,
         ])->timeout(10)->post($subscription->target_url, $this->delivery->payload);

         $this->delivery->increment('attempts');
-        $this->delivery->update(['response_status' => $response->status(), 'response_body' => mb_substr($response->body(), 0, 10000)]);
+        $this->delivery->increment('attempt_count');
+        $this->delivery->update([
+            'response_status' => $response->status(),
+            'response_code' => $response->status(),
+            'response_body' => mb_substr($response->body(), 0, 10000),
+            'response_body_snippet' => mb_substr($response->body(), 0, 1000),
+            'last_attempt_at' => now(),
+        ]);

         if ($response->successful()) {
             $subscription->update(['consecutive_failures' => 0]);
diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php
index 0837f11c..0843161a 100644
--- a/app/Models/WebhookSubscription.php
+++ b/app/Models/WebhookSubscription.php
@@ -10,13 +10,13 @@ class WebhookSubscription extends Model
 {
     use BelongsToStore;

-    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'secret_encrypted', 'status', 'consecutive_failures'];
+    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures'];

-    protected $hidden = ['secret_encrypted'];
+    protected $hidden = ['signing_secret_encrypted'];

     protected function casts(): array
     {
-        return ['secret_encrypted' => 'encrypted'];
+        return ['signing_secret_encrypted' => 'encrypted'];
     }

     protected static function booted(): void
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 9f79d2ae..93b51f60 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,10 +3,13 @@
 namespace App\Providers;

 use App\Auth\CustomerUserProvider;
+use App\Auth\StoreScopedPasswordBrokerManager;
 use App\Contracts\PaymentProvider as PaymentProviderContract;
+use App\Contracts\TaxProvider;
 use App\Models\Product;
 use App\Observers\ProductObserver;
 use App\Services\MockPaymentProvider;
+use App\Services\Tax\ManualTaxProvider;
 use Carbon\CarbonImmutable;
 use Illuminate\Cache\RateLimiting\Limit;
 use Illuminate\Database\Eloquent\Model;
@@ -29,7 +32,9 @@ class AppServiceProvider extends ServiceProvider
     public function register(): void
     {
         $this->app->bind(PaymentProviderContract::class, MockPaymentProvider::class);
+        $this->app->bind(TaxProvider::class, ManualTaxProvider::class);
         Auth::provider('customer', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model']));
+        $this->app->extend('auth.password', fn ($manager, $app): StoreScopedPasswordBrokerManager => new StoreScopedPasswordBrokerManager($app));
     }

     /**
@@ -49,6 +54,9 @@ public function boot(): void
         RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip()));
         RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip()));
         RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()));
+        RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()));
+        RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));
+        RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip()));
     }

     /**
diff --git a/config/logging.php b/config/logging.php
index 9e998a49..b2a40373 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -73,6 +73,14 @@
             'replace_placeholders' => true,
         ],

+        'audit' => [
+            'driver' => 'daily',
+            'path' => storage_path('logs/audit.log'),
+            'level' => 'info',
+            'days' => 90,
+            'replace_placeholders' => true,
+        ],
+
         'slack' => [
             'driver' => 'slack',
             'url' => env('LOG_SLACK_WEBHOOK_URL'),
 M app/Http/Controllers/Api/AdminController.php
 M app/Http/Controllers/Api/StorefrontCheckoutController.php
 M app/Http/Middleware/ResolveStore.php
 M app/Jobs/CleanupAbandonedCarts.php
 M app/Jobs/DeliverWebhook.php
 M app/Livewire/Admin/Themes/Editor.php
 M app/Livewire/Admin/Themes/Index.php
 M app/Models/Theme.php
 M app/Models/ThemeSetting.php
 M app/Models/WebhookSubscription.php
 M app/Providers/AppServiceProvider.php
 M routes/api.php
 M tests/Feature/SearchAnalyticsWebhookTest.php
?? config/cors.php
?? database/factories/ThemeFactory.php
?? database/factories/ThemeSettingFactory.php
?? database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php
?? database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php
?? database/seeders/ThemeSeeder.php
?? tests/Feature/CommerceValidationAndCleanupTest.php


text:
Script completed
Wall time 0.1 seconds
Output:

text:
diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php
index 4b79186f..e18ec779 100644
--- a/app/Jobs/DeliverWebhook.php
+++ b/app/Jobs/DeliverWebhook.php
@@ -30,15 +30,24 @@ public function handle(WebhookService $webhooks): void
         $this->delivery->load('subscription');
         $payload = json_encode($this->delivery->payload, JSON_THROW_ON_ERROR);
         $subscription = $this->delivery->subscription;
+        $timestamp = (string) now()->timestamp;
         $response = Http::withHeaders([
-            'X-Platform-Signature' => $webhooks->sign($payload, $subscription->secret_encrypted),
+            'Content-Type' => 'application/json',
+            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
             'X-Platform-Event' => $this->delivery->event,
             'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(),
-            'X-Platform-Timestamp' => (string) now()->timestamp,
+            'X-Platform-Timestamp' => $timestamp,
         ])->timeout(10)->post($subscription->target_url, $this->delivery->payload);

         $this->delivery->increment('attempts');
-        $this->delivery->update(['response_status' => $response->status(), 'response_body' => mb_substr($response->body(), 0, 10000)]);
+        $this->delivery->increment('attempt_count');
+        $this->delivery->update([
+            'response_status' => $response->status(),
+            'response_code' => $response->status(),
+            'response_body' => mb_substr($response->body(), 0, 10000),
+            'response_body_snippet' => mb_substr($response->body(), 0, 1000),
+            'last_attempt_at' => now(),
+        ]);

         if ($response->successful()) {
             $subscription->update(['consecutive_failures' => 0]);
diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php
index 0837f11c..0843161a 100644
--- a/app/Models/WebhookSubscription.php
+++ b/app/Models/WebhookSubscription.php
@@ -10,13 +10,13 @@ class WebhookSubscription extends Model
 {
     use BelongsToStore;

-    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'secret_encrypted', 'status', 'consecutive_failures'];
+    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures'];

-    protected $hidden = ['secret_encrypted'];
+    protected $hidden = ['signing_secret_encrypted'];

     protected function casts(): array
     {
-        return ['secret_encrypted' => 'encrypted'];
+        return ['signing_secret_encrypted' => 'encrypted'];
     }

     protected static function booted(): void
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 9f79d2ae..93b51f60 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,10 +3,13 @@
 namespace App\Providers;

 use App\Auth\CustomerUserProvider;
+use App\Auth\StoreScopedPasswordBrokerManager;
 use App\Contracts\PaymentProvider as PaymentProviderContract;
+use App\Contracts\TaxProvider;
 use App\Models\Product;
 use App\Observers\ProductObserver;
 use App\Services\MockPaymentProvider;
+use App\Services\Tax\ManualTaxProvider;
 use Carbon\CarbonImmutable;
 use Illuminate\Cache\RateLimiting\Limit;
 use Illuminate\Database\Eloquent\Model;
@@ -29,7 +32,9 @@ class AppServiceProvider extends ServiceProvider
     public function register(): void
     {
         $this->app->bind(PaymentProviderContract::class, MockPaymentProvider::class);
+        $this->app->bind(TaxProvider::class, ManualTaxProvider::class);
         Auth::provider('customer', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model']));
+        $this->app->extend('auth.password', fn ($manager, $app): StoreScopedPasswordBrokerManager => new StoreScopedPasswordBrokerManager($app));
     }

     /**
@@ -49,6 +54,9 @@ public function boot(): void
         RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip()));
         RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip()));
         RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()));
+        RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()));
+        RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));
+        RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip()));
     }

     /**
diff --git a/config/logging.php b/config/logging.php
index 9e998a49..b2a40373 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -73,6 +73,14 @@
             'replace_placeholders' => true,
         ],

+        'audit' => [
+            'driver' => 'daily',
+            'path' => storage_path('logs/audit.log'),
+            'level' => 'info',
+            'days' => 90,
+            'replace_placeholders' => true,
+        ],
+
         'slack' => [
             'driver' => 'slack',
             'url' => env('LOG_SLACK_WEBHOOK_URL'),
 M app/Http/Controllers/Api/AdminController.php
 M app/Http/Controllers/Api/StorefrontCheckoutController.php
 M app/Http/Middleware/ResolveStore.php
 M app/Jobs/CleanupAbandonedCarts.php
 M app/Jobs/DeliverWebhook.php
 M app/Livewire/Admin/Themes/Editor.php
 M app/Livewire/Admin/Themes/Index.php
 M app/Models/Theme.php
 M app/Models/ThemeSetting.php
 M app/Models/WebhookSubscription.php
 M app/Providers/AppServiceProvider.php
 M routes/api.php
 M tests/Feature/SearchAnalyticsWebhookTest.php
?? config/cors.php
?? database/factories/ThemeFactory.php
?? database/factories/ThemeSettingFactory.php
?? database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php
?? database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php
?? database/seeders/ThemeSeeder.php
?? tests/Feature/CommerceValidationAndCleanupTest.php


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,300p' database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php; sed -n '1,180p' database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php; sed -n '1,220p' app/Auth/StoreScopedTokenRepository.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:20000});
text(r.output);


command: sed -n '1,300p' database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php; sed -n '1,180p' database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php; sed -n '1,220p' app/Auth/StoreScopedTokenRepository.php

type: read

StoreScopedTokenRepository.php

stdout:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        $this->migrateThemeSettings();
        $this->migrateCustomerPasswordResetTokens();
        $this->migrateWebhookSubscriptions();
    }

    public function down(): void
    {
        $this->restoreWebhookSubscriptions();
        $this->restoreCustomerPasswordResetTokens();
        $this->restoreThemeSettings();
    }

    private function migrateThemeSettings(): void
    {
        if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) {
            return;
        }

        Schema::create('theme_settings_contract', function (Blueprint $table): void {
            $table->unsignedBigInteger('theme_id')->primary();
            $table->text('settings_json')->default('{}');
            $table->timestamp('updated_at')->nullable();
            $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete();
        });

        $settingsByTheme = [];
        $updatedAtByTheme = [];

        DB::table('theme_settings')->orderBy('theme_id')->orderBy('id')->get()->each(function (object $row) use (&$settingsByTheme, &$updatedAtByTheme): void {
            $themeId = (int) $row->theme_id;
            $settingsByTheme[$themeId][$row->key] = $this->decodeJson($row->value);

            if ($row->updated_at !== null && (! isset($updatedAtByTheme[$themeId]) || $row->updated_at > $updatedAtByTheme[$themeId])) {
                $updatedAtByTheme[$themeId] = $row->updated_at;
            }
        });

        foreach ($settingsByTheme as $themeId => $settings) {
            DB::table('theme_settings_contract')->insert([
                'theme_id' => $themeId,
                'settings_json' => $this->encodeJsonObject($settings),
                'updated_at' => $updatedAtByTheme[$themeId] ?? null,
            ]);
        }

        $this->replaceTable('theme_settings', 'theme_settings_contract');
    }

    private function restoreThemeSettings(): void
    {
        if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) {
            return;
        }

        Schema::create('theme_settings_legacy', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
            $table->string('key');
            $table->json('value')->nullable();
            $table->timestamps();
            $table->unique(['theme_id', 'key']);
        });

        DB::table('theme_settings')->orderBy('theme_id')->get()->each(function (object $row): void {
            $settings = $this->decodeJson($row->settings_json);

            if (! is_array($settings)) {
                return;
            }

            foreach ($settings as $key => $value) {
                DB::table('theme_settings_legacy')->insert([
                    'theme_id' => $row->theme_id,
                    'key' => (string) $key,
                    'value' => $this->encodeJson($value),
                    'created_at' => null,
                    'updated_at' => $row->updated_at,
                ]);
            }
        });

        $this->replaceTable('theme_settings', 'theme_settings_legacy');
    }

    private function migrateCustomerPasswordResetTokens(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_contract', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')
            ->whereNotNull('store_id')
            ->orderBy('store_id')
            ->orderBy('email')
            ->get()
            ->each(function (object $row): void {
                DB::table('customer_password_reset_tokens_contract')->insert([
                    'store_id' => $row->store_id,
                    'email' => $row->email,
                    'token' => $row->token,
                    'created_at' => $row->created_at,
                ]);
            });

        $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_contract');
    }

    private function restoreCustomerPasswordResetTokens(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_legacy', function (Blueprint $table): void {
            $table->unsignedBigInteger('store_id')->nullable();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')->get()->each(function (object $row): void {
            DB::table('customer_password_reset_tokens_legacy')->insert([
                'store_id' => $row->store_id,
                'email' => $row->email,
                'token' => $row->token,
                'created_at' => $row->created_at,
            ]);
        });

        $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_legacy');
    }

    private function migrateWebhookSubscriptions(): void
    {
        if (! Schema::hasTable('webhook_subscriptions') || Schema::hasColumn('webhook_subscriptions', 'secret_encrypted') === false) {
            return;
        }

        Schema::create('webhook_subscriptions_contract', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->string('event_type');
            $table->text('target_url');
            $table->unsignedBigInteger('app_installation_id')->nullable()->index();
            $table->text('signing_secret_encrypted');
            $table->string('status')->default('active')->index();
            $table->unsignedInteger('consecutive_failures')->default(0);
            $table->timestamps();
            $table->index(['store_id', 'event']);
            $table->index(['store_id', 'event_type']);
        });

        DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void {
            DB::table('webhook_subscriptions_contract')->insert([
                'id' => $row->id,
                'store_id' => $row->store_id,
                'event' => $row->event,
                'event_type' => $row->event_type ?? $row->event,
                'target_url' => $row->target_url,
                'app_installation_id' => $row->app_installation_id ?? null,
                'signing_secret_encrypted' => $row->signing_secret_encrypted ?? $row->secret_encrypted,
                'status' => $row->status,
                'consecutive_failures' => $row->consecutive_failures,
                'created_at' => $row->created_at,
                'updated_at' => $row->updated_at,
            ]);
        });

        $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_contract');
    }

    private function restoreWebhookSubscriptions(): void
    {
        if (! Schema::hasTable('webhook_subscriptions') || ! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) {
            return;
        }

        Schema::create('webhook_subscriptions_legacy', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->string('event_type')->nullable();
            $table->text('target_url');
            $table->unsignedBigInteger('app_installation_id')->nullable()->index();
            $table->text('secret_encrypted');
            $table->string('status')->default('active');
            $table->unsignedInteger('consecutive_failures')->default(0);
            $table->timestamps();
            $table->index(['store_id', 'event']);
        });

        DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void {
            DB::table('webhook_subscriptions_legacy')->insert([
                'id' => $row->id,
                'store_id' => $row->store_id,
                'event' => $row->event,
                'event_type' => $row->event_type,
                'target_url' => $row->target_url,
                'app_installation_id' => $row->app_installation_id,
                'secret_encrypted' => $row->signing_secret_encrypted,
                'status' => $row->status,
                'consecutive_failures' => $row->consecutive_failures,
                'created_at' => $row->created_at,
                'updated_at' => $row->updated_at,
            ]);
        });

        $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_legacy');
    }

    private function replaceTable(string $table, string $replacement): void
    {
        Schema::disableForeignKeyConstraints();

        try {
            Schema::drop($table);
            Schema::rename($replacement, $table);
        } finally {
            Schema::enableForeignKeyConstraints();
        }
    }

    private function decodeJson(mixed $value): mixed
    {
        if ($value === null || ! is_string($value)) {
            return $value;
        }

        try {
            return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return $value;
        }
    }

    private function encodeJson(mixed $value): string
    {
        return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
    }

    /** @param array<string, mixed> $settings */
    private function encodeJsonObject(array $settings): string
    {
        return $this->encodeJson($settings === [] ? (object) [] : $settings);
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_scoped', function (Blueprint $table): void {
            $table->unsignedBigInteger('store_id')->nullable();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')->get()->each(function (object $token): void {
            DB::table('customer_password_reset_tokens_scoped')->insert((array) $token);
        });

        Schema::drop('customer_password_reset_tokens');
        Schema::rename('customer_password_reset_tokens_scoped', 'customer_password_reset_tokens');
    }

    public function down(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_legacy', function (Blueprint $table): void {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        DB::table('customer_password_reset_tokens')->select(['email', 'token', 'created_at'])->orderBy('created_at')->get()->each(function (object $token): void {
            DB::table('customer_password_reset_tokens_legacy')->insertOrIgnore((array) $token);
        });

        Schema::drop('customer_password_reset_tokens');
        Schema::rename('customer_password_reset_tokens_legacy', 'customer_password_reset_tokens');
    }
};
<?php

namespace App\Auth;

use App\Models\Store;
use Illuminate\Auth\Passwords\DatabaseTokenRepository;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Support\Carbon;

class StoreScopedTokenRepository extends DatabaseTokenRepository
{
    public function create(CanResetPasswordContract $user): string
    {
        $email = $user->getEmailForPasswordReset();
        $this->deleteExisting($user);
        $token = $this->createNewToken();
        $this->scopedTable()->insert($this->getPayload($email, $token));

        return $token;
    }

    public function exists(CanResetPasswordContract $user, string $token): bool
    {
        $record = (array) $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->first();

        return $record !== []
            && ! $this->tokenExpired($record['created_at'])
            && $this->getHasher()->check($token, $record['token']);
    }

    public function recentlyCreatedToken(CanResetPasswordContract $user): bool
    {
        $record = (array) $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->first();

        return $record !== [] && $this->tokenRecentlyCreated($record['created_at']);
    }

    public function delete(CanResetPasswordContract $user): void
    {
        $this->deleteExisting($user);
    }

    public function deleteExpired(): void
    {
        $expiredAt = Carbon::now()->subSeconds($this->expires);
        $this->getTable()->where('created_at', '<', $expiredAt)->delete();
    }

    protected function deleteExisting(CanResetPasswordContract $user): int
    {
        return $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->delete();
    }

    protected function getPayload($email, #[\SensitiveParameter] $token): array
    {
        return array_merge(parent::getPayload($email, $token), ['store_id' => $this->currentStoreId()]);
    }

    private function scopedTable(): \Illuminate\Database\Query\Builder
    {
        return $this->getTable()->where('store_id', $this->currentStoreId());
    }

    private function currentStoreId(): int
    {
        if (! app()->bound('current_store') || ! app('current_store') instanceof Store) {
            throw new \LogicException('A current store is required for customer password reset tokens.');
        }

        return (int) app('current_store')->getKey();
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        $this->migrateThemeSettings();
        $this->migrateCustomerPasswordResetTokens();
        $this->migrateWebhookSubscriptions();
    }

    public function down(): void
    {
        $this->restoreWebhookSubscriptions();
        $this->restoreCustomerPasswordResetTokens();
        $this->restoreThemeSettings();
    }

    private function migrateThemeSettings(): void
    {
        if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) {
            return;
        }

        Schema::create('theme_settings_contract', function (Blueprint $table): void {
            $table->unsignedBigInteger('theme_id')->primary();
            $table->text('settings_json')->default('{}');
            $table->timestamp('updated_at')->nullable();
            $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete();
        });

        $settingsByTheme = [];
        $updatedAtByTheme = [];

        DB::table('theme_settings')->orderBy('theme_id')->orderBy('id')->get()->each(function (object $row) use (&$settingsByTheme, &$updatedAtByTheme): void {
            $themeId = (int) $row->theme_id;
            $settingsByTheme[$themeId][$row->key] = $this->decodeJson($row->value);

            if ($row->updated_at !== null && (! isset($updatedAtByTheme[$themeId]) || $row->updated_at > $updatedAtByTheme[$themeId])) {
                $updatedAtByTheme[$themeId] = $row->updated_at;
            }
        });

        foreach ($settingsByTheme as $themeId => $settings) {
            DB::table('theme_settings_contract')->insert([
                'theme_id' => $themeId,
                'settings_json' => $this->encodeJsonObject($settings),
                'updated_at' => $updatedAtByTheme[$themeId] ?? null,
            ]);
        }

        $this->replaceTable('theme_settings', 'theme_settings_contract');
    }

    private function restoreThemeSettings(): void
    {
        if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) {
            return;
        }

        Schema::create('theme_settings_legacy', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
            $table->string('key');
            $table->json('value')->nullable();
            $table->timestamps();
            $table->unique(['theme_id', 'key']);
        });

        DB::table('theme_settings')->orderBy('theme_id')->get()->each(function (object $row): void {
            $settings = $this->decodeJson($row->settings_json);

            if (! is_array($settings)) {
                return;
            }

            foreach ($settings as $key => $value) {
                DB::table('theme_settings_legacy')->insert([
                    'theme_id' => $row->theme_id,
                    'key' => (string) $key,
                    'value' => $this->encodeJson($value),
                    'created_at' => null,
                    'updated_at' => $row->updated_at,
                ]);
            }
        });

        $this->replaceTable('theme_settings', 'theme_settings_legacy');
    }

    private function migrateCustomerPasswordResetTokens(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_contract', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')
            ->whereNotNull('store_id')
            ->orderBy('store_id')
            ->orderBy('email')
            ->get()
            ->each(function (object $row): void {
                DB::table('customer_password_reset_tokens_contract')->insert([
                    'store_id' => $row->store_id,
                    'email' => $row->email,
                    'token' => $row->token,
                    'created_at' => $row->created_at,
                ]);
            });

        $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_contract');
    }

    private function restoreCustomerPasswordResetTokens(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_legacy', function (Blueprint $table): void {
            $table->unsignedBigInteger('store_id')->nullable();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')->get()->each(function (object $row): void {
            DB::table('customer_password_reset_tokens_legacy')->insert([
                'store_id' => $row->store_id,
                'email' => $row->email,
                'token' => $row->token,
                'created_at' => $row->created_at,
            ]);
        });

        $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_legacy');
    }

    private function migrateWebhookSubscriptions(): void
    {
        if (! Schema::hasTable('webhook_subscriptions') || Schema::hasColumn('webhook_subscriptions', 'secret_encrypted') === false) {
            return;
        }

        Schema::create('webhook_subscriptions_contract', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->string('event_type');
            $table->text('target_url');
            $table->unsignedBigInteger('app_installation_id')->nullable()->index();
            $table->text('signing_secret_encrypted');
            $table->string('status')->default('active')->index();
            $table->unsignedInteger('consecutive_failures')->default(0);
            $table->timestamps();
            $table->index(['store_id', 'event']);
            $table->index(['store_id', 'event_type']);
        });

        DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void {
            DB::table('webhook_subscriptions_contract')->insert([
                'id' => $row->id,
                'store_id' => $row->store_id,
                'event' => $row->event,
                'event_type' => $row->event_type ?? $row->event,
                'target_url' => $row->target_url,
                'app_installation_id' => $row->app_installation_id ?? null,
                'signing_secret_encrypted' => $row->signing_secret_encrypted ?? $row->secret_encrypted,
                'status' => $row->status,
                'consecutive_failures' => $row->consecutive_failures,
                'created_at' => $row->created_at,
                'updated_at' => $row->updated_at,
            ]);
        });

        $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_contract');
    }

    private function restoreWebhookSubscriptions(): void
    {
        if (! Schema::hasTable('webhook_subscriptions') || ! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) {
            return;
        }

        Schema::create('webhook_subscriptions_legacy', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->string('event_type')->nullable();
            $table->text('target_url');
            $table->unsignedBigInteger('app_installation_id')->nullable()->index();
            $table->text('secret_encrypted');
            $table->string('status')->default('active');
            $table->unsignedInteger('consecutive_failures')->default(0);
            $table->timestamps();
            $table->index(['store_id', 'event']);
        });

        DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void {
            DB::table('webhook_subscriptions_legacy')->insert([
                'id' => $row->id,
                'store_id' => $row->store_id,
                'event' => $row->event,
                'event_type' => $row->event_type,
                'target_url' => $row->target_url,
                'app_installation_id' => $row->app_installation_id,
                'secret_encrypted' => $row->signing_secret_encrypted,
                'status' => $row->status,
                'consecutive_failures' => $row->consecutive_failures,
                'created_at' => $row->created_at,
                'updated_at' => $row->updated_at,
            ]);
        });

        $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_legacy');
    }

    private function replaceTable(string $table, string $replacement): void
    {
        Schema::disableForeignKeyConstraints();

        try {
            Schema::drop($table);
            Schema::rename($replacement, $table);
        } finally {
            Schema::enableForeignKeyConstraints();
        }
    }

    private function decodeJson(mixed $value): mixed
    {
        if ($value === null || ! is_string($value)) {
            return $value;
        }

        try {
            return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return $value;
        }
    }

    private function encodeJson(mixed $value): string
    {
        return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
    }

    /** @param array<string, mixed> $settings */
    private function encodeJsonObject(array $settings): string
    {
        return $this->encodeJson($settings === [] ? (object) [] : $settings);
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_scoped', function (Blueprint $table): void {
            $table->unsignedBigInteger('store_id')->nullable();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')->get()->each(function (object $token): void {
            DB::table('customer_password_reset_tokens_scoped')->insert((array) $token);
        });

        Schema::drop('customer_password_reset_tokens');
        Schema::rename('customer_password_reset_tokens_scoped', 'customer_password_reset_tokens');
    }

    public function down(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_legacy', function (Blueprint $table): void {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        DB::table('customer_password_reset_tokens')->select(['email', 'token', 'created_at'])->orderBy('created_at')->get()->each(function (object $token): void {
            DB::table('customer_password_reset_tokens_legacy')->insertOrIgnore((array) $token);
        });

        Schema::drop('customer_password_reset_tokens');
        Schema::rename('customer_password_reset_tokens_legacy', 'customer_password_reset_tokens');
    }
};
<?php

namespace App\Auth;

use App\Models\Store;
use Illuminate\Auth\Passwords\DatabaseTokenRepository;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Support\Carbon;

class StoreScopedTokenRepository extends DatabaseTokenRepository
{
    public function create(CanResetPasswordContract $user): string
    {
        $email = $user->getEmailForPasswordReset();
        $this->deleteExisting($user);
        $token = $this->createNewToken();
        $this->scopedTable()->insert($this->getPayload($email, $token));

        return $token;
    }

    public function exists(CanResetPasswordContract $user, string $token): bool
    {
        $record = (array) $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->first();

        return $record !== []
            && ! $this->tokenExpired($record['created_at'])
            && $this->getHasher()->check($token, $record['token']);
    }

    public function recentlyCreatedToken(CanResetPasswordContract $user): bool
    {
        $record = (array) $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->first();

        return $record !== [] && $this->tokenRecentlyCreated($record['created_at']);
    }

    public function delete(CanResetPasswordContract $user): void
    {
        $this->deleteExisting($user);
    }

    public function deleteExpired(): void
    {
        $expiredAt = Carbon::now()->subSeconds($this->expires);
        $this->getTable()->where('created_at', '<', $expiredAt)->delete();
    }

    protected function deleteExisting(CanResetPasswordContract $user): int
    {
        return $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->delete();
    }

    protected function getPayload($email, #[\SensitiveParameter] $token): array
    {
        return array_merge(parent::getPayload($email, $token), ['store_id' => $this->currentStoreId()]);
    }

    private function scopedTable(): \Illuminate\Database\Query\Builder
    {
        return $this->getTable()->where('store_id', $this->currentStoreId());
    }

    private function currentStoreId(): int
    {
        if (! app()->bound('current_store') || ! app('current_store') instanceof Store) {
            throw new \LogicException('A current store is required for customer password reset tokens.');
        }

        return (int) app('current_store')->getKey();
    }
}


exec

status: completed

input:
const r = await tools.exec_command({cmd:"rg -n -A35 -B5 'create.*webhook_subscriptions|theme_settings|customer_password_reset_tokens' database/migrations/2026_08_20_000000_create_shop_schema.php database/migrations | head -180; sed -n '1,180p' app/Services/WebhookService.php; rg -n 'secret_encrypted|signing_secret_encrypted' app database resources tests | head -100",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:18000});
text(r.output);