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

namespace App\Http\Requests\Api\Storefront\V1;

use Illuminate\Foundation\Http\FormRequest;

class CompleteCheckoutPaymentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'],
            'card_number' => ['required_if:payment_method,credit_card', 'string'],
            'card_holder' => ['required_if:payment_method,credit_card', 'string', 'max:255'],
            'card_expiry' => ['required_if:payment_method,credit_card', 'string', 'max:20'],
            'card_cvc' => ['required_if:payment_method,credit_card', 'string', 'max:10'],
        ];
    }
}
<?php

namespace App\Http\Requests\Api\Storefront\V1;

use Illuminate\Foundation\Http\FormRequest;

class SetCheckoutAddressRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['nullable', 'email'],
            'shipping_address' => ['required', 'array'],
            'shipping_address.first_name' => ['required', 'string'],
            'shipping_address.last_name' => ['required', 'string'],
            'shipping_address.address1' => ['required', 'string'],
            'shipping_address.address2' => ['nullable', 'string'],
            'shipping_address.city' => ['required', 'string'],
            'shipping_address.province_code' => ['nullable', 'string'],
            'shipping_address.country' => ['required', 'string', 'size:2'],
            'shipping_address.country_code' => ['nullable', 'string', 'size:2'],
            'shipping_address.postal_code' => ['required', 'string'],
            'billing_address' => ['nullable', 'array'],
        ];
    }
}


command: rg -n "api/storefront/v1/checkouts" tests/Feature tests/Browser

type: search

cmd: rg -n api/storefront/v1/checkouts tests/Feature tests/Browser

path: Feature

aggregated_output:
tests/Feature/Api/StorefrontCheckoutApiTest.php:81:        ->postJson('/api/storefront/v1/checkouts', [
tests/Feature/Api/StorefrontCheckoutApiTest.php:96:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:109:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:118:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'SAVE10'])
tests/Feature/Api/StorefrontCheckoutApiTest.php:124:        ->deleteJson("/api/storefront/v1/checkouts/{$checkoutId}/discount")
tests/Feature/Api/StorefrontCheckoutApiTest.php:130:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/payment-method", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:161:        ->postJson('/api/storefront/v1/checkouts', [
tests/Feature/Api/StorefrontCheckoutApiTest.php:168:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:175:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:185:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:191:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'NOTREAL'])
tests/Feature/Api/StorefrontOrderApiTest.php:69:        ->postJson('/api/storefront/v1/checkouts', [
tests/Feature/Api/StorefrontOrderApiTest.php:76:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontOrderApiTest.php:89:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
tests/Feature/Api/StorefrontOrderApiTest.php:102:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
tests/Feature/Api/StorefrontOrderApiTest.php:142:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
tests/Feature/Api/StorefrontOrderApiTest.php:145:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
tests/Feature/Api/StorefrontOrderApiTest.php:161:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [

status: completed


output:
Chunk ID: 993708
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 569
Output:
tests/Feature/Api/StorefrontCheckoutApiTest.php:81:        ->postJson('/api/storefront/v1/checkouts', [
tests/Feature/Api/StorefrontCheckoutApiTest.php:96:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:109:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:118:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'SAVE10'])
tests/Feature/Api/StorefrontCheckoutApiTest.php:124:        ->deleteJson("/api/storefront/v1/checkouts/{$checkoutId}/discount")
tests/Feature/Api/StorefrontCheckoutApiTest.php:130:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/payment-method", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:161:        ->postJson('/api/storefront/v1/checkouts', [
tests/Feature/Api/StorefrontCheckoutApiTest.php:168:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:175:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:185:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
tests/Feature/Api/StorefrontCheckoutApiTest.php:191:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'NOTREAL'])
tests/Feature/Api/StorefrontOrderApiTest.php:69:        ->postJson('/api/storefront/v1/checkouts', [
tests/Feature/Api/StorefrontOrderApiTest.php:76:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
tests/Feature/Api/StorefrontOrderApiTest.php:89:        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
tests/Feature/Api/StorefrontOrderApiTest.php:102:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
tests/Feature/Api/StorefrontOrderApiTest.php:142:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
tests/Feature/Api/StorefrontOrderApiTest.php:145:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
tests/Feature/Api/StorefrontOrderApiTest.php:161:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [


cmd: sed -n '1,240p' app/Livewire/Admin/Developers/Index.php


cmd: sed -n '1,130p' app/Services/WebhookService.php


command: sed -n '1,240p' app/Livewire/Admin/Developers/Index.php

Index.php

type: read

aggregated_output:
<?php

namespace App\Livewire\Admin\Developers;

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

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $newTokenName = '';

    public ?string $generatedToken = null;

    public string $webhookEventType = 'order.created';

    public string $webhookUrl = '';

    public ?int $editingWebhookId = null;

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

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

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

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

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

        $user = auth()->user();

        abort_unless($user instanceof User, 403);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /**
     * @return Collection<int, PersonalAccessToken>
     */
    public function tokens(): Collection
    {
        return PersonalAccessToken::query()
            ->where('tokenable_type', (new User)->getMorphClass())
            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
            ->latest('created_at')
            ->get();
    }

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

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

    private function token(int $tokenId): PersonalAccessToken
    {
        return PersonalAccessToken::query()
            ->where('tokenable_type', (new User)->getMorphClass())
            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
            ->findOrFail($tokenId);
    }

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

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

        abort_unless($store instanceof Store, 404);

        return $store;
    }

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


command:
/bin/zsh
-lc
sed -n '1,130p' tests/Pest.php

Pest.php

cmd: sed -n '1,130p' tests/Pest.php

aggregated_output:
<?php

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
    ->in('Feature', 'Browser');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

/**
 * @param  list<string>  $abilities
 */
function adminApiBearerToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): string
{
    return adminApiToken($store, $abilities, $user)['plain_text'];
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
 */
function adminApiToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): array
{
    $user ??= $store->users()->wherePivot('role', 'owner')->first()
        ?? $store->users()->first();

    if (! $user instanceof \App\Models\User) {
        $user = \App\Models\User::factory()->create(['email_verified_at' => now()]);
        $store->users()->attach($user->getKey(), [
            'role' => \App\Enums\StoreUserRole::Owner->value,
            'created_at' => now(),
        ]);
    }

    return app(\App\Services\WebhookService::class)
        ->createApiToken($store, 'Test API token', $abilities, $user);
}


command: sed -n '1,130p' app/Services/WebhookService.php

aggregated_output:
<?php

namespace App\Services;

use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Jobs\DeliverWebhook;
use App\Models\PersonalAccessToken;
use App\Models\Store;
use App\Models\User;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use Illuminate\Support\Str;
use InvalidArgumentException;

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

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

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

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

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

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

    /**
     * @param  list<string>  $abilities
     * @return array{token: PersonalAccessToken, plain_text: string}
     */
    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): array
    {
        $authenticatedUser = auth()->user();
        $user ??= $authenticatedUser instanceof User
            ? $authenticatedUser
            : $store->users()
                ->wherePivot('role', 'owner')
                ->firstOrFail();
        $plainText = 'shop_'.Str::random(48);
        $token = PersonalAccessToken::query()->create([
            'tokenable_type' => $user->getMorphClass(),
            'tokenable_id' => $user->getKey(),
            'name' => $name,
            'token' => hash('sha256', $plainText),
            'abilities' => $abilities,
            'expires_at' => now()->addYear(),
            'created_at' => now(),
        ]);

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

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

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

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

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

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

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


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

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
    ->in('Feature', 'Browser');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

/**
 * @param  list<string>  $abilities
 */
function adminApiBearerToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): string
{
    return adminApiToken($store, $abilities, $user)['plain_text'];
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
 */
function adminApiToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): array
{
    $user ??= $store->users()->wherePivot('role', 'owner')->first()
        ?? $store->users()->first();

    if (! $user instanceof \App\Models\User) {
        $user = \App\Models\User::factory()->create(['email_verified_at' => now()]);
        $store->users()->attach($user->getKey(), [
            'role' => \App\Enums\StoreUserRole::Owner->value,
            'created_at' => now(),
        ]);
    }

    return app(\App\Services\WebhookService::class)
        ->createApiToken($store, 'Test API token', $abilities, $user);
}


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

namespace App\Livewire\Admin\Developers;

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

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $newTokenName = '';

    public ?string $generatedToken = null;

    public string $webhookEventType = 'order.created';

    public string $webhookUrl = '';

    public ?int $editingWebhookId = null;

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

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

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

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

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

        $user = auth()->user();

        abort_unless($user instanceof User, 403);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /**
     * @return Collection<int, PersonalAccessToken>
     */
    public function tokens(): Collection
    {
        return PersonalAccessToken::query()
            ->where('tokenable_type', (new User)->getMorphClass())
            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
            ->latest('created_at')
            ->get();
    }

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

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

    private function token(int $tokenId): PersonalAccessToken
    {
        return PersonalAccessToken::query()
            ->where('tokenable_type', (new User)->getMorphClass())
            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
            ->findOrFail($tokenId);
    }

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

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

        abort_unless($store instanceof Store, 404);

        return $store;
    }

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


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

namespace App\Services;

use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Jobs\DeliverWebhook;
use App\Models\PersonalAccessToken;
use App\Models\Store;
use App\Models\User;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use Illuminate\Support\Str;
use InvalidArgumentException;

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

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

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

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

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

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

    /**
     * @param  list<string>  $abilities
     * @return array{token: PersonalAccessToken, plain_text: string}
     */
    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): array
    {
        $authenticatedUser = auth()->user();
        $user ??= $authenticatedUser instanceof User
            ? $authenticatedUser
            : $store->users()
                ->wherePivot('role', 'owner')
                ->firstOrFail();
        $plainText = 'shop_'.Str::random(48);
        $token = PersonalAccessToken::query()->create([
            'tokenable_type' => $user->getMorphClass(),
            'tokenable_id' => $user->getKey(),
            'name' => $name,
            'token' => hash('sha256', $plainText),
            'abilities' => $abilities,
            'expires_at' => now()->addYear(),
            'created_at' => now(),
        ]);

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

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

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

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

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

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

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


cmd: sed -n '1,90p' tests/Feature/Foundation/DatabaseConstraintTest.php && rg -n "PersonalAccessToken|personal_access_tokens|Admin API|revoke" tests/Feature/Admin/AppsDevelopersTest.php tests/Feature/Api/AdminPlatformApiTest.php tests/Feature/Api/AdminCatalogApiTest.php


command: sed -n '1,90p' tests/Feature/Foundation/DatabaseConstraintTest.php && rg -n "PersonalAccessToken|personal_access_tokens|Admin API|revoke" tests/Feature/Admin/AppsDevelopersTest.php tests/Feature/Api/AdminPlatformApiTest.php tests/Feature/Api/AdminCatalogApiTest.php

DatabaseConstraintTest.php

type: search

cmd: rg -n 'PersonalAccessToken|personal_access_tokens|Admin API|revoke' tests/Feature/Admin/AppsDevelopersTest.php tests/Feature/Api/AdminPlatformApiTest.php tests/Feature/Api/AdminCatalogApiTest.php

query: PersonalAccessToken|personal_access_tokens|Admin API|revoke

aggregated_output:
<?php

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

uses(RefreshDatabase::class);

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

test('sqlite enum columns are created with check constraints', function (): void {
    $tableSql = collect(DB::select(
        "select name, sql from sqlite_master where type = 'table' and name not like 'sqlite_%'"
    ))->mapWithKeys(fn (object $table): array => [$table->name => $table->sql]);

    expect($tableSql['stores'])->toContain('"status" varchar check ("status" in (\'active\', \'suspended\'))')
        ->and($tableSql['store_users'])->toContain('"role" varchar check ("role" in (\'owner\', \'admin\', \'staff\', \'support\'))')
        ->and($tableSql['products'])->toContain('"status" varchar check ("status" in (\'draft\', \'active\', \'archived\'))')
        ->and($tableSql['orders'])->toContain('"financial_status" varchar check ("financial_status" in (\'pending\', \'authorized\', \'paid\', \'partially_refunded\', \'refunded\', \'voided\'))')
        ->and($tableSql['tax_settings'])->toContain('"provider" varchar check ("provider" in (\'stripe_tax\', \'none\'))')
        ->and($tableSql['data_exports'])->toContain('"status" varchar check ("status" in (\'queued\', \'processing\', \'completed\', \'failed\'))');
});

test('personal access tokens table matches the api token schema', function (): void {
    expect(Schema::hasTable('personal_access_tokens'))->toBeTrue()
        ->and(Schema::hasColumns('personal_access_tokens', [
            'id',
            'tokenable_type',
            'tokenable_id',
            'name',
            'token',
            'abilities',
            'last_used_at',
            'expires_at',
            'created_at',
            'updated_at',
        ]))->toBeTrue()
        ->and(Schema::hasIndex('personal_access_tokens', ['token'], 'unique'))->toBeTrue()
        ->and(Schema::hasIndex('personal_access_tokens', ['tokenable_type', 'tokenable_id']))->toBeTrue();
});
tests/Feature/Admin/AppsDevelopersTest.php:6:use App\Models\PersonalAccessToken;
tests/Feature/Admin/AppsDevelopersTest.php:63:    $initialTokenCount = PersonalAccessToken::query()
tests/Feature/Admin/AppsDevelopersTest.php:82:    expect(PersonalAccessToken::query()
tests/Feature/Api/AdminCatalogApiTest.php:36: * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
tests/Feature/Api/AdminCatalogApiTest.php:49:            'title' => 'Admin API Jacket',
tests/Feature/Api/AdminCatalogApiTest.php:74:        ->assertJsonPath('data.0.title', 'Admin API Jacket')
tests/Feature/Api/AdminCatalogApiTest.php:82:        ->assertJsonPath('data.title', 'Admin API Jacket')
tests/Feature/Api/AdminPlatformApiTest.php:29: * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}


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

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

uses(RefreshDatabase::class);

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

test('sqlite enum columns are created with check constraints', function (): void {
    $tableSql = collect(DB::select(
        "select name, sql from sqlite_master where type = 'table' and name not like 'sqlite_%'"
    ))->mapWithKeys(fn (object $table): array => [$table->name => $table->sql]);

    expect($tableSql['stores'])->toContain('"status" varchar check ("status" in (\'active\', \'suspended\'))')
        ->and($tableSql['store_users'])->toContain('"role" varchar check ("role" in (\'owner\', \'admin\', \'staff\', \'support\'))')
        ->and($tableSql['products'])->toContain('"status" varchar check ("status" in (\'draft\', \'active\', \'archived\'))')
        ->and($tableSql['orders'])->toContain('"financial_status" varchar check ("financial_status" in (\'pending\', \'authorized\', \'paid\', \'partially_refunded\', \'refunded\', \'voided\'))')
        ->and($tableSql['tax_settings'])->toContain('"provider" varchar check ("provider" in (\'stripe_tax\', \'none\'))')
        ->and($tableSql['data_exports'])->toContain('"status" varchar check ("status" in (\'queued\', \'processing\', \'completed\', \'failed\'))');
});

test('personal access tokens table matches the api token schema', function (): void {
    expect(Schema::hasTable('personal_access_tokens'))->toBeTrue()
        ->and(Schema::hasColumns('personal_access_tokens', [
            'id',
            'tokenable_type',
            'tokenable_id',
            'name',
            'token',
            'abilities',
            'last_used_at',
            'expires_at',
            'created_at',
            'updated_at',
        ]))->toBeTrue()
        ->and(Schema::hasIndex('personal_access_tokens', ['token'], 'unique'))->toBeTrue()
        ->and(Schema::hasIndex('personal_access_tokens', ['tokenable_type', 'tokenable_id']))->toBeTrue();
});
tests/Feature/Admin/AppsDevelopersTest.php:6:use App\Models\PersonalAccessToken;
tests/Feature/Admin/AppsDevelopersTest.php:63:    $initialTokenCount = PersonalAccessToken::query()
tests/Feature/Admin/AppsDevelopersTest.php:82:    expect(PersonalAccessToken::query()
tests/Feature/Api/AdminCatalogApiTest.php:36: * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
tests/Feature/Api/AdminCatalogApiTest.php:49:            'title' => 'Admin API Jacket',
tests/Feature/Api/AdminCatalogApiTest.php:74:        ->assertJsonPath('data.0.title', 'Admin API Jacket')
tests/Feature/Api/AdminCatalogApiTest.php:82:        ->assertJsonPath('data.title', 'Admin API Jacket')
tests/Feature/Api/AdminPlatformApiTest.php:29: * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}


cmd: sed -n '52,92p' tests/Feature/Admin/AppsDevelopersTest.php && sed -n '1,130p' tests/Feature/Api/AdminCatalogApiTest.php


command: sed -n '52,92p' tests/Feature/Admin/AppsDevelopersTest.php && sed -n '1,130p' tests/Feature/Api/AdminCatalogApiTest.php

AdminCatalogApiTest.php

aggregated_output:
test('developer route rejects staff users', function (): void {
    $this->actingAs(appsDevelopersUser(StoreUserRole::Staff))
        ->get('/admin/developers')
        ->assertForbidden();
});

test('developers component generates tokens and manages webhooks', function (): void {
    $store = appsDevelopersStore();
    app()->instance('current_store', $store);
    $user = appsDevelopersUser(StoreUserRole::Owner);

    $initialTokenCount = PersonalAccessToken::query()
        ->where('tokenable_type', (new User)->getMorphClass())
        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
        ->count();

    Livewire::actingAs($user)
        ->test(DevelopersIndex::class)
        ->assertSee('API tokens')
        ->set('newTokenName', 'CI Pipeline')
        ->call('generateToken')
        ->assertHasNoErrors()
        ->assertSet('generatedToken', fn (?string $token): bool => str_starts_with((string) $token, 'shop_'))
        ->call('openWebhookModal')
        ->set('webhookEventType', WebhookEventType::OrderCreated->value)
        ->set('webhookUrl', 'https://example.com/webhooks/orders')
        ->call('saveWebhook')
        ->assertHasNoErrors()
        ->assertSee('https://example.com/webhooks/orders');

    expect(PersonalAccessToken::query()
        ->where('tokenable_type', (new User)->getMorphClass())
        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
        ->count())->toBe($initialTokenCount + 1)
        ->and(WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('target_url', 'https://example.com/webhooks/orders')
            ->exists())->toBeTrue();
});
<?php

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

uses(RefreshDatabase::class);

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

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

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

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
 */
function adminCatalogApiToken(Store $store, array $abilities): array
{
    return adminApiToken($store, $abilities);
}

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

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

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

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

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

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

    $writeToken = adminApiBearerToken($store, ['write-products'], $user);

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


output:
Chunk ID: a30fe4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1591
Output:
test('developer route rejects staff users', function (): void {
    $this->actingAs(appsDevelopersUser(StoreUserRole::Staff))
        ->get('/admin/developers')
        ->assertForbidden();
});

test('developers component generates tokens and manages webhooks', function (): void {
    $store = appsDevelopersStore();
    app()->instance('current_store', $store);
    $user = appsDevelopersUser(StoreUserRole::Owner);

    $initialTokenCount = PersonalAccessToken::query()
        ->where('tokenable_type', (new User)->getMorphClass())
        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
        ->count();

    Livewire::actingAs($user)
        ->test(DevelopersIndex::class)
        ->assertSee('API tokens')
        ->set('newTokenName', 'CI Pipeline')
        ->call('generateToken')
        ->assertHasNoErrors()
        ->assertSet('generatedToken', fn (?string $token): bool => str_starts_with((string) $token, 'shop_'))
        ->call('openWebhookModal')
        ->set('webhookEventType', WebhookEventType::OrderCreated->value)
        ->set('webhookUrl', 'https://example.com/webhooks/orders')
        ->call('saveWebhook')
        ->assertHasNoErrors()
        ->assertSee('https://example.com/webhooks/orders');

    expect(PersonalAccessToken::query()
        ->where('tokenable_type', (new User)->getMorphClass())
        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
        ->count())->toBe($initialTokenCount + 1)
        ->and(WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('target_url', 'https://example.com/webhooks/orders')
            ->exists())->toBeTrue();
});
<?php

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

uses(RefreshDatabase::class);

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

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

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

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
 */
function adminCatalogApiToken(Store $store, array $abilities): array
{
    return adminApiToken($store, $abilities);
}

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

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

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

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

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

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

    $writeToken = adminApiBearerToken($store, ['write-products'], $user);

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


message: I’m making two scoped security changes now: `personal_access_tokens` will carry a `store_id` and middleware will reject use on other stores; checkout API calls after creation will require the checkout access token returned by the create response.

phase: commentary


type: message

role: assistant

text: I’m making two scoped security changes now: `personal_access_tokens` will carry a `store_id` and middleware will reject use on other stores; checkout API calls after creation will require the checkout access token returned by the create response.


status: completed

input:
*** Begin Patch
*** Update File: database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php
@@
         Schema::create('personal_access_tokens', function (Blueprint $table) {
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
             $table->morphs('tokenable');
             $table->string('name');
             $table->string('token', 64)->unique();
             $table->text('abilities')->nullable();
             $table->timestamp('last_used_at')->nullable();
             $table->timestamp('expires_at')->nullable();
             $table->timestamps();
+
+            $table->index(['store_id', 'tokenable_type', 'tokenable_id']);
         });
*** End Patch


stdout:
Success. Updated the following files:
M database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php

type: update

unified_diff:
@@ -15,2 +15,3 @@
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
             $table->morphs('tokenable');
@@ -22,2 +23,4 @@
             $table->timestamps();
+
+            $table->index(['store_id', 'tokenable_type', 'tokenable_id']);
         });


output: {"output":"Success. Updated the following files:\nM database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


role: user

text:
<subagent_notification>
{"agent_path":"019df484-b411-7593-9e23-5ad86555c272","status":{"completed":"No critical/high functional regressions found in the requested areas.\n\nEvidence:\n\n- Ran targeted feature coverage:\n  `php artisan test --compact tests/Feature/Storefront/OrderViewsTest.php tests/Feature/Storefront/CartCheckoutUiTest.php tests/Feature/Checkout/CheckoutServiceTest.php tests/Feature/Api/AdminCatalogApiTest.php tests/Feature/Api/AdminOrderApiTest.php tests/Feature/Api/AdminPlatformApiTest.php tests/Feature/Api/AdminCollectionApiTest.php tests/Feature/Api/AdminPageApiTest.php tests/Feature/Api/AdminDiscountApiTest.php tests/Feature/Api/AdminStoreSettingsApiTest.php tests/Feature/Api/AdminShippingSettingsApiTest.php tests/Feature/Api/AdminTaxSettingsApiTest.php tests/Feature/Api/AdminSearchIndexApiTest.php tests/Feature/Api/AdminAnalyticsSummaryApiTest.php tests/Feature/Api/AdminThemeApiTest.php tests/Feature/Api/AdminOrderExportApiTest.php tests/Feature/Admin/AppsDevelopersTest.php tests/Feature/Admin/OrderManagementTest.php tests/Feature/Foundation/AuthorizationTest.php tests/Feature/Security/HtmlSanitizationTest.php tests/Feature/Webhooks/WebhookDeliveryTest.php`\n  Result: `94 passed (1107 assertions)`.\n\n- Ran storefront order API coverage:\n  `php artisan test --compact tests/Feature/Api/StorefrontOrderApiTest.php`\n  Result: `3 passed (32 assertions)`.\n\n- Ran order service idempotency coverage:\n  `php artisan test --compact tests/Feature/Orders/OrderServiceTest.php`\n  Result: `7 passed (42 assertions)`.\n\nStatic checks matched the post-audit intent:\n\n- Checkout routes now use checkout-id confirmation: [routes/web.php](/Users/fabianwesner/Herd/shop/routes/web.php:62).\n- Confirmation still gates access by customer session or `last_order_id`: [Confirmation.php](/Users/fabianwesner/Herd/shop/app/Livewire/Storefront/Checkout/Confirmation.php:41).\n- Admin API routes require bearer auth plus ability middleware: [routes/api.php](/Users/fabianwesner/Herd/shop/routes/api.php:76).\n- Admin API middleware enforces both store role and token abilities: [AuthenticateAdminApi.php](/Users/fabianwesner/Herd/shop/app/Http/Middleware/AuthenticateAdminApi.php:33).\n- Livewire admin order mutation actions call policies before state changes: [Show.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Orders/Show.php:70).\n- HTML sanitizer allowlists tags/attributes and blocks unsafe schemes/dangerous elements: [SanitizeHtml.php](/Users/fabianwesner/Herd/shop/app/Actions/SanitizeHtml.php:15).\n- Checkout completion idempotency returns an existing order before dispatching new events: [OrderService.php](/Users/fabianwesner/Herd/shop/app/Services/OrderService.php:53).\n\nOne caveat: I attempted `php artisan test --compact tests/Browser/Storefront/CheckoutTest.php`, but the local browser run produced no output for about a minute and had to be killed. I did not count that as an app regression because the targeted feature tests covering the same routing and checkout behavior passed. No files were edited."}}
</subagent_notification>


message: The QA engineer’s pass did not find regressions in their targeted areas, but the code reviewer’s two security findings are still blockers. I’ve started the token store-scoping migration and will finish both fixes, then rerun the focused suites and the full verification subset.


text: The QA engineer’s pass did not find regressions in their targeted areas, but the code reviewer’s two security findings are still blockers. I’ve started the token store-scoping migration and will finish both fixes, then rerun the focused suites and the full verification subset.


cmd: php artisan make:class Support/CheckoutAccessToken --no-interaction


output:
Chunk ID: c7aa0d
Wall time: 0.1525 seconds
Process exited with code 0
Original token count: 20
Output:

   INFO  Class [app/Support/CheckoutAccessToken.php] created successfully.


command: php artisan make:class Support/CheckoutAccessToken --no-interaction

type: unknown

aggregated_output: INFO  Class [app/Support/CheckoutAccessToken.php] created successfully.


cmd: sed -n '1,110p' app/Models/Checkout.php app/Models/Cart.php


command: sed -n '1,110p' app/Models/Checkout.php app/Models/Cart.php

aggregated_output:
<?php

namespace App\Models;

use App\Enums\CheckoutStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Checkout extends Model
{
    /** @use HasFactory<\Database\Factories\CheckoutFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'cart_id',
        'customer_id',
        'status',
        'payment_method',
        'email',
        'shipping_address_json',
        'billing_address_json',
        'shipping_method_id',
        'discount_code',
        'tax_provider_snapshot_json',
        'totals_json',
        'expires_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'started',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return BelongsTo<Cart, $this>
     */
    public function cart(): BelongsTo
    {
        return $this->belongsTo(Cart::class);
    }

    /**
     * @return BelongsTo<Customer, $this>
     */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /**
     * @return BelongsTo<ShippingRate, $this>
     */
    public function shippingRate(): BelongsTo
    {
        return $this->belongsTo(ShippingRate::class, 'shipping_method_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => CheckoutStatus::class,
            'shipping_address_json' => 'array',
            'billing_address_json' => 'array',
            'shipping_method_id' => 'integer',
            'tax_provider_snapshot_json' => 'array',
            'totals_json' => 'array',
            'expires_at' => 'datetime',
        ];
    }
}
<?php

namespace App\Models;

use App\Enums\CartStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Cart extends Model
{
    /** @use HasFactory<\Database\Factories\CartFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',


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

namespace App\Models;

use App\Enums\CheckoutStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Checkout extends Model
{
    /** @use HasFactory<\Database\Factories\CheckoutFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'cart_id',
        'customer_id',
        'status',
        'payment_method',
        'email',
        'shipping_address_json',
        'billing_address_json',
        'shipping_method_id',
        'discount_code',
        'tax_provider_snapshot_json',
        'totals_json',
        'expires_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'started',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return BelongsTo<Cart, $this>
     */
    public function cart(): BelongsTo
    {
        return $this->belongsTo(Cart::class);
    }

    /**
     * @return BelongsTo<Customer, $this>
     */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /**
     * @return BelongsTo<ShippingRate, $this>
     */
    public function shippingRate(): BelongsTo
    {
        return $this->belongsTo(ShippingRate::class, 'shipping_method_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => CheckoutStatus::class,
            'shipping_address_json' => 'array',
            'billing_address_json' => 'array',
            'shipping_method_id' => 'integer',
            'tax_provider_snapshot_json' => 'array',
            'totals_json' => 'array',
            'expires_at' => 'datetime',
        ];
    }
}
<?php

namespace App\Models;

use App\Enums\CartStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Cart extends Model
{
    /** @use HasFactory<\Database\Factories\CartFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',


cmd: rg -n "PersonalAccessToken::query\(\)->create|createApiToken\(" app tests database


command: rg -n "PersonalAccessToken::query\(\)->create|createApiToken\(" app tests database

type: search

cmd: rg -n "PersonalAccessToken::query\\(\\)->create|createApiToken\\(" app tests database

query: PersonalAccessToken::query\(\)->create|createApiToken\(

path: app

aggregated_output:
tests/Pest.php:69:        ->createApiToken($store, 'Test API token', $abilities, $user);
app/Services/WebhookService.php:65:    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): array
app/Services/WebhookService.php:74:        $token = PersonalAccessToken::query()->create([
app/Livewire/Admin/Developers/Index.php:72:        $result = $webhooks->createApiToken($this->scopedStore(), $this->newTokenName, $this->tokenAbilities, $user);


output:
Chunk ID: 9401f1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 121
Output:
tests/Pest.php:69:        ->createApiToken($store, 'Test API token', $abilities, $user);
app/Services/WebhookService.php:65:    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): array
app/Services/WebhookService.php:74:        $token = PersonalAccessToken::query()->create([
app/Livewire/Admin/Developers/Index.php:72:        $result = $webhooks->createApiToken($this->scopedStore(), $this->newTokenName, $this->tokenAbilities, $user);


status: completed

input:
*** Begin Patch
*** Update File: app/Models/PersonalAccessToken.php
@@
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
@@
     protected $fillable = [
+        'store_id',
         'tokenable_type',
         'tokenable_id',
@@
     ];

+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
*** End Patch


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

unified_diff:
@@ -5,2 +5,3 @@
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -13,2 +14,3 @@
     protected $fillable = [
+        'store_id',
         'tokenable_type',
@@ -23,2 +25,10 @@
     /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
      * @return MorphTo<Model, $this>


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


status: completed

input:
*** Begin Patch
*** Update File: app/Services/WebhookService.php
@@
         $plainText = 'shop_'.Str::random(48);
         $token = PersonalAccessToken::query()->create([
+            'store_id' => $store->getKey(),
             'tokenable_type' => $user->getMorphClass(),
             'tokenable_id' => $user->getKey(),
*** End Patch


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

unified_diff:
@@ -74,2 +74,3 @@
         $token = PersonalAccessToken::query()->create([
+            'store_id' => $store->getKey(),
             'tokenable_type' => $user->getMorphClass(),


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


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Middleware/AuthenticateAdminApi.php
@@
         if (! $user instanceof User || ! $token instanceof PersonalAccessToken) {
             return response()->json(['message' => 'Unauthenticated.'], 401);
         }

+        if ((int) $token->store_id !== $store->getKey()) {
+            return response()->json(['message' => 'Forbidden.'], 403);
+        }
+
         if (! $this->userCanAccessStore($user, $store, $abilities)) {
             return response()->json(['message' => 'Forbidden.'], 403);
         }
*** End Patch


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

unified_diff:
@@ -32,2 +32,6 @@

+        if ((int) $token->store_id !== $store->getKey()) {
+            return response()->json(['message' => 'Forbidden.'], 403);
+        }
+
         if (! $this->userCanAccessStore($user, $store, $abilities)) {


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


status: completed

input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Developers/Index.php
@@
         return PersonalAccessToken::query()
+            ->where('store_id', $this->storeId)
             ->where('tokenable_type', (new User)->getMorphClass())
             ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
@@
         return PersonalAccessToken::query()
+            ->where('store_id', $this->storeId)
             ->where('tokenable_type', (new User)->getMorphClass())
             ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
*** End Patch


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

unified_diff:
@@ -150,2 +150,3 @@
         return PersonalAccessToken::query()
+            ->where('store_id', $this->storeId)
             ->where('tokenable_type', (new User)->getMorphClass())
@@ -185,2 +186,3 @@
         return PersonalAccessToken::query()
+            ->where('store_id', $this->storeId)
             ->where('tokenable_type', (new User)->getMorphClass())


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


status: completed

input:
*** Begin Patch
*** Update File: app/Support/CheckoutAccessToken.php
@@
 <?php

 namespace App\Support;

-class CheckoutAccessToken
+use App\Models\Checkout;
+use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Str;
+
+class CheckoutAccessToken
 {
+    public static function make(Checkout $checkout): string
+    {
+        return hash_hmac('sha256', self::payload($checkout), self::key());
+    }
+
+    public static function valid(Checkout $checkout, ?string $token): bool
+    {
+        if ($token === null || $token === '') {
+            return false;
+        }
+
+        return hash_equals(self::make($checkout), $token);
+    }
+
+    private static function payload(Checkout $checkout): string
+    {
+        return implode('|', [
+            $checkout->store_id,
+            $checkout->getKey(),
+            $checkout->cart_id,
+            $checkout->created_at?->timestamp ?? 0,
+        ]);
+    }
+
+    private static function key(): string
+    {
+        $key = (string) Config::get('app.key');
+
+        return Str::startsWith($key, 'base64:')
+            ? base64_decode(Str::after($key, 'base64:'), true) ?: $key
+            : $key;
+    }
 }
*** End Patch


output:
apply_patch verification failed: Failed to find expected lines in /Users/fabianwesner/Herd/shop/app/Support/CheckoutAccessToken.php:
<?php

namespace App\Support;

class CheckoutAccessToken
{
}


cmd: sed -n '1,80p' app/Support/CheckoutAccessToken.php


command: sed -n '1,80p' app/Support/CheckoutAccessToken.php

aggregated_output:
<?php

namespace App\Support;

class CheckoutAccessToken
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}


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

namespace App\Support;

class CheckoutAccessToken
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}


status: completed

input:
*** Begin Patch
*** Update File: app/Support/CheckoutAccessToken.php
@@
 namespace App\Support;

+use App\Models\Checkout;
+use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Str;
+
 class CheckoutAccessToken
 {
-    /**
-     * Create a new class instance.
-     */
-    public function __construct()
+    public static function make(Checkout $checkout): string
     {
-        //
+        return hash_hmac('sha256', self::payload($checkout), self::key());
+    }
+
+    public static function valid(Checkout $checkout, ?string $token): bool
+    {
+        if ($token === null || $token === '') {
+            return false;
+        }
+
+        return hash_equals(self::make($checkout), $token);
+    }
+
+    private static function payload(Checkout $checkout): string
+    {
+        return implode('|', [
+            $checkout->store_id,
+            $checkout->getKey(),
+            $checkout->cart_id,
+            $checkout->created_at?->timestamp ?? 0,
+        ]);
+    }
+
+    private static function key(): string
+    {
+        $key = (string) Config::get('app.key');
+
+        return Str::startsWith($key, 'base64:')
+            ? base64_decode(Str::after($key, 'base64:'), true) ?: $key
+            : $key;
     }
 }
*** End Patch


stdout:
Success. Updated the following files:
M app/Support/CheckoutAccessToken.php

unified_diff:
@@ -4,10 +4,39 @@

+use App\Models\Checkout;
+use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Str;
+
 class CheckoutAccessToken
 {
-    /**
-     * Create a new class instance.
-     */
-    public function __construct()
+    public static function make(Checkout $checkout): string
+    {
+        return hash_hmac('sha256', self::payload($checkout), self::key());
+    }
+
+    public static function valid(Checkout $checkout, ?string $token): bool
     {
-        //
+        if ($token === null || $token === '') {
+            return false;
+        }
+
+        return hash_equals(self::make($checkout), $token);
+    }
+
+    private static function payload(Checkout $checkout): string
+    {
+        return implode('|', [
+            $checkout->store_id,
+            $checkout->getKey(),
+            $checkout->cart_id,
+            $checkout->created_at?->timestamp ?? 0,
+        ]);
+    }
+
+    private static function key(): string
+    {
+        $key = (string) Config::get('app.key');
+
+        return Str::startsWith($key, 'base64:')
+            ? base64_decode(Str::after($key, 'base64:'), true) ?: $key
+            : $key;
     }


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


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Resources/Storefront/V1/CheckoutResource.php
@@
 namespace App\Http\Resources\Storefront\V1;

+use App\Support\CheckoutAccessToken;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
@@
             'id' => $this->id,
+            'access_token' => CheckoutAccessToken::make($this->resource),
             'store_id' => $this->store_id,
*** End Patch


stdout:
Success. Updated the following files:
M app/Http/Resources/Storefront/V1/CheckoutResource.php

unified_diff:
@@ -4,2 +4,3 @@

+use App\Support\CheckoutAccessToken;
 use Illuminate\Http\Request;
@@ -18,2 +19,3 @@
             'id' => $this->id,
+            'access_token' => CheckoutAccessToken::make($this->resource),
             'store_id' => $this->store_id,


output: {"output":"Success. Updated the following files:\nM app/Http/Resources/Storefront/V1/CheckoutResource.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Storefront/V1/CheckoutController.php
@@
 use App\Models\Order;
 use App\Models\ShippingRate;
+use App\Models\Store;
 use App\Services\CheckoutService;
 use App\Services\PricingEngine;
 use App\Services\ShippingCalculator;
+use App\Support\CheckoutAccessToken;
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
 use Illuminate\Support\Collection;
@@
     public function store(StoreCheckoutRequest $request, CheckoutService $checkouts, PricingEngine $pricing): CheckoutResource|JsonResponse
     {
         $cart = Cart::query()->findOrFail($request->validated('cart_id'));
+        abort_unless((int) $cart->store_id === $this->currentStore()->getKey(), 404);

         try {
@@
-    public function show(Checkout $checkout): CheckoutResource
+    public function show(Request $request, Checkout $checkout): CheckoutResource
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         return CheckoutResource::make($this->loadCheckout($checkout));
     }

     public function address(SetCheckoutAddressRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         $addressData = $request->validated();
         $addressData['email'] ??= $checkout->email;
@@
     public function shippingMethod(SetCheckoutShippingRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         try {
@@
     public function applyDiscount(ApplyCheckoutDiscountRequest $request, Checkout $checkout, PricingEngine $pricing): CheckoutResource|JsonResponse
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         $checkout->forceFill([
@@
-    public function destroyDiscount(Checkout $checkout, PricingEngine $pricing): CheckoutResource
+    public function destroyDiscount(Request $request, Checkout $checkout, PricingEngine $pricing): CheckoutResource
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
         abort_if($checkout->discount_code === null, 404);
@@
     public function paymentMethod(SelectCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         try {
@@
     public function pay(CompleteCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): OrderResource|JsonResponse
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         try {
@@
     }

+    private function authorizeCheckoutAccess(Request $request, Checkout $checkout): void
+    {
+        abort_unless((int) $checkout->store_id === $this->currentStore()->getKey(), 404);
+        abort_unless(CheckoutAccessToken::valid($checkout, $this->tokenFromRequest($request)), 404);
+    }
+
+    private function tokenFromRequest(Request $request): ?string
+    {
+        $token = $request->query('token');
+
+        if (is_string($token) && $token !== '') {
+            return $token;
+        }
+
+        $token = $request->header('X-Checkout-Token');
+
+        return is_string($token) && $token !== '' ? $token : null;
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
     private function loadCheckout(Checkout $checkout): Checkout
*** End Patch


stdout:
Success. Updated the following files:
M app/Http/Controllers/Api/Storefront/V1/CheckoutController.php

unified_diff:
@@ -22,2 +22,3 @@
 use App\Models\ShippingRate;
+use App\Models\Store;
 use App\Services\CheckoutService;
@@ -25,3 +26,5 @@
 use App\Services\ShippingCalculator;
+use App\Support\CheckoutAccessToken;
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
 use Illuminate\Support\Collection;
@@ -33,2 +36,3 @@
         $cart = Cart::query()->findOrFail($request->validated('cart_id'));
+        abort_unless((int) $cart->store_id === $this->currentStore()->getKey(), 404);

@@ -49,4 +53,6 @@

-    public function show(Checkout $checkout): CheckoutResource
+    public function show(Request $request, Checkout $checkout): CheckoutResource
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         return CheckoutResource::make($this->loadCheckout($checkout));
@@ -56,2 +62,4 @@
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         $addressData = $request->validated();
@@ -68,2 +76,4 @@
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         try {
@@ -80,2 +90,4 @@
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         $checkout->forceFill([
@@ -99,4 +111,5 @@

-    public function destroyDiscount(Checkout $checkout, PricingEngine $pricing): CheckoutResource
+    public function destroyDiscount(Request $request, Checkout $checkout, PricingEngine $pricing): CheckoutResource
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
         abort_if($checkout->discount_code === null, 404);
@@ -111,2 +124,4 @@
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         try {
@@ -123,2 +138,4 @@
     {
+        $this->authorizeCheckoutAccess($request, $checkout);
+
         try {
@@ -145,2 +162,30 @@

+    private function authorizeCheckoutAccess(Request $request, Checkout $checkout): void
+    {
+        abort_unless((int) $checkout->store_id === $this->currentStore()->getKey(), 404);
+        abort_unless(CheckoutAccessToken::valid($checkout, $this->tokenFromRequest($request)), 404);
+    }
+
+    private function tokenFromRequest(Request $request): ?string
+    {
+        $token = $request->query('token');
+
+        if (is_string($token) && $token !== '') {
+            return $token;
+        }
+
+        $token = $request->header('X-Checkout-Token');
+
+        return is_string($token) && $token !== '' ? $token : null;
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
     private function loadCheckout(Checkout $checkout): Checkout


output: {"output":"Success. Updated the following files:\nM app/Http/Controllers/Api/Storefront/V1/CheckoutController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/StorefrontCheckoutApiTest.php
@@
 function storefrontApiCheckoutAddress(string $country = 'DE'): array
 {
@@
     ];
 }

+function storefrontApiCheckoutUrl(int $checkoutId, string $token, string $suffix = ''): string
+{
+    return "/api/storefront/v1/checkouts/{$checkoutId}{$suffix}?token={$token}";
+}
+
 test('storefront checkout api progresses through address shipping discount removal and payment selection', function (): void {
@@

     $checkoutId = $checkoutResponse['data']['id'];
+    $checkoutToken = $checkoutResponse['data']['access_token'];

     $addressResponse = $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [
             'shipping_address' => storefrontApiCheckoutAddress(),
         ]);
@@
     $shippingRateId = $addressResponse['data']['available_shipping_rates'][0]['id'];

     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/shipping-method'), [
             'shipping_rate_id' => $shippingRateId,
         ])
@@
     $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'SAVE10'])
+        ->postJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/apply-discount'), ['code' => 'SAVE10'])
         ->assertOk()
@@
     $api()
-        ->deleteJson("/api/storefront/v1/checkouts/{$checkoutId}/discount")
+        ->deleteJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/discount'))
         ->assertOk()
@@
     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/payment-method", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/payment-method'), [
             'payment_method' => 'credit_card',
         ])
@@
     $checkoutId = $api()
         ->postJson('/api/storefront/v1/checkouts', [
             'cart_id' => $cartId,
             'email' => 'buyer@example.test',
         ])
         ->assertCreated()['data']['id'];
+    $checkoutToken = Checkout::withoutGlobalScopes()
+        ->findOrFail($checkoutId)
+        ->pipe(fn (Checkout $checkout): string => \App\Support\CheckoutAccessToken::make($checkout));

     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [
             'shipping_address' => array_diff_key(storefrontApiCheckoutAddress(), ['first_name' => true]),
         ])
@@
     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [
             'shipping_address' => storefrontApiCheckoutAddress(),
         ])
@@
     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/shipping-method'), [
             'shipping_rate_id' => $otherStoreRate->getKey(),
         ])
         ->assertUnprocessable();

     $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'NOTREAL'])
+        ->postJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/apply-discount'), ['code' => 'NOTREAL'])
         ->assertUnprocessable()
         ->assertJsonPath('reason', 'discount_not_found');
 });
+
+test('storefront checkout api requires the checkout access token', function (): void {
+    $store = storefrontApiCheckoutStore();
+    $variant = storefrontApiCheckoutVariant($store);
+    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.23'])->withHeader('Host', 'shop.test');
+
+    $cartId = $api()
+        ->postJson('/api/storefront/v1/carts')
+        ->assertCreated()['data']['id'];
+
+    $api()
+        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
+            'variant_id' => $variant->getKey(),
+            'quantity' => 1,
+        ])
+        ->assertCreated();
+
+    $checkoutResponse = $api()
+        ->postJson('/api/storefront/v1/checkouts', [
+            'cart_id' => $cartId,
+            'email' => 'buyer@example.test',
+        ])
+        ->assertCreated();
+
+    $checkoutId = $checkoutResponse['data']['id'];
+    $checkoutToken = $checkoutResponse['data']['access_token'];
+
+    $api()
+        ->getJson("/api/storefront/v1/checkouts/{$checkoutId}")
+        ->assertNotFound();
+
+    $api()
+        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address?token=bad-token", [
+            'shipping_address' => storefrontApiCheckoutAddress(),
+        ])
+        ->assertNotFound();
+
+    $api()
+        ->getJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken))
+        ->assertOk()
+        ->assertJsonPath('data.id', $checkoutId)
+        ->assertJsonPath('data.email', 'buyer@example.test');
+});
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Api/StorefrontCheckoutApiTest.php

unified_diff:
@@ -63,2 +63,7 @@

+function storefrontApiCheckoutUrl(int $checkoutId, string $token, string $suffix = ''): string
+{
+    return "/api/storefront/v1/checkouts/{$checkoutId}{$suffix}?token={$token}";
+}
+
 test('storefront checkout api progresses through address shipping discount removal and payment selection', function (): void {
@@ -93,5 +98,6 @@
     $checkoutId = $checkoutResponse['data']['id'];
+    $checkoutToken = $checkoutResponse['data']['access_token'];

     $addressResponse = $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [
             'shipping_address' => storefrontApiCheckoutAddress(),
@@ -108,3 +114,3 @@
     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/shipping-method'), [
             'shipping_rate_id' => $shippingRateId,
@@ -117,3 +123,3 @@
     $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'SAVE10'])
+        ->postJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/apply-discount'), ['code' => 'SAVE10'])
         ->assertOk()
@@ -123,3 +129,3 @@
     $api()
-        ->deleteJson("/api/storefront/v1/checkouts/{$checkoutId}/discount")
+        ->deleteJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/discount'))
         ->assertOk()
@@ -129,3 +135,3 @@
     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/payment-method", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/payment-method'), [
             'payment_method' => 'credit_card',
@@ -165,5 +171,8 @@
         ->assertCreated()['data']['id'];
+    $checkoutToken = Checkout::withoutGlobalScopes()
+        ->findOrFail($checkoutId)
+        ->pipe(fn (Checkout $checkout): string => \App\Support\CheckoutAccessToken::make($checkout));

     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [
             'shipping_address' => array_diff_key(storefrontApiCheckoutAddress(), ['first_name' => true]),
@@ -174,3 +183,3 @@
     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [
             'shipping_address' => storefrontApiCheckoutAddress(),
@@ -184,3 +193,3 @@
     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
+        ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/shipping-method'), [
             'shipping_rate_id' => $otherStoreRate->getKey(),
@@ -190,3 +199,3 @@
     $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'NOTREAL'])
+        ->postJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/apply-discount'), ['code' => 'NOTREAL'])
         ->assertUnprocessable()
@@ -194 +203,44 @@
 });
+
+test('storefront checkout api requires the checkout access token', function (): void {
+    $store = storefrontApiCheckoutStore();
+    $variant = storefrontApiCheckoutVariant($store);
+    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.23'])->withHeader('Host', 'shop.test');
+
+    $cartId = $api()
+        ->postJson('/api/storefront/v1/carts')
+        ->assertCreated()['data']['id'];
+
+    $api()
+        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
+            'variant_id' => $variant->getKey(),
+            'quantity' => 1,
+        ])
+        ->assertCreated();
+
+    $checkoutResponse = $api()
+        ->postJson('/api/storefront/v1/checkouts', [
+            'cart_id' => $cartId,
+            'email' => 'buyer@example.test',
+        ])
+        ->assertCreated();
+
+    $checkoutId = $checkoutResponse['data']['id'];
+    $checkoutToken = $checkoutResponse['data']['access_token'];
+
+    $api()
+        ->getJson("/api/storefront/v1/checkouts/{$checkoutId}")
+        ->assertNotFound();
+
+    $api()
+        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address?token=bad-token", [
+            'shipping_address' => storefrontApiCheckoutAddress(),
+        ])
+        ->assertNotFound();
+
+    $api()
+        ->getJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken))
+        ->assertOk()
+        ->assertJsonPath('data.id', $checkoutId)
+        ->assertJsonPath('data.email', 'buyer@example.test');
+});


output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/StorefrontCheckoutApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/StorefrontCheckoutApiTest.php
@@
-    $checkoutId = $api()
+    $checkoutResponse = $api()
         ->postJson('/api/storefront/v1/checkouts', [
             'cart_id' => $cartId,
             'email' => 'buyer@example.test',
         ])
-        ->assertCreated()['data']['id'];
-    $checkoutToken = Checkout::withoutGlobalScopes()
-        ->findOrFail($checkoutId)
-        ->pipe(fn (Checkout $checkout): string => \App\Support\CheckoutAccessToken::make($checkout));
+        ->assertCreated();
+    $checkoutId = $checkoutResponse['data']['id'];
+    $checkoutToken = $checkoutResponse['data']['access_token'];
*** End Patch


unified_diff:
@@ -165,3 +165,3 @@

-    $checkoutId = $api()
+    $checkoutResponse = $api()
         ->postJson('/api/storefront/v1/checkouts', [
@@ -170,6 +170,5 @@
         ])
-        ->assertCreated()['data']['id'];
-    $checkoutToken = Checkout::withoutGlobalScopes()
-        ->findOrFail($checkoutId)
-        ->pipe(fn (Checkout $checkout): string => \App\Support\CheckoutAccessToken::make($checkout));
+        ->assertCreated();
+    $checkoutId = $checkoutResponse['data']['id'];
+    $checkoutToken = $checkoutResponse['data']['access_token'];


output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/StorefrontCheckoutApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/StorefrontOrderApiTest.php
@@
- * @return array{0: int, 1: ProductVariant}
+ * @return array{0: int, 1: ProductVariant, 2: string}
  */
 function storefrontOrderApiCheckout(object $testCase, string $remoteAddress = '10.0.0.41'): array
@@
-    $checkoutId = $api()
+    $checkoutResponse = $api()
         ->postJson('/api/storefront/v1/checkouts', [
             'cart_id' => $cartId,
             'email' => 'buyer@example.test',
         ])
-        ->assertCreated()['data']['id'];
+        ->assertCreated();
+    $checkoutId = $checkoutResponse['data']['id'];
+    $checkoutToken = $checkoutResponse['data']['access_token'];

     $addressResponse = $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
+        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address?token={$checkoutToken}", [
             'shipping_address' => [
                 'first_name' => 'Test',
@@

     $api()
-        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
+        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method?token={$checkoutToken}", [
             'shipping_rate_id' => $addressResponse['data']['available_shipping_rates'][0]['id'],
         ])
         ->assertOk();

-    return [$checkoutId, $variant];
+    return [$checkoutId, $variant, $checkoutToken];
 }

 test('storefront order api pays a checkout and exposes token-gated order lookup', function (): void {
-    [$checkoutId] = storefrontOrderApiCheckout($this);
+    [$checkoutId, , $checkoutToken] = storefrontOrderApiCheckout($this);
     $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.41'])->withHeader('Host', 'shop.test');

     $payResponse = $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
+        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", [
             'payment_method' => 'credit_card',
@@
 test('storefront order api returns the same order when checkout payment is retried', function (): void {
-    [$checkoutId, $variant] = storefrontOrderApiCheckout($this, '10.0.0.43');
+    [$checkoutId, $variant, $checkoutToken] = storefrontOrderApiCheckout($this, '10.0.0.43');
@@
     $firstResponse = $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
+        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", $payload)
         ->assertOk();
     $secondResponse = $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
+        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", $payload)
         ->assertOk();
@@
 test('storefront order api returns payment failures and releases reservations', function (): void {
-    [$checkoutId, $variant] = storefrontOrderApiCheckout($this, '10.0.0.42');
+    [$checkoutId, $variant, $checkoutToken] = storefrontOrderApiCheckout($this, '10.0.0.42');
@@
     $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
+        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", [
             'payment_method' => 'credit_card',
*** End Patch