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

<?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');


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


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

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

Question: PersonalAccessToken|personal_access_tokens|Admin API|revoke

<?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}


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


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

AdminCatalogApiTest.php

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,


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.


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


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

@@ -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}}


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


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.


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.


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

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


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


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

<?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',


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


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

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

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

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);


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


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

@@ -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}}


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


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

@@ -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}}


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


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

@@ -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}}


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


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

@@ -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}}


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
{
}


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


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

<?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()
    {
        //
    }
}


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


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

@@ -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}}


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


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

@@ -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}}


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


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

@@ -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}}


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


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

@@ -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}}


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


@@ -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}}


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


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

@@ -48,3 +48,3 @@
 /**
- * @return array{0: int, 1: ProductVariant}
+ * @return array{0: int, 1: ProductVariant, 2: string}
  */
@@ -67,3 +67,3 @@

-    $checkoutId = $api()
+    $checkoutResponse = $api()
         ->postJson('/api/storefront/v1/checkouts', [
@@ -72,6 +72,8 @@
         ])
-        ->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' => [
@@ -88,3 +90,3 @@
     $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'],
@@ -93,3 +95,3 @@

-    return [$checkoutId, $variant];
+    return [$checkoutId, $variant, $checkoutToken];
 }
@@ -97,3 +99,3 @@
 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');
@@ -101,3 +103,3 @@
     $payResponse = $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
+        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", [
             'payment_method' => 'credit_card',
@@ -130,3 +132,3 @@
 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');
     $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.43'])->withHeader('Host', 'shop.test');
@@ -141,6 +143,6 @@
     $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();
@@ -156,3 +158,3 @@
 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 = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.42'])->withHeader('Host', 'shop.test');
@@ -160,3 +162,3 @@
     $api()
-        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
+        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", [
             'payment_method' => 'credit_card',


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


Input:
*** Begin Patch
*** Update File: tests/Feature/Foundation/DatabaseConstraintTest.php
@@
             'id',
+            'store_id',
             'tokenable_type',
@@
         ]))->toBeTrue()
         ->and(Schema::hasIndex('personal_access_tokens', ['token'], 'unique'))->toBeTrue()
-        ->and(Schema::hasIndex('personal_access_tokens', ['tokenable_type', 'tokenable_id']))->toBeTrue();
+        ->and(Schema::hasIndex('personal_access_tokens', ['tokenable_type', 'tokenable_id']))->toBeTrue()
+        ->and(Schema::hasIndex('personal_access_tokens', ['store_id', 'tokenable_type', 'tokenable_id']))->toBeTrue();
 });
*** End Patch


Success. Updated the following files:
M tests/Feature/Foundation/DatabaseConstraintTest.php

@@ -30,2 +30,3 @@
             'id',
+            'store_id',
             'tokenable_type',
@@ -41,3 +42,4 @@
         ->and(Schema::hasIndex('personal_access_tokens', ['token'], 'unique'))->toBeTrue()
-        ->and(Schema::hasIndex('personal_access_tokens', ['tokenable_type', 'tokenable_id']))->toBeTrue();
+        ->and(Schema::hasIndex('personal_access_tokens', ['tokenable_type', 'tokenable_id']))->toBeTrue()
+        ->and(Schema::hasIndex('personal_access_tokens', ['store_id', 'tokenable_type', 'tokenable_id']))->toBeTrue();
 });


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


Input:
*** Begin Patch
*** Update File: tests/Feature/Admin/AppsDevelopersTest.php
@@
     $initialTokenCount = PersonalAccessToken::query()
+        ->where('store_id', $store->getKey())
         ->where('tokenable_type', (new User)->getMorphClass())
         ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
@@
     expect(PersonalAccessToken::query()
+        ->where('store_id', $store->getKey())
         ->where('tokenable_type', (new User)->getMorphClass())
*** End Patch


Success. Updated the following files:
M tests/Feature/Admin/AppsDevelopersTest.php

@@ -63,2 +63,3 @@
     $initialTokenCount = PersonalAccessToken::query()
+        ->where('store_id', $store->getKey())
         ->where('tokenable_type', (new User)->getMorphClass())
@@ -82,2 +83,3 @@
     expect(PersonalAccessToken::query()
+        ->where('store_id', $store->getKey())
         ->where('tokenable_type', (new User)->getMorphClass())


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


Input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminCatalogApiTest.php
@@
     $this->actingAs(adminCatalogApiUser())
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products")
         ->assertUnauthorized();

-    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
+    $user = adminCatalogApiUser();
+    $this->withToken(adminApiBearerToken($store, ['read-products'], $user))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
         ->assertOk()
@@
-    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
+    $this->withToken(adminApiBearerToken($store, ['read-products'], $user))
         ->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);
+
+    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();
+    $otherStore->users()->syncWithoutDetaching([
+        $user->getKey() => [
+            'role' => 'owner',
+            'created_at' => now(),
+        ],
+    ]);
+
+    $this->withToken(adminApiBearerToken($store, ['read-products'], $user))
+        ->getJson("/api/admin/v1/stores/{$otherStore->getKey()}/products")
+        ->assertForbidden();
 });
*** End Patch


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

@@ -70,3 +70,4 @@

-    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
+    $user = adminCatalogApiUser();
+    $this->withToken(adminApiBearerToken($store, ['read-products'], $user))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
@@ -78,3 +79,3 @@

-    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
+    $this->withToken(adminApiBearerToken($store, ['read-products'], $user))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
@@ -84,2 +85,14 @@
         ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50);
+
+    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();
+    $otherStore->users()->syncWithoutDetaching([
+        $user->getKey() => [
+            'role' => 'owner',
+            'created_at' => now(),
+        ],
+    ]);
+
+    $this->withToken(adminApiBearerToken($store, ['read-products'], $user))
+        ->getJson("/api/admin/v1/stores/{$otherStore->getKey()}/products")
+        ->assertForbidden();
 });


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


Both fixes are implemented in code now: Admin API tokens are tied to `store_id`, and checkout API reads/mutations require the checkout access token returned by checkout creation. I’m formatting and running the focused security/API tests next.


php artisan test --compact tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Api/StorefrontOrderApiTest.php tests/Feature/Api/AdminCatalogApiTest.php tests/Feature/Admin/AppsDevelopersTest.php tests/Feature/Foundation/DatabaseConstraintTest.php


Output:
Chunk ID: 1a26e9
Wall time: 4.2151 seconds
Process exited with code 0
Original token count: 5
Output:
{"result":"pass"}


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


Output:
Chunk ID: 86f0bb
Wall time: 7.8563 seconds
Process exited with code 0
Original token count: 21
Output:

  ..................

  Tests:    18 passed (163 assertions)
  Duration: 7.63s


<subagent_notification>
{"agent_path":"019df484-b3d2-7523-82a6-e0d7fa983a14","status":{"completed":"Found critical/high blockers. I did not edit files.\n\n**Findings**\n\n- **Critical: full verification suite fails under the project’s default test command.**  \n  [tests/Pest.php](/Users/fabianwesner/Herd/shop/tests/Pest.php:14) includes both `Feature` and `Browser` suites in the default Pest run, while [composer.json](/Users/fabianwesner/Herd/shop/composer.json:61) runs `@php artisan test` without a memory override. A standalone `php artisan test --compact` reproducibly exits with `Allowed memory size of 134217728 bytes exhausted` at [vendor/pestphp/pest-plugin-browser/src/Playwright/InitScript.php](/Users/fabianwesner/Herd/shop/vendor/pestphp/pest-plugin-browser/src/Playwright/InitScript.php:17). This contradicts the completion claim in [specs/progress.md](/Users/fabianwesner/Herd/shop/specs/progress.md:713).  \n  Repro: run `php artisan test --compact`.\n\n- **High: required audit logging is not implemented.**  \n  The spec requires structured audit logging, auth/resource/security event coverage, and 90-day retention in [specs/06-AUTH-AND-SECURITY.md](/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md:975) and [specs/06-AUTH-AND-SECURITY.md](/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md:1109). The app only defines a generic single-file JSON channel at [config/logging.php](/Users/fabianwesner/Herd/shop/config/logging.php:68), and app event registration in [app/Providers/AppServiceProvider.php](/Users/fabianwesner/Herd/shop/app/Providers/AppServiceProvider.php:187) only wires webhook dispatch events. I found no audit logger/listeners for auth, admin resource changes, or security events.  \n  Verify: perform admin login + product update, then inspect configured logs for `auth.login` / `product.updated` audit entries.\n\n- **High: Support users can view dashboard analytics despite the permission matrix denying analytics access.**  \n  The matrix says `Analytics | View` is `N` for Support at [specs/06-AUTH-AND-SECURITY.md](/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md:365). The admin group applies `CheckStoreRole` without role arguments via [routes/web.php](/Users/fabianwesner/Herd/shop/routes/web.php:129), and that middleware allows any store role when no roles are passed at [app/Http/Middleware/CheckStoreRole.php](/Users/fabianwesner/Herd/shop/app/Http/Middleware/CheckStoreRole.php:27). The dashboard then loads KPI/order/funnel data without authorization at [app/Livewire/Admin/Dashboard.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Dashboard.php:72).  \n  Repro: create a `support` store user, authenticate, request `/admin`; expected `403`, actual dashboard data renders.\n\n- **High: Admin API does not use the required `api.admin` named limiter/per-token behavior.**  \n  Spec requires admin API middleware `throttle:api.admin` at [specs/02-API-ROUTES.md](/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md:1208) and defines `api.admin` as 60/min by user/token/IP at [specs/06-AUTH-AND-SECURITY.md](/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md:738). Routes use generic `throttle:60,1` at [routes/api.php](/Users/fabianwesner/Herd/shop/routes/api.php:67) and [routes/api.php](/Users/fabianwesner/Herd/shop/routes/api.php:76), while [app/Providers/AppServiceProvider.php](/Users/fabianwesner/Herd/shop/app/Providers/AppServiceProvider.php:124) defines storefront/search/checkout/analytics/webhook limiters but no `api.admin`.  \n  Verify: inspect route middleware or make 61 admin API requests with two tokens from one IP; expected token/user-keyed limiter, actual generic IP-style limiter.\n\n- **High: platform management is authorized by ordinary store ownership, not a platform super-admin boundary.**  \n  Spec labels platform endpoints “Super-Admin Only” at [specs/02-API-ROUTES.md](/Users/fabianwesner/Herd/shop/specs/02-API-ROUTES.md:1218), and `manage-platform` is “super-admin only” at [specs/06-AUTH-AND-SECURITY.md](/Users/fabianwesner/Herd/shop/specs/06-AUTH-AND-SECURITY.md:263). [app/Http/Middleware/AuthenticatePlatformApi.php](/Users/fabianwesner/Herd/shop/app/Http/Middleware/AuthenticatePlatformApi.php:37) grants platform access to any user who is `owner` on any store. The test at [tests/Feature/Api/AdminPlatformApiTest.php](/Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminPlatformApiTest.php:36) codifies that behavior.  \n  Repro: issue a `manage-platform` token for a normal store owner and POST `/api/admin/v1/platform/organizations`; expected `403` unless platform super-admin, actual `201`.\n\n**Verification run**\n\n- `php artisan migrate:fresh --seed --no-interaction` passed.\n- `php artisan test --compact` failed twice with the Pest Browser memory fatal above."}}
</subagent_notification>


The new QA report adds more blockers. I’m treating the test-command memory failure, missing audit logging, support dashboard access, admin API limiter, and platform super-admin boundary as required fixes before completion.


logging channels stack custom log channel context

events listeners authentication login logout failed

rate limiter named limiter api middleware request user token

authorization policies livewire component authorize