command: 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

type: search

query: create.*webhook_subscriptions|theme_settings|customer_password_reset_tokens

WebhookService.php

query: secret_encrypted|signing_secret_encrypted

path: app

stdout:
database/migrations/2026_08_20_000000_create_shop_schema.php-194-            $table->longText('content')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-195-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-196-            $table->unique(['theme_id', 'path']);
database/migrations/2026_08_20_000000_create_shop_schema.php-197-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-198-
database/migrations/2026_08_20_000000_create_shop_schema.php:199:        Schema::create('theme_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-200-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-201-            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-202-            $table->string('key');
database/migrations/2026_08_20_000000_create_shop_schema.php-203-            $table->json('value')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-204-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-205-            $table->unique(['theme_id', 'key']);
database/migrations/2026_08_20_000000_create_shop_schema.php-206-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-207-
database/migrations/2026_08_20_000000_create_shop_schema.php-208-        Schema::create('pages', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-209-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-210-            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-211-            $table->string('title');
database/migrations/2026_08_20_000000_create_shop_schema.php-212-            $table->string('handle');
database/migrations/2026_08_20_000000_create_shop_schema.php-213-            $table->longText('content')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-214-            $table->string('status')->default('draft')->index();
database/migrations/2026_08_20_000000_create_shop_schema.php-215-            $table->timestamp('published_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-216-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-217-            $table->unique(['store_id', 'handle']);
database/migrations/2026_08_20_000000_create_shop_schema.php-218-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-219-
database/migrations/2026_08_20_000000_create_shop_schema.php-220-        Schema::create('navigation_menus', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-221-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-222-            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-223-            $table->string('name');
database/migrations/2026_08_20_000000_create_shop_schema.php-224-            $table->string('handle');
database/migrations/2026_08_20_000000_create_shop_schema.php-225-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-226-            $table->unique(['store_id', 'handle']);
database/migrations/2026_08_20_000000_create_shop_schema.php-227-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-228-
database/migrations/2026_08_20_000000_create_shop_schema.php-229-        Schema::create('navigation_items', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-230-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-231-            $table->foreignId('navigation_menu_id')->constrained('navigation_menus')->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-232-            $table->string('label');
database/migrations/2026_08_20_000000_create_shop_schema.php-233-            $table->string('type')->default('link');
database/migrations/2026_08_20_000000_create_shop_schema.php-234-            $table->string('url')->nullable();
--
database/migrations/2026_08_20_000000_create_shop_schema.php-544-            $table->timestamp('expires_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-545-            $table->timestamp('revoked_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-546-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-547-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-548-
database/migrations/2026_08_20_000000_create_shop_schema.php:549:        Schema::create('webhook_subscriptions', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-550-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-551-            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-552-            $table->string('event');
database/migrations/2026_08_20_000000_create_shop_schema.php-553-            $table->text('target_url');
database/migrations/2026_08_20_000000_create_shop_schema.php-554-            $table->text('secret_encrypted');
database/migrations/2026_08_20_000000_create_shop_schema.php-555-            $table->string('status')->default('active');
database/migrations/2026_08_20_000000_create_shop_schema.php-556-            $table->unsignedInteger('consecutive_failures')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php-557-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-558-            $table->index(['store_id', 'event']);
database/migrations/2026_08_20_000000_create_shop_schema.php-559-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-560-
database/migrations/2026_08_20_000000_create_shop_schema.php-561-        Schema::create('webhook_deliveries', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-562-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-563-            $table->foreignId('webhook_subscription_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-564-            $table->string('event');
database/migrations/2026_08_20_000000_create_shop_schema.php-565-            $table->json('payload');
database/migrations/2026_08_20_000000_create_shop_schema.php-566-            $table->unsignedInteger('attempts')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php-567-            $table->unsignedSmallInteger('response_status')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-568-            $table->text('response_body')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-569-            $table->timestamp('delivered_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-570-            $table->timestamp('next_attempt_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-571-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-572-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-573-    }
database/migrations/2026_08_20_000000_create_shop_schema.php-574-
database/migrations/2026_08_20_000000_create_shop_schema.php-575-    public function down(): void
database/migrations/2026_08_20_000000_create_shop_schema.php-576-    {
database/migrations/2026_08_20_000000_create_shop_schema.php-577-        foreach ([
database/migrations/2026_08_20_000000_create_shop_schema.php-578-            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_000000_create_shop_schema.php-579-            'app_installations', 'apps', 'analytics_daily', 'analytics_events',
database/migrations/2026_08_20_000000_create_shop_schema.php-580-            'fulfillment_lines', 'fulfillments', 'refunds', 'payments', 'order_lines', 'orders',
database/migrations/2026_08_20_000000_create_shop_schema.php-581-            'checkouts', 'cart_lines', 'carts', 'discounts', 'tax_settings', 'shipping_rates',
database/migrations/2026_08_20_000000_create_shop_schema.php-582-            'shipping_zones', 'search_queries', 'search_settings', 'navigation_items',
database/migrations/2026_08_20_000000_create_shop_schema.php:583:            'navigation_menus', 'pages', 'theme_settings', 'theme_files', 'themes',
database/migrations/2026_08_20_000000_create_shop_schema.php-584-            'product_media', 'collection_products', 'collections', 'inventory_items',
database/migrations/2026_08_20_000000_create_shop_schema.php-585-            'variant_option_values', 'product_variants', 'product_option_values', 'product_options',
database/migrations/2026_08_20_000000_create_shop_schema.php-586-            'products', 'customer_addresses', 'customers', 'store_settings', 'store_users',
database/migrations/2026_08_20_000000_create_shop_schema.php-587-            'store_domains', 'stores', 'organizations',
database/migrations/2026_08_20_000000_create_shop_schema.php-588-        ] as $table) {
database/migrations/2026_08_20_000000_create_shop_schema.php-589-            Schema::dropIfExists($table);
database/migrations/2026_08_20_000000_create_shop_schema.php-590-        }
database/migrations/2026_08_20_000000_create_shop_schema.php-591-
database/migrations/2026_08_20_000000_create_shop_schema.php-592-        Schema::table('users', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-593-            $table->dropColumn(['status', 'last_login_at']);
database/migrations/2026_08_20_000000_create_shop_schema.php-594-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-595-    }
database/migrations/2026_08_20_000000_create_shop_schema.php-596-};
--
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-21-        $this->restoreThemeSettings();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-22-    }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-23-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-24-    private function migrateThemeSettings(): void
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-25-    {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:26:        if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-27-            return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-28-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-29-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:30:        Schema::create('theme_settings_contract', function (Blueprint $table): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-31-            $table->unsignedBigInteger('theme_id')->primary();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-32-            $table->text('settings_json')->default('{}');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-33-            $table->timestamp('updated_at')->nullable();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-34-            $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-35-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-36-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-37-        $settingsByTheme = [];
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-38-        $updatedAtByTheme = [];
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-39-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:40:        DB::table('theme_settings')->orderBy('theme_id')->orderBy('id')->get()->each(function (object $row) use (&$settingsByTheme, &$updatedAtByTheme): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-41-            $themeId = (int) $row->theme_id;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-42-            $settingsByTheme[$themeId][$row->key] = $this->decodeJson($row->value);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-43-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-44-            if ($row->updated_at !== null && (! isset($updatedAtByTheme[$themeId]) || $row->updated_at > $updatedAtByTheme[$themeId])) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-45-                $updatedAtByTheme[$themeId] = $row->updated_at;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-46-            }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-47-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-48-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-49-        foreach ($settingsByTheme as $themeId => $settings) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:50:            DB::table('theme_settings_contract')->insert([
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-51-                'theme_id' => $themeId,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-52-                'settings_json' => $this->encodeJsonObject($settings),
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-53-                'updated_at' => $updatedAtByTheme[$themeId] ?? null,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-54-            ]);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-55-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-56-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:57:        $this->replaceTable('theme_settings', 'theme_settings_contract');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-58-    }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-59-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-60-    private function restoreThemeSettings(): void
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-61-    {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:62:        if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-63-            return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-64-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-65-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:66:        Schema::create('theme_settings_legacy', function (Blueprint $table): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-67-            $table->id();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-68-            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-69-            $table->string('key');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-70-            $table->json('value')->nullable();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-71-            $table->timestamps();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-72-            $table->unique(['theme_id', 'key']);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-73-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-74-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:75:        DB::table('theme_settings')->orderBy('theme_id')->get()->each(function (object $row): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-76-            $settings = $this->decodeJson($row->settings_json);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-77-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-78-            if (! is_array($settings)) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-79-                return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-80-            }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-81-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-82-            foreach ($settings as $key => $value) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:83:                DB::table('theme_settings_legacy')->insert([
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-84-                    'theme_id' => $row->theme_id,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-85-                    'key' => (string) $key,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-86-                    'value' => $this->encodeJson($value),
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-87-                    'created_at' => null,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-88-                    'updated_at' => $row->updated_at,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-89-                ]);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-90-            }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-91-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-92-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:93:        $this->replaceTable('theme_settings', 'theme_settings_legacy');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-94-    }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-95-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-96-    private function migrateCustomerPasswordResetTokens(): void
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-97-    {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:98:        if (! Schema::hasTable('customer_password_reset_tokens')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-99-            return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-100-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-101-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:102:        Schema::create('customer_password_reset_tokens_contract', function (Blueprint $table): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-103-            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-104-            $table->string('email');
<?php

namespace App\Services;

use App\Jobs\DeliverWebhook;
use App\Models\Store;
use App\Models\WebhookSubscription;

class WebhookService
{
    public function dispatch(Store $store, string $eventType, array $payload): void
    {
        WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where(function ($query) use ($eventType): void {
                $query->where('event', $eventType)->orWhere('event_type', $eventType);
            })
            ->where('status', 'active')
            ->get()
            ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void {
                $delivery = $subscription->deliveries()->create(['event' => $eventType, 'event_id' => (string) str()->uuid(), 'payload' => $payload, 'attempts' => 0, 'attempt_count' => 0, 'next_attempt_at' => now()]);
                DeliverWebhook::dispatch($delivery);
            });
    }

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

    public function verify(string $payload, string $signature, string $secret): bool
    {
        return hash_equals($this->sign($payload, $secret), $signature);
    }
}
tests/Feature/SearchAnalyticsWebhookTest.php:67:    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'test-secret', 'status' => 'active']);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:157:        if (! Schema::hasTable('webhook_subscriptions') || Schema::hasColumn('webhook_subscriptions', 'secret_encrypted') === false) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:168:            $table->text('signing_secret_encrypted');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:184:                'signing_secret_encrypted' => $row->signing_secret_encrypted ?? $row->secret_encrypted,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:197:        if (! Schema::hasTable('webhook_subscriptions') || ! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:208:            $table->text('secret_encrypted');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:223:                'secret_encrypted' => $row->signing_secret_encrypted,
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:84:            if (! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:85:                $table->text('signing_secret_encrypted')->nullable();
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:113:            'signing_secret_encrypted' => 'webhook_subscriptions',
database/migrations/2026_08_20_000000_create_shop_schema.php:533:            $table->text('client_secret_encrypted');
database/migrations/2026_08_20_000000_create_shop_schema.php:554:            $table->text('secret_encrypted');
app/Livewire/Admin/Developers/Index.php:29:        WebhookSubscription::query()->create(['event' => $data['event'], 'target_url' => $data['targetUrl'], 'signing_secret_encrypted' => Str::random(48), 'status' => 'active']);
app/Jobs/DeliverWebhook.php:36:            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
app/Models/WebhookSubscription.php:13:    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures'];
app/Models/WebhookSubscription.php:15:    protected $hidden = ['signing_secret_encrypted'];
app/Models/WebhookSubscription.php:19:        return ['signing_secret_encrypted' => 'encrypted'];


text:
Script completed
Wall time 0.1 seconds
Output:

text:
database/migrations/2026_08_20_000000_create_shop_schema.php-194-            $table->longText('content')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-195-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-196-            $table->unique(['theme_id', 'path']);
database/migrations/2026_08_20_000000_create_shop_schema.php-197-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-198-
database/migrations/2026_08_20_000000_create_shop_schema.php:199:        Schema::create('theme_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-200-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-201-            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-202-            $table->string('key');
database/migrations/2026_08_20_000000_create_shop_schema.php-203-            $table->json('value')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-204-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-205-            $table->unique(['theme_id', 'key']);
database/migrations/2026_08_20_000000_create_shop_schema.php-206-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-207-
database/migrations/2026_08_20_000000_create_shop_schema.php-208-        Schema::create('pages', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-209-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-210-            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-211-            $table->string('title');
database/migrations/2026_08_20_000000_create_shop_schema.php-212-            $table->string('handle');
database/migrations/2026_08_20_000000_create_shop_schema.php-213-            $table->longText('content')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-214-            $table->string('status')->default('draft')->index();
database/migrations/2026_08_20_000000_create_shop_schema.php-215-            $table->timestamp('published_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-216-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-217-            $table->unique(['store_id', 'handle']);
database/migrations/2026_08_20_000000_create_shop_schema.php-218-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-219-
database/migrations/2026_08_20_000000_create_shop_schema.php-220-        Schema::create('navigation_menus', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-221-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-222-            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-223-            $table->string('name');
database/migrations/2026_08_20_000000_create_shop_schema.php-224-            $table->string('handle');
database/migrations/2026_08_20_000000_create_shop_schema.php-225-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-226-            $table->unique(['store_id', 'handle']);
database/migrations/2026_08_20_000000_create_shop_schema.php-227-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-228-
database/migrations/2026_08_20_000000_create_shop_schema.php-229-        Schema::create('navigation_items', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-230-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-231-            $table->foreignId('navigation_menu_id')->constrained('navigation_menus')->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-232-            $table->string('label');
database/migrations/2026_08_20_000000_create_shop_schema.php-233-            $table->string('type')->default('link');
database/migrations/2026_08_20_000000_create_shop_schema.php-234-            $table->string('url')->nullable();
--
database/migrations/2026_08_20_000000_create_shop_schema.php-544-            $table->timestamp('expires_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-545-            $table->timestamp('revoked_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-546-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-547-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-548-
database/migrations/2026_08_20_000000_create_shop_schema.php:549:        Schema::create('webhook_subscriptions', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-550-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-551-            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-552-            $table->string('event');
database/migrations/2026_08_20_000000_create_shop_schema.php-553-            $table->text('target_url');
database/migrations/2026_08_20_000000_create_shop_schema.php-554-            $table->text('secret_encrypted');
database/migrations/2026_08_20_000000_create_shop_schema.php-555-            $table->string('status')->default('active');
database/migrations/2026_08_20_000000_create_shop_schema.php-556-            $table->unsignedInteger('consecutive_failures')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php-557-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-558-            $table->index(['store_id', 'event']);
database/migrations/2026_08_20_000000_create_shop_schema.php-559-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-560-
database/migrations/2026_08_20_000000_create_shop_schema.php-561-        Schema::create('webhook_deliveries', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-562-            $table->id();
database/migrations/2026_08_20_000000_create_shop_schema.php-563-            $table->foreignId('webhook_subscription_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php-564-            $table->string('event');
database/migrations/2026_08_20_000000_create_shop_schema.php-565-            $table->json('payload');
database/migrations/2026_08_20_000000_create_shop_schema.php-566-            $table->unsignedInteger('attempts')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php-567-            $table->unsignedSmallInteger('response_status')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-568-            $table->text('response_body')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-569-            $table->timestamp('delivered_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-570-            $table->timestamp('next_attempt_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php-571-            $table->timestamps();
database/migrations/2026_08_20_000000_create_shop_schema.php-572-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-573-    }
database/migrations/2026_08_20_000000_create_shop_schema.php-574-
database/migrations/2026_08_20_000000_create_shop_schema.php-575-    public function down(): void
database/migrations/2026_08_20_000000_create_shop_schema.php-576-    {
database/migrations/2026_08_20_000000_create_shop_schema.php-577-        foreach ([
database/migrations/2026_08_20_000000_create_shop_schema.php-578-            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_000000_create_shop_schema.php-579-            'app_installations', 'apps', 'analytics_daily', 'analytics_events',
database/migrations/2026_08_20_000000_create_shop_schema.php-580-            'fulfillment_lines', 'fulfillments', 'refunds', 'payments', 'order_lines', 'orders',
database/migrations/2026_08_20_000000_create_shop_schema.php-581-            'checkouts', 'cart_lines', 'carts', 'discounts', 'tax_settings', 'shipping_rates',
database/migrations/2026_08_20_000000_create_shop_schema.php-582-            'shipping_zones', 'search_queries', 'search_settings', 'navigation_items',
database/migrations/2026_08_20_000000_create_shop_schema.php:583:            'navigation_menus', 'pages', 'theme_settings', 'theme_files', 'themes',
database/migrations/2026_08_20_000000_create_shop_schema.php-584-            'product_media', 'collection_products', 'collections', 'inventory_items',
database/migrations/2026_08_20_000000_create_shop_schema.php-585-            'variant_option_values', 'product_variants', 'product_option_values', 'product_options',
database/migrations/2026_08_20_000000_create_shop_schema.php-586-            'products', 'customer_addresses', 'customers', 'store_settings', 'store_users',
database/migrations/2026_08_20_000000_create_shop_schema.php-587-            'store_domains', 'stores', 'organizations',
database/migrations/2026_08_20_000000_create_shop_schema.php-588-        ] as $table) {
database/migrations/2026_08_20_000000_create_shop_schema.php-589-            Schema::dropIfExists($table);
database/migrations/2026_08_20_000000_create_shop_schema.php-590-        }
database/migrations/2026_08_20_000000_create_shop_schema.php-591-
database/migrations/2026_08_20_000000_create_shop_schema.php-592-        Schema::table('users', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php-593-            $table->dropColumn(['status', 'last_login_at']);
database/migrations/2026_08_20_000000_create_shop_schema.php-594-        });
database/migrations/2026_08_20_000000_create_shop_schema.php-595-    }
database/migrations/2026_08_20_000000_create_shop_schema.php-596-};
--
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-21-        $this->restoreThemeSettings();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-22-    }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-23-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-24-    private function migrateThemeSettings(): void
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-25-    {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:26:        if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-27-            return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-28-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-29-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:30:        Schema::create('theme_settings_contract', function (Blueprint $table): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-31-            $table->unsignedBigInteger('theme_id')->primary();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-32-            $table->text('settings_json')->default('{}');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-33-            $table->timestamp('updated_at')->nullable();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-34-            $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-35-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-36-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-37-        $settingsByTheme = [];
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-38-        $updatedAtByTheme = [];
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-39-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:40:        DB::table('theme_settings')->orderBy('theme_id')->orderBy('id')->get()->each(function (object $row) use (&$settingsByTheme, &$updatedAtByTheme): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-41-            $themeId = (int) $row->theme_id;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-42-            $settingsByTheme[$themeId][$row->key] = $this->decodeJson($row->value);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-43-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-44-            if ($row->updated_at !== null && (! isset($updatedAtByTheme[$themeId]) || $row->updated_at > $updatedAtByTheme[$themeId])) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-45-                $updatedAtByTheme[$themeId] = $row->updated_at;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-46-            }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-47-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-48-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-49-        foreach ($settingsByTheme as $themeId => $settings) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:50:            DB::table('theme_settings_contract')->insert([
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-51-                'theme_id' => $themeId,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-52-                'settings_json' => $this->encodeJsonObject($settings),
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-53-                'updated_at' => $updatedAtByTheme[$themeId] ?? null,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-54-            ]);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-55-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-56-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:57:        $this->replaceTable('theme_settings', 'theme_settings_contract');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-58-    }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-59-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-60-    private function restoreThemeSettings(): void
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-61-    {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:62:        if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-63-            return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-64-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-65-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:66:        Schema::create('theme_settings_legacy', function (Blueprint $table): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-67-            $table->id();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-68-            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-69-            $table->string('key');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-70-            $table->json('value')->nullable();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-71-            $table->timestamps();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-72-            $table->unique(['theme_id', 'key']);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-73-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-74-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:75:        DB::table('theme_settings')->orderBy('theme_id')->get()->each(function (object $row): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-76-            $settings = $this->decodeJson($row->settings_json);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-77-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-78-            if (! is_array($settings)) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-79-                return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-80-            }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-81-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-82-            foreach ($settings as $key => $value) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:83:                DB::table('theme_settings_legacy')->insert([
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-84-                    'theme_id' => $row->theme_id,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-85-                    'key' => (string) $key,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-86-                    'value' => $this->encodeJson($value),
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-87-                    'created_at' => null,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-88-                    'updated_at' => $row->updated_at,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-89-                ]);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-90-            }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-91-        });
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-92-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:93:        $this->replaceTable('theme_settings', 'theme_settings_legacy');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-94-    }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-95-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-96-    private function migrateCustomerPasswordResetTokens(): void
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-97-    {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:98:        if (! Schema::hasTable('customer_password_reset_tokens')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-99-            return;
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-100-        }
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-101-
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:102:        Schema::create('customer_password_reset_tokens_contract', function (Blueprint $table): void {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-103-            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php-104-            $table->string('email');
<?php

namespace App\Services;

use App\Jobs\DeliverWebhook;
use App\Models\Store;
use App\Models\WebhookSubscription;

class WebhookService
{
    public function dispatch(Store $store, string $eventType, array $payload): void
    {
        WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where(function ($query) use ($eventType): void {
                $query->where('event', $eventType)->orWhere('event_type', $eventType);
            })
            ->where('status', 'active')
            ->get()
            ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void {
                $delivery = $subscription->deliveries()->create(['event' => $eventType, 'event_id' => (string) str()->uuid(), 'payload' => $payload, 'attempts' => 0, 'attempt_count' => 0, 'next_attempt_at' => now()]);
                DeliverWebhook::dispatch($delivery);
            });
    }

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

    public function verify(string $payload, string $signature, string $secret): bool
    {
        return hash_equals($this->sign($payload, $secret), $signature);
    }
}
tests/Feature/SearchAnalyticsWebhookTest.php:67:    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'test-secret', 'status' => 'active']);
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:157:        if (! Schema::hasTable('webhook_subscriptions') || Schema::hasColumn('webhook_subscriptions', 'secret_encrypted') === false) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:168:            $table->text('signing_secret_encrypted');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:184:                'signing_secret_encrypted' => $row->signing_secret_encrypted ?? $row->secret_encrypted,
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:197:        if (! Schema::hasTable('webhook_subscriptions') || ! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:208:            $table->text('secret_encrypted');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:223:                'secret_encrypted' => $row->signing_secret_encrypted,
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:84:            if (! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:85:                $table->text('signing_secret_encrypted')->nullable();
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:113:            'signing_secret_encrypted' => 'webhook_subscriptions',
database/migrations/2026_08_20_000000_create_shop_schema.php:533:            $table->text('client_secret_encrypted');
database/migrations/2026_08_20_000000_create_shop_schema.php:554:            $table->text('secret_encrypted');
app/Livewire/Admin/Developers/Index.php:29:        WebhookSubscription::query()->create(['event' => $data['event'], 'target_url' => $data['targetUrl'], 'signing_secret_encrypted' => Str::random(48), 'status' => 'active']);
app/Jobs/DeliverWebhook.php:36:            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
app/Models/WebhookSubscription.php:13:    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures'];
app/Models/WebhookSubscription.php:15:    protected $hidden = ['signing_secret_encrypted'];
app/Models/WebhookSubscription.php:19:        return ['signing_secret_encrypted' => 'encrypted'];


exec

status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,180p' app/Models/Theme.php; rg -n 'themeSetting|theme_settings|settings_json|settings' app/Livewire/Admin/Themes app/Models resources/views/livewire/admin/themes database/seeders/ThemeSeeder.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:14000});
text(r.output);


type: CommandExecution

command: sed -n '1,180p' app/Models/Theme.php; rg -n 'themeSetting|theme_settings|settings_json|settings' app/Livewire/Admin/Themes app/Models resources/views/livewire/admin/themes database/seeders/ThemeSeeder.php

Theme.php

type: read

cmd: sed -n '1,180p' app/Models/Theme.php

query: themeSetting|theme_settings|settings_json|settings

path: Themes

status: completed

stdout:
<?php

namespace App\Models;

use App\Enums\ThemeStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Theme extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'name', 'status', 'version', 'settings', 'published_at'];

    protected function casts(): array
    {
        return ['status' => ThemeStatus::class, 'settings' => 'array', 'published_at' => 'datetime'];
    }

    public function files(): HasMany
    {
        return $this->hasMany(ThemeFile::class);
    }

    public function themeSettings(): HasOne
    {
        return $this->hasOne(ThemeSetting::class, 'theme_id', 'id');
    }

    public function settingsRows(): HasOne
    {
        return $this->themeSettings();
    }
}
app/Livewire/Admin/Themes/Editor.php:13:    public string $settingsJson = '{}';
app/Livewire/Admin/Themes/Editor.php:18:        $this->settingsJson = json_encode($theme->settings ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}';
app/Livewire/Admin/Themes/Editor.php:24:        $data = $this->validate(['settingsJson' => ['required', 'json']]);
app/Livewire/Admin/Themes/Editor.php:25:        $this->theme->update(['settings' => json_decode($data['settingsJson'], true, 512, JSON_THROW_ON_ERROR)]);
app/Livewire/Admin/Themes/Editor.php:26:        $this->dispatch('toast', message: 'Theme settings saved.');
resources/views/livewire/admin/themes/editor.blade.php:1:

Theme editor

{{ $theme->name }}

Edit JSON-backed theme settings with a live preview link.

<flux:button variant="ghost" :href="route('admin.themes.index')" wire:navigate>Back to themes</flux:button>

<form wire:submit="save" class="space-y-5 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-200"><flux:textarea wire:model="settingsJson" label="Theme settings (JSON)" rows="20" /><flux:button type="submit" variant="primary">Save settings</flux:button></form>

Storefront preview

Open the storefront in another tab to preview published theme changes.
Open storefront

app/Models/ThemeSetting.php:22:    protected $attributes = ['settings_json' => '{}'];
app/Models/ThemeSetting.php:24:    protected $fillable = ['theme_id', 'settings_json'];
app/Models/ThemeSetting.php:28:        return ['settings_json' => 'array'];
app/Models/Theme.php:15:    protected $fillable = ['store_id', 'name', 'status', 'version', 'settings', 'published_at'];
app/Models/Theme.php:19:        return ['status' => ThemeStatus::class, 'settings' => 'array', 'published_at' => 'datetime'];
app/Models/Theme.php:27:    public function themeSettings(): HasOne
app/Models/Theme.php:32:    public function settingsRows(): HasOne
app/Models/Theme.php:34:        return $this->themeSettings();
app/Models/SearchSetting.php:25:        static::saving(function (SearchSetting $settings): void {
app/Models/SearchSetting.php:26:            $settings->synonyms_json = $settings->synonyms ?? [];
app/Models/SearchSetting.php:27:            $settings->stop_words_json = $settings->stopwords ?? [];
app/Models/Store.php:46:    public function settings(): HasOne
app/Models/StoreSettings.php:21:    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];
app/Models/StoreSettings.php:24:        'settings_json' => '{}',
app/Models/StoreSettings.php:29:        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Models;

use App\Enums\ThemeStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Theme extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'name', 'status', 'version', 'settings', 'published_at'];

    protected function casts(): array
    {
        return ['status' => ThemeStatus::class, 'settings' => 'array', 'published_at' => 'datetime'];
    }

    public function files(): HasMany
    {
        return $this->hasMany(ThemeFile::class);
    }

    public function themeSettings(): HasOne
    {
        return $this->hasOne(ThemeSetting::class, 'theme_id', 'id');
    }

    public function settingsRows(): HasOne
    {
        return $this->themeSettings();
    }
}
app/Livewire/Admin/Themes/Editor.php:13:    public string $settingsJson = '{}';
app/Livewire/Admin/Themes/Editor.php:18:        $this->settingsJson = json_encode($theme->settings ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}';
app/Livewire/Admin/Themes/Editor.php:24:        $data = $this->validate(['settingsJson' => ['required', 'json']]);
app/Livewire/Admin/Themes/Editor.php:25:        $this->theme->update(['settings' => json_decode($data['settingsJson'], true, 512, JSON_THROW_ON_ERROR)]);
app/Livewire/Admin/Themes/Editor.php:26:        $this->dispatch('toast', message: 'Theme settings saved.');
resources/views/livewire/admin/themes/editor.blade.php:1:

Theme editor

{{ $theme->name }}

Edit JSON-backed theme settings with a live preview link.

<flux:button variant="ghost" :href="route('admin.themes.index')" wire:navigate>Back to themes</flux:button>

<form wire:submit="save" class="space-y-5 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-200"><flux:textarea wire:model="settingsJson" label="Theme settings (JSON)" rows="20" /><flux:button type="submit" variant="primary">Save settings</flux:button></form>

Storefront preview

Open the storefront in another tab to preview published theme changes.
Open storefront

app/Models/ThemeSetting.php:22:    protected $attributes = ['settings_json' => '{}'];
app/Models/ThemeSetting.php:24:    protected $fillable = ['theme_id', 'settings_json'];
app/Models/ThemeSetting.php:28:        return ['settings_json' => 'array'];
app/Models/Theme.php:15:    protected $fillable = ['store_id', 'name', 'status', 'version', 'settings', 'published_at'];
app/Models/Theme.php:19:        return ['status' => ThemeStatus::class, 'settings' => 'array', 'published_at' => 'datetime'];
app/Models/Theme.php:27:    public function themeSettings(): HasOne
app/Models/Theme.php:32:    public function settingsRows(): HasOne
app/Models/Theme.php:34:        return $this->themeSettings();
app/Models/SearchSetting.php:25:        static::saving(function (SearchSetting $settings): void {
app/Models/SearchSetting.php:26:            $settings->synonyms_json = $settings->synonyms ?? [];
app/Models/SearchSetting.php:27:            $settings->stop_words_json = $settings->stopwords ?? [];
app/Models/Store.php:46:    public function settings(): HasOne
app/Models/StoreSettings.php:21:    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];
app/Models/StoreSettings.php:24:        'settings_json' => '{}',
app/Models/StoreSettings.php:29:        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];


exec

status: completed

input:
const ids = ["01a0218c-0039-7f51-959b-d5b50efb5e7a","01a0218b-ffc7-74f1-9ca6-1853e56b0b86","01a0218c-00c8-7bb3-8389-240bad1414fd"];
for (const target of ids) {
  const r = await tools.multi_agent_v1__send_input({target, message:"Please finish your scoped work soon and return a concise summary with files/tests. Avoid further broad scanning."});
  text(r);
}


type: CollabAgentToolCall

status: completed

agent_nickname: Arendt

prompt: Please finish your scoped work soon and return a concise summary with files/tests. Avoid further broad scanning.

01a0218c-0039-7f51-959b-d5b50efb5e7a: running


type: CollabAgentToolCall

status: completed

agent_nickname: Nash

prompt: Please finish your scoped work soon and return a concise summary with files/tests. Avoid further broad scanning.

01a0218b-ffc7-74f1-9ca6-1853e56b0b86: running


type: CollabAgentToolCall

status: completed

agent_nickname: Halley

prompt: Please finish your scoped work soon and return a concise summary with files/tests. Avoid further broad scanning.

01a0218c-00c8-7bb3-8389-240bad1414fd: running


text:
Script completed
Wall time 0.0 seconds
Output:

text: {"submission_id":"01a02193-2746-79f3-93e3-9a7ea1a3d903"}

text: {"submission_id":"01a02193-274a-7d61-86d1-68bcc11650b0"}

text: {"submission_id":"01a02193-274d-70c1-b8c2-2c324476fca2"}


status: completed

input:
const r = await tools.multi_agent_v1__wait_agent({targets:["01a0218c-0039-7f51-959b-d5b50efb5e7a","01a0218b-ffc7-74f1-9ca6-1853e56b0b86","01a0218c-00c8-7bb3-8389-240bad1414fd"],timeout_ms:30000});
text(r);


output:
Script running with cell ID 918
Wall time 11.0 seconds
Output:


type: CollabAgentToolCall

tool: wait

completed:
Implemented scoped fixes:

- Nested shipping/billing address validation and conditional card fields in [StorefrontCheckoutController.php](/Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCheckoutController.php).
- Idempotent, checkout-owned inventory cleanup in [CleanupAbandonedCarts.php](/Users/wesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php).
- Corrected search limiter to 30/min and webhooks to 100/min in [AppServiceProvider.php](/Users/wesner/Herd/shop/app/Providers/AppServiceProvider.php).
- Added focused coverage in [CommerceValidationAndCleanupTest.php](/Users/wesner/Herd/shop/tests/Feature/CommerceValidationAndCleanupTest.php) and updated checkout fixtures.

Verification:

- Pint passed.
- PHP lint and diff checks passed.
- Direct limiter verification passed: `30/60s`, `100/60s`.
- Pest is blocked by an unrelated malformed untracked migration containing a duplicate migration declaration. I did not edit it per your restriction.


text:
Script completed
Wall time 8.1 seconds
Output:

text: {"status":{"01a0218c-00c8-7bb3-8389-240bad1414fd":{"completed":"Implemented scoped fixes:\n\n- Nested shipping/billing address validation and conditional card fields in [StorefrontCheckoutController.php](/Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCheckoutController.php).\n- Idempotent, checkout-owned inventory cleanup in [CleanupAbandonedCarts.php](/Users/wesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php).\n- Corrected search limiter to 30/min and webhooks to 100/min in [AppServiceProvider.php](/Users/wesner/Herd/shop/app/Providers/AppServiceProvider.php).\n- Added focused coverage in [CommerceValidationAndCleanupTest.php](/Users/wesner/Herd/shop/tests/Feature/CommerceValidationAndCleanupTest.php) and updated checkout fixtures.\n\nVerification:\n\n- Pint passed.\n- PHP lint and diff checks passed.\n- Direct limiter verification passed: `30/60s`, `100/60s`.\n- Pest is blocked by an unrelated malformed untracked migration containing a duplicate migration declaration. I did not edit it per your restriction."}},"timed_out":false}


type: message

role: user

text:
<subagent_notification>
{"agent_path":"01a0218c-00c8-7bb3-8389-240bad1414fd","status":{"completed":"Implemented scoped fixes:\n\n- Nested shipping/billing address validation and conditional card fields in [StorefrontCheckoutController.php](/Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCheckoutController.php).\n- Idempotent, checkout-owned inventory cleanup in [CleanupAbandonedCarts.php](/Users/wesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php).\n- Corrected search limiter to 30/min and webhooks to 100/min in [AppServiceProvider.php](/Users/wesner/Herd/shop/app/Providers/AppServiceProvider.php).\n- Added focused coverage in [CommerceValidationAndCleanupTest.php](/Users/wesner/Herd/shop/tests/Feature/CommerceValidationAndCleanupTest.php) and updated checkout fixtures.\n\nVerification:\n\n- Pint passed.\n- PHP lint and diff checks passed.\n- Direct limiter verification passed: `30/60s`, `100/60s`.\n- Pest is blocked by an unrelated malformed untracked migration containing a duplicate migration declaration. I did not edit it per your restriction."}}
</subagent_notification>


status: completed

input:
const r = await tools.exec_command({cmd:"for f in database/migrations/*.php; do php -l \"$f\" >/dev/null || echo \"$f\"; done; rg -n '^return new class|^return new class' database/migrations/2026_08_20_*.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:8000});
text(r.output);


command: for f in database/migrations/*.php; do php -l "$f" >/dev/null || echo "$f"; done; rg -n '^return new class|^return new class' database/migrations/2026_08_20_*.php

type: unknown

stdout:
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:7:return new class extends Migration
database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php:7:return new class extends Migration
database/migrations/2026_08_20_000000_create_shop_schema.php:7:return new class extends Migration
database/migrations/2026_08_20_220003_add_discount_code_to_carts.php:7:return new class extends Migration
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:8:return new class extends Migration
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:7:return new class extends Migration
database/migrations/2026_08_20_210057_add_tls_mode_to_store_domains_table.php:7:return new class extends Migration
database/migrations/2026_08_20_220000_add_search_and_auth_support.php:8:return new class extends Migration
database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php:8:return new class extends Migration
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:7:return new class extends Migration
database/migrations/2026_08_20_220001_add_webhook_delivery_status.php:7:return new class extends Migration
database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php:7:return new class extends Migration
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:7:return new class extends Migration


text:
Script completed
Wall time 0.6 seconds
Output:

text:
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:7:return new class extends Migration
database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php:7:return new class extends Migration
database/migrations/2026_08_20_000000_create_shop_schema.php:7:return new class extends Migration
database/migrations/2026_08_20_220003_add_discount_code_to_carts.php:7:return new class extends Migration
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:8:return new class extends Migration
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:7:return new class extends Migration
database/migrations/2026_08_20_210057_add_tls_mode_to_store_domains_table.php:7:return new class extends Migration
database/migrations/2026_08_20_220000_add_search_and_auth_support.php:8:return new class extends Migration
database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php:8:return new class extends Migration
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:7:return new class extends Migration
database/migrations/2026_08_20_220001_add_webhook_delivery_status.php:7:return new class extends Migration
database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php:7:return new class extends Migration
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:7:return new class extends Migration


status: completed

input:
const r = await tools.multi_agent_v1__wait_agent({targets:["01a0218c-0039-7f51-959b-d5b50efb5e7a","01a0218b-ffc7-74f1-9ca6-1853e56b0b86"],timeout_ms:30000});
text(r);


output:
Script running with cell ID 920
Wall time 11.0 seconds
Output:


completed:
Implemented scoped API/security fixes.

Changed:

- Admin API now requires Sanctum bearer tokens and token abilities; session auth is rejected.
- Admin tenant resolution uses `{storeId}` and verifies user membership.
- Guest cart endpoints work statelessly via `cartId`, while enforcing current-store ownership.
- Added focused authorization, membership, stateless-cart, and cross-tenant Pest coverage.

Verification:

- Pint passed.
- PHP lint and diff checks passed.
- Affected tests: **13 passed, 45 assertions**.


text:
Script completed
Wall time 4.9 seconds
Output:

text: {"status":{"01a0218c-0039-7f51-959b-d5b50efb5e7a":{"completed":"Implemented scoped API/security fixes.\n\nChanged:\n\n- Admin API now requires Sanctum bearer tokens and token abilities; session auth is rejected.\n- Admin tenant resolution uses `{storeId}` and verifies user membership.\n- Guest cart endpoints work statelessly via `cartId`, while enforcing current-store ownership.\n- Added focused authorization, membership, stateless-cart, and cross-tenant Pest coverage.\n\nVerification:\n\n- Pint passed.\n- PHP lint and diff checks passed.\n- Affected tests: **13 passed, 45 assertions**."}},"timed_out":false}


text:
<subagent_notification>
{"agent_path":"01a0218c-0039-7f51-959b-d5b50efb5e7a","status":{"completed":"Implemented scoped API/security fixes.\n\nChanged:\n\n- Admin API now requires Sanctum bearer tokens and token abilities; session auth is rejected.\n- Admin tenant resolution uses `{storeId}` and verifies user membership.\n- Guest cart endpoints work statelessly via `cartId`, while enforcing current-store ownership.\n- Added focused authorization, membership, stateless-cart, and cross-tenant Pest coverage.\n\nVerification:\n\n- Pint passed.\n- PHP lint and diff checks passed.\n- Affected tests: **13 passed, 45 assertions**."}}
</subagent_notification>


status: completed

input:
const r = await tools.multi_agent_v1__wait_agent({targets:["01a0218b-ffc7-74f1-9ca6-1853e56b0b86"],timeout_ms:30000});
text(r);


output:
Script running with cell ID 921
Wall time 11.0 seconds
Output:


text:
Script completed
Wall time 17.3 seconds
Output:


status: completed

input:
const r = await tools.multi_agent_v1__send_input({target:"01a0218b-ffc7-74f1-9ca6-1853e56b0b86",message:"Please finish the schema/auth/webhook scope now and return the summary. Do not run more broad scans."});
text(r);


type: CollabAgentToolCall

status: completed

prompt: Please finish the schema/auth/webhook scope now and return the summary. Do not run more broad scans.


text:
Script completed
Wall time 0.0 seconds
Output:

text: {"submission_id":"01a02194-8430-7180-92f2-65a881a748d8"}


status: completed

input:
const r = await tools.multi_agent_v1__wait_agent({targets:["01a0218b-ffc7-74f1-9ca6-1853e56b0b86"],timeout_ms:30000});
text(r);


output:
Script running with cell ID 923
Wall time 11.0 seconds
Output:


completed:
Implemented the requested schema/auth/webhook scope.

Changes include:

- Added contract migration for:
  - one-to-one `theme_settings` with `theme_id` primary key and `settings_json`
  - non-null, store-scoped customer reset tokens with FK
  - webhook secret column migration to `signing_secret_encrypted`
  - legacy data preservation
- Updated theme relationships, factories, scoped token repository, webhook model/job, and admin webhook creation.
- Enabled encrypted sessions and credentialed CORS defaults.
- Set audit log retention to 90 days.
- Added focused contract tests.

Verification:

- Pint passed.
- Focused tests: 14 passed, 46 assertions.
- Full suite: 78 passed; 4 unrelated existing seed-fixture failures in `CommerceValidationAndCleanupTest`.
- `git diff --check` passed.


text:
Script completed
Wall time 5.9 seconds
Output:

text: {"status":{"01a0218b-ffc7-74f1-9ca6-1853e56b0b86":{"completed":"Implemented the requested schema/auth/webhook scope.\n\nChanges include:\n\n- Added contract migration for:\n  - one-to-one `theme_settings` with `theme_id` primary key and `settings_json`\n  - non-null, store-scoped customer reset tokens with FK\n  - webhook secret column migration to `signing_secret_encrypted`\n  - legacy data preservation\n- Updated theme relationships, factories, scoped token repository, webhook model/job, and admin webhook creation.\n- Enabled encrypted sessions and credentialed CORS defaults.\n- Set audit log retention to 90 days.\n- Added focused contract tests.\n\nVerification:\n\n- Pint passed.\n- Focused tests: 14 passed, 46 assertions.\n- Full suite: 78 passed; 4 unrelated existing seed-fixture failures in `CommerceValidationAndCleanupTest`.\n- `git diff --check` passed."}},"timed_out":false}


text:
<subagent_notification>
{"agent_path":"01a0218b-ffc7-74f1-9ca6-1853e56b0b86","status":{"completed":"Implemented the requested schema/auth/webhook scope.\n\nChanges include:\n\n- Added contract migration for:\n  - one-to-one `theme_settings` with `theme_id` primary key and `settings_json`\n  - non-null, store-scoped customer reset tokens with FK\n  - webhook secret column migration to `signing_secret_encrypted`\n  - legacy data preservation\n- Updated theme relationships, factories, scoped token repository, webhook model/job, and admin webhook creation.\n- Enabled encrypted sessions and credentialed CORS defaults.\n- Set audit log retention to 90 days.\n- Added focused contract tests.\n\nVerification:\n\n- Pint passed.\n- Focused tests: 14 passed, 46 assertions.\n- Full suite: 78 passed; 4 unrelated existing seed-fixture failures in `CommerceValidationAndCleanupTest`.\n- `git diff --check` passed."}}
</subagent_notification>


status: completed

input:
const ids = ["01a0218c-0039-7f51-959b-d5b50efb5e7a","01a0218b-ffc7-74f1-9ca6-1853e56b0b86","01a0218c-00c8-7bb3-8389-240bad1414fd"];
const out = await Promise.all(ids.map(target => tools.multi_agent_v1__close_agent({target})));
out.forEach(text);


text:
Script completed
Wall time 0.0 seconds
Output:

text: {"previous_status":{"completed":"Implemented scoped API/security fixes.\n\nChanged:\n\n- Admin API now requires Sanctum bearer tokens and token abilities; session auth is rejected.\n- Admin tenant resolution uses `{storeId}` and verifies user membership.\n- Guest cart endpoints work statelessly via `cartId`, while enforcing current-store ownership.\n- Added focused authorization, membership, stateless-cart, and cross-tenant Pest coverage.\n\nVerification:\n\n- Pint passed.\n- PHP lint and diff checks passed.\n- Affected tests: **13 passed, 45 assertions**."}}

text: {"previous_status":{"completed":"Implemented the requested schema/auth/webhook scope.\n\nChanges include:\n\n- Added contract migration for:\n  - one-to-one `theme_settings` with `theme_id` primary key and `settings_json`\n  - non-null, store-scoped customer reset tokens with FK\n  - webhook secret column migration to `signing_secret_encrypted`\n  - legacy data preservation\n- Updated theme relationships, factories, scoped token repository, webhook model/job, and admin webhook creation.\n- Enabled encrypted sessions and credentialed CORS defaults.\n- Set audit log retention to 90 days.\n- Added focused contract tests.\n\nVerification:\n\n- Pint passed.\n- Focused tests: 14 passed, 46 assertions.\n- Full suite: 78 passed; 4 unrelated existing seed-fixture failures in `CommerceValidationAndCleanupTest`.\n- `git diff --check` passed."}}

text: {"previous_status":{"completed":"Implemented scoped fixes:\n\n- Nested shipping/billing address validation and conditional card fields in [StorefrontCheckoutController.php](/Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCheckoutController.php).\n- Idempotent, checkout-owned inventory cleanup in [CleanupAbandonedCarts.php](/Users/wesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php).\n- Corrected search limiter to 30/min and webhooks to 100/min in [AppServiceProvider.php](/Users/wesner/Herd/shop/app/Providers/AppServiceProvider.php).\n- Added focused coverage in [CommerceValidationAndCleanupTest.php](/Users/wesner/Herd/shop/tests/Feature/CommerceValidationAndCleanupTest.php) and updated checkout fixtures.\n\nVerification:\n\n- Pint passed.\n- PHP lint and diff checks passed.\n- Direct limiter verification passed: `30/60s`, `100/60s`.\n- Pest is blocked by an unrelated malformed untracked migration containing a duplicate migration declaration. I did not edit it per your restriction."}}


type: AgentMessage

type: Text

text: The delegated fixes are integrated: API abilities/tenant resolution/stateless carts, contract schema migration and webhook secret alignment, encrypted sessions/CORS, checkout validation, idempotent cleanup, and corrected limiter tiers. The remaining reported failures are in the new cleanup/validation fixture test, so I’m reproducing those locally and then doing one final format/migration/full-suite pass.

phase: commentary


role: assistant


status: completed

input:
const r = await tools.exec_command({cmd:"vendor/bin/pint --dirty --format agent",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:12000});
text(r.output);


command:
/bin/zsh
-lc
vendor/bin/pint --dirty --format agent

cmd: vendor/bin/pint --dirty --format agent

result: pass


text:
Script completed
Wall time 0.3 seconds
Output:

text: {"result":"pass"}


status: completed

input:
const r = await tools.exec_command({cmd:"php artisan test --compact tests/Feature/CommerceValidationAndCleanupTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:1000,max_output_tokens:20000});
text(r.output);
if (r.session_id) text(JSON.stringify({session_id:r.session_id}));


command: php artisan test --compact tests/Feature/CommerceValidationAndCleanupTest.php

status: failed

stdout:
  ⨯⨯⨯⨯
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  Tests:    4 failed (0 assertions)
  Duration: 0.71s


text:
Script completed
Wall time 0.9 seconds
Output:

text:
  ⨯⨯⨯⨯
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceValidationAndCleanu…  ModelNotFoundException
  No query results for model [App\Models\Product].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   tests/Feature/CommerceValidationAndCleanupTest.php:24

  Tests:    4 failed (0 assertions)
  Duration: 0.71s


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,280p' tests/Feature/CommerceValidationAndCleanupTest.php; sed -n '1,220p' database/seeders/ShopSeeder.php; sed -n '1,120p' tests/TestCase.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:18000});
text(r.output);


command: sed -n '1,280p' tests/Feature/CommerceValidationAndCleanupTest.php; sed -n '1,220p' database/seeders/ShopSeeder.php; sed -n '1,120p' tests/TestCase.php

TestCase.php

cmd: sed -n '1,120p' tests/TestCase.php

stdout:
<?php

use App\Enums\CheckoutStatus;
use App\Jobs\CleanupAbandonedCarts;
use App\Jobs\ExpireAbandonedCheckouts;
use App\Models\Checkout;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\CartService;
use App\Services\InventoryService;
use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

uses(LazilyRefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $this->variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    app()->instance('current_store', $this->store);
});

test('checkout addresses validate every required nested field', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $this->variant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'address-validation@example.test',
    ])->assertCreated()->json();

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => ['first_name' => 'Incomplete'],
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'shipping_address.last_name',
        'shipping_address.address1',
        'shipping_address.city',
        'shipping_address.country',
        'shipping_address.country_code',
        'shipping_address.postal_code',
    ]);

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => [
            'first_name' => 'Jane',
            'last_name' => 'Doe',
            'address1' => '123 Main St',
            'city' => 'Berlin',
            'country' => 'Germany',
            'country_code' => 'DE',
            'postal_code' => '10115',
        ],
        'use_shipping_as_billing' => false,
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'billing_address',
        'billing_address.first_name',
    ]);
});

test('credit card payments require all card fields while other payment methods do not', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $this->variant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'card-validation@example.test',
    ])->assertCreated()->json();
    $address = [
        'first_name' => 'Jane',
        'last_name' => 'Doe',
        'address1' => '123 Main St',
        'city' => 'Berlin',
        'country' => 'Germany',
        'country_code' => 'DE',
        'postal_code' => '10115',
    ];
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => $address])->assertOk();
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()])->assertOk();

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [
        'payment_method' => 'credit_card',
        'card_number' => '4242 4242 4242 4242',
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'card_expiry',
        'card_cvc',
        'card_holder',
    ]);
});

test('abandoned cart cleanup does not release inventory owned by another checkout', function (): void {
    $carts = app(CartService::class);
    $inventory = InventoryItem::query()->where('variant_id', $this->variant->getKey())->firstOrFail();

    $expiredCart = $carts->create($this->store);
    $carts->addLine($expiredCart, $this->variant->getKey(), 1);
    $expiredCheckout = Checkout::withoutGlobalScopes()->create([
        'store_id' => $this->store->getKey(),
        'cart_id' => $expiredCart->getKey(),
        'email' => 'expired@example.test',
        'status' => CheckoutStatus::PaymentSelected,
        'expires_at' => now()->subMinute(),
    ]);
    app(InventoryService::class)->reserve($inventory, 1);

    app(ExpireAbandonedCheckouts::class)->handle(app(InventoryService::class));

    $activeCart = $carts->create($this->store);
    $carts->addLine($activeCart, $this->variant->getKey(), 1);
    $activeCheckout = Checkout::withoutGlobalScopes()->create([
        'store_id' => $this->store->getKey(),
        'cart_id' => $activeCart->getKey(),
        'email' => 'active@example.test',
        'status' => CheckoutStatus::PaymentSelected,
        'expires_at' => now()->addHour(),
    ]);
    app(InventoryService::class)->reserve($inventory->refresh(), 1);
    $expiredCart->update(['updated_at' => now()->subDays(15)]);

    app(CleanupAbandonedCarts::class)->handle(app(InventoryService::class));

    expect($expiredCheckout->refresh()->status)->toBe(CheckoutStatus::Expired)
        ->and($activeCheckout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected)
        ->and($expiredCart->refresh()->status->value)->toBe('abandoned')
        ->and($inventory->refresh()->quantity_reserved)->toBe(1);
});

test('commerce rate limiters match the specification', function (): void {
    $request = Request::create('/api/storefront/v1/search', 'GET', [], [], [], ['REMOTE_ADDR' => '192.0.2.1']);
    $search = RateLimiter::limiter('search');
    $webhooks = RateLimiter::limiter('webhooks');

    expect($search)->not->toBeNull()
        ->and($webhooks)->not->toBeNull()
        ->and($search($request)->maxAttempts)->toBe(30)
        ->and($webhooks($request)->maxAttempts)->toBe(100)
        ->and($search($request)->decaySeconds)->toBe(60)
        ->and($webhooks($request)->decaySeconds)->toBe(60);
});
<?php

namespace Database\Seeders;

use App\Enums\InventoryPolicy;
use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\Store;
use App\Models\StoreDomain;
use Illuminate\Database\Seeder;

class ShopSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            OrganizationSeeder::class,
            StoreSeeder::class,
            StoreDomainSeeder::class,
            UserSeeder::class,
            StoreUserSeeder::class,
            StoreSettingsSeeder::class,
            TaxSettingsSeeder::class,
            ShippingSeeder::class,
            CollectionSeeder::class,
            ProductSeeder::class,
            DiscountSeeder::class,
            CustomerSeeder::class,
            OrderSeeder::class,
            ThemeSeeder::class,
            PageSeeder::class,
            NavigationSeeder::class,
            AnalyticsSeeder::class,
            SearchSettingsSeeder::class,
        ]);

        $this->seedLegacyCommerceFixtures();
    }

    private function seedLegacyCommerceFixtures(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
        $classic = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->where('handle', 'classic-cotton-t-shirt')->firstOrFail();
        $classicVariant = $classic->variants()->orderBy('position')->firstOrFail();

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $classicVariant->getKey()],
            ['store_id' => $store->getKey(), 'quantity_on_hand' => 80, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny],
        );

        StoreDomain::query()->updateOrCreate(
            ['hostname' => 'shop.test'],
            ['store_id' => $store->getKey(), 'type' => 'storefront', 'is_primary' => false, 'tls_mode' => 'managed'],
        );

        $legacyProduct = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->where('handle', 'limited-edition-sneakers')->firstOrFail();
        $legacyProduct->update(['handle' => 'sold-out-limited-tee', 'title' => 'Sold Out Limited Tee', 'description' => '
A sold-out limited edition tee.
', 'description_html' => '
A sold-out limited edition tee.
', 'vendor' => 'Acme Sport', 'product_type' => 'T-Shirts', 'tags' => ['limited'], 'status' => ProductStatus::Active, 'published_at' => now(), 'sales_count' => 0, 'metadata' => ['compatibility_alias_for' => 'limited-edition-sneakers']]);
        $variant = $legacyProduct->variants()->updateOrCreate(
            ['position' => 0],
            ['title' => 'Default', 'sku' => 'ACME-LEGACY-SOLD-OUT', 'price_amount' => 3999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_grams' => 250, 'weight_g' => 250, 'requires_shipping' => true, 'is_default' => true, 'status' => VariantStatus::Active, 'metadata' => []],
        );

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $variant->getKey()],
            ['store_id' => $store->getKey(), 'quantity_on_hand' => 0, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny],
        );
    }
}
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    //
}