output:
Chunk ID: 89a0b8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 7220
Output:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    $this->actingAs(adminCatalogApiUser())
        ->getJson('/oauth/authorize?client_id=test&redirect_uri=https://example.test/callback&response_type=code&scope=read-products&state=state')
        ->assertStatus(501)
        ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.');

    $this->postJson('/oauth/token', [
        'grant_type' => 'authorization_code',
        'client_id' => 'test',
        'client_secret' => 'secret',
        'redirect_uri' => 'https://example.test/callback',
        'code' => 'code',
    ])
        ->assertStatus(501)
        ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.');

    $this->getJson("/api/apps/v1/stores/{$store->getKey()}/products")
        ->assertStatus(501)
        ->assertJsonPath('message', 'App API endpoints are deferred for initial implementation.');
});
<?php

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentShipmentStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Livewire\Admin\Orders\Index as AdminOrdersIndex;
use App\Livewire\Admin\Orders\Show as AdminOrderShow;
use App\Models\Fulfillment;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Payment;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

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

function adminOrderManagementStore(): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $store);

    return $store;
}

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

/**
 * @return array{0: Order, 1: OrderLine, 2: ProductVariant}
 */
function adminOrderManagementOrder(Store $store, array $orderAttributes = [], int $quantity = 2, int $unitPrice = 2500): array
{
    $product = Product::factory()
        ->withDefaultVariant($unitPrice)
        ->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update([
            'quantity_on_hand' => 10,
            'quantity_reserved' => 0,
        ]);

    $total = $quantity * $unitPrice;
    $order = Order::factory()->paid()->create(array_merge([
        'store_id' => $store->getKey(),
        'subtotal_amount' => $total,
        'shipping_amount' => 0,
        'tax_amount' => 0,
        'total_amount' => $total,
        'email' => 'buyer@example.test',
    ], $orderAttributes));
    $line = OrderLine::factory()->create([
        'order_id' => $order->getKey(),
        'product_id' => $product->getKey(),
        'variant_id' => $variant->getKey(),
        'title_snapshot' => 'Admin Test Product',
        'sku_snapshot' => 'ADMIN-TEST-001',
        'quantity' => $quantity,
        'unit_price_amount' => $unitPrice,
        'total_amount' => $total,
    ]);

    Payment::factory()->create([
        'order_id' => $order->getKey(),
        'method' => $order->payment_method,
        'status' => $order->financial_status === FinancialStatus::Pending ? PaymentStatus::Pending : PaymentStatus::Captured,
        'amount' => $total,
        'currency' => $order->currency,
    ]);

    return [$order, $line, $variant];
}

test('admin order routes require authentication and render scoped orders', function () {
    $store = adminOrderManagementStore();
    [$order] = adminOrderManagementOrder($store, [
        'order_number' => '#7001',
        'email' => 'alpha@example.test',
    ]);

    $otherStore = Store::factory()->create();
    adminOrderManagementOrder($otherStore, [
        'order_number' => '#9001',
        'email' => 'other@example.test',
    ]);

    $this->get('/admin/orders')->assertRedirect('/admin/login');

    $user = adminOrderManagementUser();

    $this->actingAs($user)
        ->get('/admin/orders')
        ->assertSuccessful()
        ->assertSee('#7001')
        ->assertDontSee('#9001');

    $this->actingAs($user)
        ->get('/admin/orders/'.$order->getKey())
        ->assertSuccessful()
        ->assertSee('#7001')
        ->assertSee('Admin Test Product');
});

test('admin order index filters by search and status dimensions', function () {
    $store = adminOrderManagementStore();
    $user = adminOrderManagementUser();
    adminOrderManagementOrder($store, [
        'order_number' => '#7001',
        'email' => 'alpha@example.test',
    ]);
    adminOrderManagementOrder($store, [
        'order_number' => '#7002',
        'email' => 'bravo@example.test',
        'status' => OrderStatus::Pending,
        'financial_status' => FinancialStatus::Pending,
        'payment_method' => PaymentMethod::BankTransfer,
    ]);

    Livewire::actingAs($user)
        ->test(AdminOrdersIndex::class)
        ->assertSee('#7001')
        ->assertSee('#7002')
        ->set('search', 'alpha')
        ->assertSee('#7001')
        ->assertDontSee('#7002')
        ->set('search', '')
        ->set('financialStatusFilter', 'pending')
        ->assertSee('#7002')
        ->assertDontSee('#7001');
});

test('admin order detail confirms bank transfer payments', function () {
    $store = adminOrderManagementStore();
    $user = adminOrderManagementUser();
    [$order, $line, $variant] = adminOrderManagementOrder($store, [
        'payment_method' => PaymentMethod::BankTransfer,
        'status' => OrderStatus::Pending,
        'financial_status' => FinancialStatus::Pending,
    ]);

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update(['quantity_reserved' => $line->quantity]);

    Livewire::actingAs($user)
        ->test(AdminOrderShow::class, ['order' => $order])
        ->call('confirmBankTransferPayment')
        ->assertHasNoErrors();

    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($order->refresh()->status)->toBe(OrderStatus::Paid)
        ->and($order->financial_status)->toBe(FinancialStatus::Paid)
        ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Captured)
        ->and($inventory->quantity_on_hand)->toBe(8)
        ->and($inventory->quantity_reserved)->toBe(0);
});

test('admin order detail processes refunds and fulfillment transitions', function () {
    $store = adminOrderManagementStore();
    $user = adminOrderManagementUser();
    [$order, $line] = adminOrderManagementOrder($store, quantity: 2, unitPrice: 2500);

    Livewire::actingAs($user)
        ->test(AdminOrderShow::class, ['order' => $order])
        ->set('refundAmount', '10.00')
        ->set('refundReason', 'Customer return')
        ->call('processRefund')
        ->assertHasNoErrors();

    expect($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded)
        ->and($order->refunds()->first()?->amount)->toBe(1000);

    Livewire::actingAs($user)
        ->test(AdminOrderShow::class, ['order' => $order->refresh()])
        ->set("fulfillmentLineQuantities.{$line->getKey()}", 1)
        ->set('trackingCompany', 'DHL')
        ->set('trackingNumber', 'DHL123')
        ->call('createFulfillment')
        ->assertHasNoErrors();

    $fulfillment = Fulfillment::query()->where('order_id', $order->getKey())->firstOrFail();

    expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial)
        ->and($fulfillment->lines()->first()?->quantity)->toBe(1);

    Livewire::actingAs($user)
        ->test(AdminOrderShow::class, ['order' => $order->refresh()])
        ->call('markFulfillmentShipped', $fulfillment->getKey())
        ->call('markFulfillmentDelivered', $fulfillment->getKey())
        ->assertHasNoErrors();

    expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Delivered)
        ->and($fulfillment->delivered_at)->not->toBeNull();
});

test('admin order detail rejects orders from another store', function () {
    $store = adminOrderManagementStore();
    $otherStore = Store::factory()->create();
    $user = adminOrderManagementUser();
    [$otherOrder] = adminOrderManagementOrder($otherStore, [
        'order_number' => '#9001',
    ]);

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

    Livewire::actingAs($user)
        ->test(AdminOrderShow::class, ['order' => $otherOrder])
        ->assertStatus(404);
});
<?php

use App\Enums\NavigationItemType;
use App\Enums\PageStatus;
use App\Enums\StoreUserRole;
use App\Enums\ThemeStatus;
use App\Livewire\Admin\Navigation\Index as AdminNavigationIndex;
use App\Livewire\Admin\Pages\Form as AdminPageForm;
use App\Livewire\Admin\Pages\Index as AdminPagesIndex;
use App\Livewire\Admin\Themes\Editor as AdminThemeEditor;
use App\Livewire\Admin\Themes\Index as AdminThemesIndex;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeSettings;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;

uses(RefreshDatabase::class);

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

function adminContentStore(): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $store);

    return $store;
}

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

function adminContentUserWithRole(Store $store, StoreUserRole $role): User
{
    $user = User::factory()->create();
    $user->stores()->attach($store->getKey(), [
        'role' => $role->value,
        'created_at' => now(),
    ]);

    return $user;
}

test('content routes render store scoped pages navigation and themes', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    Page::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Page',
    ]);
    $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    $this->get('/admin/pages')->assertRedirect('/admin/login');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/pages')
        ->assertSuccessful()
        ->assertSee('About')
        ->assertDontSee('Other Store Page');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/pages/create')
        ->assertSuccessful()
        ->assertSee('Create page');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/navigation')
        ->assertSuccessful()
        ->assertSee('Main Menu');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/themes')
        ->assertSuccessful()
        ->assertSee($theme->name);

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/themes/'.$theme->getKey().'/editor')
        ->assertSuccessful()
        ->assertSee('Sections');
});

test('admin pages can be created searched edited and deleted', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();

    Livewire::actingAs($user)
        ->test(AdminPageForm::class)
        ->set('title', 'Sizing Guide')
        ->set('handle', 'sizing-guide')
        ->set('bodyHtml', '
Measure twice.
')
        ->set('status', PageStatus::Published->value)
        ->call('save')
        ->assertHasNoErrors();

    $page = Page::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'sizing-guide')
        ->firstOrFail();

    expect($page->status)->toBe(PageStatus::Published)
        ->and($page->published_at)->not->toBeNull();

    Livewire::actingAs($user)
        ->test(AdminPagesIndex::class)
        ->set('search', 'sizing')
        ->assertSee('Sizing Guide')
        ->assertDontSee('About');

    Livewire::actingAs($user)
        ->test(AdminPageForm::class, ['page' => $page])
        ->set('title', 'Size Guide')
        ->set('status', PageStatus::Draft->value)
        ->call('save')
        ->assertHasNoErrors();

    expect($page->refresh()->title)->toBe('Size Guide')
        ->and($page->status)->toBe(PageStatus::Draft);

    Livewire::actingAs($user)
        ->test(AdminPageForm::class, ['page' => $page])
        ->call('deletePage')
        ->assertRedirect(route('admin.pages.index', absolute: false));

    expect(Page::withoutGlobalScopes()->whereKey($page->getKey())->exists())->toBeFalse();
});

test('navigation menu items can be edited and persisted in order', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    $menu = NavigationMenu::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'main-menu')
        ->firstOrFail();
    $initialItemCount = $menu->items()->count();
    $component = Livewire::actingAs($user)
        ->test(AdminNavigationIndex::class)
        ->call('selectMenu', $menu->getKey());
    $shopKey = collect($component->get('menuItems'))->firstWhere('label', 'Shop')['key'];

    $component
        ->set('itemLabel', 'Lookbook')
        ->set('itemParentKey', $shopKey)
        ->set('itemType', NavigationItemType::Link->value)
        ->set('itemUrl', '/lookbook')
        ->call('saveItem');

    $lookbookKey = collect($component->get('menuItems'))->firstWhere('label', 'Lookbook')['key'];

    $component
        ->call('reorderItem', $lookbookKey, 0, $shopKey)
        ->call('saveMenu')
        ->assertHasNoErrors();

    $items = NavigationItem::withoutGlobalScopes()
        ->where('menu_id', $menu->getKey())
        ->orderBy('position')
        ->get();
    $shop = $items->firstWhere('label', 'Shop');
    $lookbook = $items->firstWhere('label', 'Lookbook');

    expect($items)->toHaveCount($initialItemCount + 1)
        ->and($lookbook?->parent_id)->toBe($shop?->getKey())
        ->and($lookbook?->position)->toBe(0);

    $shopIndex = collect($component->get('menuItems'))
        ->search(fn (array $item): bool => $item['label'] === 'Shop');

    expect($shopIndex)->not->toBeFalse();

    $component
        ->call('removeItem', (int) $shopIndex)
        ->call('saveMenu')
        ->assertHasNoErrors();

    expect(NavigationItem::withoutGlobalScopes()
        ->where('menu_id', $menu->getKey())
        ->where('label', 'Shop')
        ->exists())->toBeFalse()
        ->and(NavigationItem::withoutGlobalScopes()
            ->where('menu_id', $menu->getKey())
            ->where('label', 'Lookbook')
            ->exists())->toBeFalse();
});

test('themes can be duplicated edited and published', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    Livewire::actingAs($user)
        ->test(AdminThemesIndex::class)
        ->call('duplicateTheme', $published->getKey())
        ->assertHasNoErrors();

    $copy = Theme::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('name', $published->name.' Copy')
        ->firstOrFail();
    $publishedFile = $published->files()->withoutGlobalScopes()->firstOrFail();
    $copyFile = $copy->files()->withoutGlobalScopes()->where('path', $publishedFile->path)->firstOrFail();

    expect($copyFile->storage_key)->not->toBe($publishedFile->storage_key)
        ->and(Storage::disk('local')->get($copyFile->storage_key))->toBe(Storage::disk('local')->get($publishedFile->storage_key));

    Livewire::actingAs($user)
        ->test(AdminThemeEditor::class, ['theme' => $copy])
        ->set('settings.home.hero.heading', 'New Hero')
        ->call('save')
        ->call('publish')
        ->assertHasNoErrors();

    expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $copy->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('New Hero')
        ->and($copy->refresh()->status)->toBe(ThemeStatus::Published)
        ->and($published->refresh()->status)->toBe(ThemeStatus::Draft)
        ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1);
});

test('theme editor saves file contents and metadata', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
    $file = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail();

    Storage::disk('local')->put($file->storage_key, 'Original theme file');

    Livewire::actingAs($user)
        ->test(AdminThemeEditor::class, ['theme' => $theme])
        ->call('selectFile', $file->getKey())
        ->assertSet('selectedFileId', $file->getKey())
        ->assertSet('fileContents', 'Original theme file')
        ->set('fileContents', '
Edited hero file
')
        ->call('saveFile')
        ->assertHasNoErrors()
        ->assertSee('Theme file saved');

    expect(Storage::disk('local')->get($file->storage_key))->toBe('
Edited hero file
')
        ->and($file->refresh()->sha256)->toBe(hash('sha256', '
Edited hero file
'))
        ->and($file->byte_size)->toBe(strlen('
Edited hero file
'));
});

test('content management honors store roles and store scoping', function (): void {
    $store = adminContentStore();
    $support = adminContentUserWithRole($store, StoreUserRole::Support);
    $staff = adminContentUserWithRole($store, StoreUserRole::Staff);
<?php

use App\Enums\MediaStatus;
use App\Enums\StoreUserRole;
use App\Jobs\ProcessMediaUpload;
use App\Livewire\Admin\Products\Form as ProductForm;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\Store;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;

uses(RefreshDatabase::class);

function productMediaAdminUser(Store $store): User
{
    $user = User::factory()->create();
    $user->stores()->attach($store->getKey(), [
        'role' => StoreUserRole::Admin->value,
    ]);

    return $user;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Queue::assertNothingPushed();
});

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

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

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

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

        return $item;
    });

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

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

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

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

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

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

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


message: I see the sanitizer worker has only partially landed its slice so far, including a placeholder test. I’m going to integrate and finish that work locally now rather than wait on incomplete edits, while keeping the webhook worker’s completed changes.

phase: commentary


type: message

role: assistant

text: I see the sanitizer worker has only partially landed its slice so far, including a placeholder test. I’m going to integrate and finish that work locally now rather than wait on incomplete edits, while keeping the webhook worker’s completed changes.


role: user

text:
<subagent_notification>
{"agent_path":"019df445-bda2-7231-b5a0-beb4b22cbb48","status":"shutdown"}
</subagent_notification>


message: The sanitizer worker is stopped so I can own those edits cleanly. I’m integrating the partially applied sanitizer changes, then I’ll add tests for the security and authorization regressions that QA identified.


text: The sanitizer worker is stopped so I can own those edits cleanly. I’m integrating the partially applied sanitizer changes, then I’ll add tests for the security and authorization regressions that QA identified.


cmd: find app/Events -maxdepth 1 -type f -print -exec sed -n '1,160p' {} \;


cmd: sed -n '1,280p' tests/Feature/Webhooks/WebhookDeliveryTest.php


cmd: git diff -- app/Services/WebhookService.php app/Http/Middleware/AuthenticateAdminApi.php app/Http/Middleware/AuthenticatePlatformApi.php config/auth.php routes/api.php app/Livewire/Admin/Developers/Index.php resources/views/livewire/admin/developers/index.blade.php app/Providers/AppServiceProvider.php


cmd: sed -n '1,320p' app/Http/Controllers/Api/Admin/V1/ProductController.php && sed -n '1,180p' app/Http/Controllers/Api/Admin/V1/PageController.php && sed -n '1,160p' app/Livewire/Admin/Pages/Form.php


command: sed -n '1,280p' tests/Feature/Webhooks/WebhookDeliveryTest.php

WebhookDeliveryTest.php

type: read

aggregated_output:
<?php

use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Jobs\DeliverWebhook;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\TaxSettings;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use App\Services\CartService;
use App\Services\CheckoutService;
use App\Services\ProductService;
use App\Services\WebhookService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

uses(RefreshDatabase::class);

function webhookActiveSubscription(Store $store, WebhookEventType $eventType): WebhookSubscription
{
    return WebhookSubscription::factory()->create([
        'store_id' => $store->getKey(),
        'event_type' => $eventType,
        'status' => WebhookSubscriptionStatus::Active,
    ]);
}

function expectQueuedWebhookDelivery(WebhookSubscription $subscription, WebhookEventType $eventType, callable $payloadMatches): void
{
    $delivery = $subscription->deliveries()->sole();

    expect($delivery)->toBeInstanceOf(WebhookDelivery::class)
        ->and($delivery->status)->toBe(WebhookDeliveryStatus::Pending)
        ->and($delivery->attempt_count)->toBe(1);

    Queue::assertPushed(DeliverWebhook::class, function (DeliverWebhook $job) use ($delivery, $eventType, $payloadMatches): bool {
        return $job->deliveryId === $delivery->getKey()
            && $job->eventType === $eventType->value
            && data_get($job->payload, 'id') === $delivery->event_id
            && data_get($job->payload, 'event_type') === $eventType->value
            && $payloadMatches($job->payload);
    });
    Queue::assertPushed(DeliverWebhook::class, 1);
}

function webhookCheckoutStore(): Store
{
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    TaxSettings::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'mode' => 'manual',
        'provider' => 'none',
        'prices_include_tax' => false,
        'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false],
    ]);

    return $store;
}

function webhookCheckoutVariant(Store $store): ProductVariant
{
    $product = Product::factory()
        ->withDefaultVariant(2500)
        ->create(['store_id' => $store->getKey()]);

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

    $variant->forceFill([
        'requires_shipping' => false,
        'weight_g' => 0,
    ])->save();

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update([
            'quantity_on_hand' => 10,
            'quantity_reserved' => 0,
        ]);

    return $variant->refresh();
}

test('dispatch creates delivery records and queues matching active subscriptions', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()->create();
    $subscription = WebhookSubscription::factory()->create([
        'store_id' => $store->getKey(),
        'event_type' => WebhookEventType::OrderCreated,
        'status' => WebhookSubscriptionStatus::Active,
    ]);
    WebhookSubscription::factory()->create([
        'store_id' => $store->getKey(),
        'event_type' => WebhookEventType::ProductUpdated,
        'status' => WebhookSubscriptionStatus::Active,
    ]);

    app(WebhookService::class)->dispatch($store, WebhookEventType::OrderCreated->value, [
        'order' => ['id' => 10],
    ]);

    Queue::assertPushed(DeliverWebhook::class, function (DeliverWebhook $job) use ($subscription): bool {
        return $job->eventType === WebhookEventType::OrderCreated->value
            && WebhookDelivery::query()->whereKey($job->deliveryId)->where('subscription_id', $subscription->getKey())->exists();
    });
    Queue::assertPushed(DeliverWebhook::class, 1);

    expect($subscription->deliveries()->count())->toBe(1);
});

test('product creation queues product created webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);
    $subscription = webhookActiveSubscription($store, WebhookEventType::ProductCreated);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Webhook Draft Product',
        'price_amount' => 1500,
    ]);

    expectQueuedWebhookDelivery(
        $subscription,
        WebhookEventType::ProductCreated,
        fn (array $payload): bool => data_get($payload, 'data.product.id') === $product->getKey()
            && data_get($payload, 'data.product.title') === 'Webhook Draft Product',
    );
});

test('product updates queue product updated webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()-…660 chars truncated…product.title') === 'Updated Product',
    );
});

test('product archive queues product deleted webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);
    $product = app(ProductService::class)->create($store, [
        'title' => 'Archived Product',
        'price_amount' => 1500,
    ]);
    $subscription = webhookActiveSubscription($store, WebhookEventType::ProductDeleted);

    app(ProductService::class)->transitionStatus($product, \App\Enums\ProductStatus::Archived);

    expectQueuedWebhookDelivery(
        $subscription,
        WebhookEventType::ProductDeleted,
        fn (array $payload): bool => data_get($payload, 'data.product.id') === $product->getKey()
            && data_get($payload, 'data.product.status') === \App\Enums\ProductStatus::Archived->value,
    );
});

test('checkout completion queues checkout completed webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = webhookCheckoutStore();
    $variant = webhookCheckoutVariant($store);
    $cart = app(CartService::class)->create($store);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    $checkout = app(CheckoutService::class)->createFromCart($cart);
    $checkout = app(CheckoutService::class)->setAddress($checkout, [
        'email' => 'buyer@example.test',
        'shipping_address' => [
            'first_name' => 'Test',
            'last_name' => 'Buyer',
            'address1' => 'Main Street 1',
            'city' => 'Berlin',
            'country' => 'DE',
            'postal_code' => '10115',
        ],
    ]);
    $checkout = app(CheckoutService::class)->setShippingMethod($checkout, null);
    $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, 'credit_card');
    $subscription = webhookActiveSubscription($store, WebhookEventType::CheckoutCompleted);

    $order = app(CheckoutService::class)->completeCheckout($checkout, [
        'card_number' => '4242424242424242',
    ]);

    expectQueuedWebhookDelivery(
        $subscription,
        WebhookEventType::CheckoutCompleted,
        fn (array $payload): bool => data_get($payload, 'data.checkout.id') === $checkout->getKey()
            && data_get($payload, 'data.checkout.email') === 'buyer@example.test'
            && data_get($payload, 'data.checkout.total_amount') === 5000
            && data_get($payload, 'data.order.id') === $order->getKey(),
    );
});

test('deliver webhook posts signed json payload and records success', function (): void {
    Http::preventStrayRequests();
    Http::fake([
        'https://example.com/webhooks/orders' => Http::response(['ok' => true], 200),
    ]);

    $subscription = WebhookSubscription::factory()->create([
        'target_url' => 'https://example.com/webhooks/orders',
        'signing_secret_encrypted' => 'whsec_test_secret',
    ]);
    $delivery = WebhookDelivery::factory()->create([
        'subscription_id' => $subscription->getKey(),
        'status' => WebhookDeliveryStatus::Pending,
    ]);
    $payload = [
        'id' => $delivery->event_id,
        'api_version' => '2026-05',
        'event_type' => WebhookEventType::OrderCreated->value,
        'store_id' => $subscription->store_id,
        'data' => ['order' => ['id' => 10]],
    ];

    (new DeliverWebhook($delivery->getKey(), WebhookEventType::OrderCreated->value, $payload))
        ->handle(app(WebhookService::class));

    Http::assertSent(function ($request) use ($delivery, $subscription): bool {
        $timestamp = $request->header('X-Platform-Timestamp')[0] ?? '';
        $signature = $request->header('X-Platform-Signature')[0] ?? '';

        return $request->url() === 'https://example.com/webhooks/orders'
            && $request->header('X-Platform-Event')[0] === WebhookEventType::OrderCreated->value
            && $request->header('X-Platform-Delivery-Id')[0] === $delivery->event_id
            && app(WebhookService::class)->verify($timestamp.'.'.$request->body(), $signature, $subscription->fresh()->signing_secret_encrypted);
    });

    $delivery->refresh();

    expect($delivery->status)->toBe(WebhookDeliveryStatus::Success)
        ->and($delivery->response_code)->toBe(200)
        ->and($delivery->attempt_count)->toBe(1);
});

test('failed deliveries pause subscription after five consecutive failures', function (): void {
    Http::preventStrayRequests();
    Http::fake([
        'https://example.com/webhooks/failing' => Http::response('nope', 500),
    ]);

    $subscription = WebhookSubscription::factory()->create([
        'target_url' => 'https://example.com/webhooks/failing',
        'status' => WebhookSubscriptionStatus::Active,
    ]);

    WebhookDelivery::factory()->count(4)->create([
        'subscription_id' => $subscription->getKey(),
        'status' => WebhookDeliveryStatus::Failed,
        'last_attempt_at' => now()->subMinutes(5),
    ]);

status: completed


command: sed -n '1,320p' app/Http/Controllers/Api/Admin/V1/ProductController.php && sed -n '1,180p' app/Http/Controllers/Api/Admin/V1/PageController.php && sed -n '1,160p' app/Livewire/Admin/Pages/Form.php

Form.php

aggregated_output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Actions\SanitizeHtml;
use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\InvalidProductTransitionException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Admin\V1\CreateProductMediaUploadRequest;
use App\Http\Requests\Api\Admin\V1\StoreProductRequest;
use App\Http\Requests\Api\Admin\V1\UpdateProductRequest;
use App\Http\Resources\Admin\V1\ProductResource;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use RuntimeException;
use Throwable;

class ProductController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])],
            'query' => ['nullable', 'string', 'max:255'],
            'collection_id' => ['nullable', 'integer'],
            'sort' => ['nullable', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $query = Product::withoutGlobalScopes()
            ->with([
                'media',
                'variants.inventoryItem',
            ])
            ->withCount('variants')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'collection_id'), function (Builder $query, int $collectionId): void {
                $query->whereHas('collections', fn (Builder $query) => $query->withoutGlobalScopes()->whereKey($collectionId));
            })
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $like = '%'.$search.'%';

                $query->where(function (Builder $query) use ($like): void {
                    $query
                        ->where('title', 'like', $like)
                        ->orWhere('vendor', 'like', $like)
                        ->orWhereHas('variants', fn (Builder $query) => $query->withoutGlobalScopes()->where('sku', 'like', $like));
                });
            });

        $this->applySort($query, (string) data_get($validated, 'sort', 'updated_at_desc'));

        return ProductResource::collection($query->paginate((int) data_get($validated, 'per_page', 25)));
    }

    public function show(Request $request, Store $store, Product $product): ProductResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessProductBelongsToStore($product, $store);

        return ProductResource::make($this->loadProduct($product));
    }

    public function store(StoreProductRequest $request, Store $store, ProductService $products): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validated();

        try {
            $product = DB::transaction(function () use ($products, $store, $validated): Product {
                $product = $products->create($store, $this->attributesForCreate($validated, $store));

                if (array_key_exists('collections', $validated)) {
                    $product->collections()->sync($this->integerList($validated['collections']));
                }

                return $product->refresh();
            });
        } catch (InvalidProductTransitionException|InvalidArgumentException|RuntimeException $exception) {
            $this->throwProductValidationException($exception);
        }

        return ProductResource::make($this->loadProduct($product))
            ->response()
            ->setStatusCode(201);
    }

    public function update(UpdateProductRequest $request, Store $store, Product $product, ProductService $products): ProductResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessProductBelongsToStore($product, $store);

        $validated = $request->validated();

        try {
            $product = DB::transaction(function () use ($products, $store, $product, $validated): Product {
                $product = Produ…13395 chars truncated…        'published_at' => $this->publishedAtForCreate($validated, $status),
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated, Page $page): array
    {
        $attributes = Arr::only($validated, ['title', 'body_html']);

        if (array_key_exists('body_html', $attributes)) {
            $attributes['body_html'] = $this->sanitizeHtml($attributes['body_html']);
        }
<?php

namespace App\Livewire\Admin\Pages;

use App\Actions\SanitizeHtml;
use App\Enums\PageStatus;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Services\NavigationService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;

class Form extends Component
{
    use AuthorizesRequests;

    public ?Page $page = null;

    public string $title = '';

    public string $handle = '';

    public string $bodyHtml = '';

    public string $status = 'draft';

    public string $publishedAt = '';

    public string $actionMessage = '';

    public function mount(?Page $page = null): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        if ($page?->exists) {
            abort_unless((int) $page->store_id === $store->getKey(), 404);

            $this->authorize('update', $page);

            $this->page = $page;
            $this->fillFromPage($page);

            return;
        }

        $this->authorize('create', Page::class);
    }

    public function updatedTitle(string $title): void
    {
        if ($this->page instanceof Page || $this->handle !== '') {
            return;
        }

        $this->handle = Str::slug($title);
    }

    public function save(NavigationService $navigation): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorizeSave();

        $this->handle = Str::slug($this->handle);

        $this->validate([
            'title' => ['required', 'string', 'max:255'],
            'handle' => [
                'required',
                'string',
                'max:255',
                'alpha_dash',
                Rule::unique('pages', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($this->page?->getKey()),
            ],
            'bodyHtml' => ['nullable', 'string'],
            'status' => ['required', Rule::in(array_column(PageStatus::cases(), 'value'))],
            'publishedAt' => ['nullable', 'date'],
        ], [], [
            'bodyHtml' => 'body',
            'publishedAt' => 'published at',
        ]);

        $publishedAt = $this->publishedAt !== '' ? $this->publishedAt : null;

        if ($this->status === PageStatus::Published->value && $publishedAt === null) {
            $publishedAt = now();
        }

        $page = $this->page instanceof Page
            ? tap($this->page)->update($this->payload($store, $publishedAt))
            : Page::withoutGlobalScopes()->create($this->payload($store, $publishedAt));

        $this->page = $page->refresh();
        $this->fillFromPage($this->page);
        $this->forgetNavigation($store, $navigation);

        $this->actionMessage = 'Page saved';

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

    public function deletePage(NavigationService $navigation): void
    {
        abort_unless($this->page instanceof Page, 404);

        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('delete', $this->page);

        $this->page->delete();
        $this->forgetNavigation($store, $navigation);

        $this->redirectRoute('admin.pages.index', navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.admin.pages.form', [
            'isEditing' => $this->page instanceof Page,
        ])->layout('layouts.app', [
            'title' => $this->page ? __('Edit page') : __('Create page'),
        ]);
    }

    private function fillFromPage(Page $page): void
    {
        $this->title = $page->title;
        $this->handle = $page->handle;
        $this->bodyHtml = (string) $page->body_html;
        $this->status = $page->status->value;
        $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i') ?? '';
    }

    /**
     * @return array<string, mixed>
     */
    private function payload(Store $store, mixed $publishedAt): array
    {
        return [
            'store_id' => $store->getKey(),
            'title' => $this->title,
            'handle' => $this->handle,
            'body_html' => $this->sanitizeHtml($this->bodyHtml),
            'status' => PageStatus::from($this->status),
            'published_at' => $publishedAt,
        ];
    }


command: git diff -- app/Services/WebhookService.php app/Http/Middleware/AuthenticateAdminApi.php app/Http/Middleware/AuthenticatePlatformApi.php config/auth.php routes/api.php app/Livewire/Admin/Developers/Index.php resources/views/livewire/admin/developers/index.blade.php app/Providers/AppServiceProvider.php

type: unknown

aggregated_output:
diff --git a/app/Http/Middleware/AuthenticateAdminApi.php b/app/Http/Middleware/AuthenticateAdminApi.php
index 4f532e1a..b34ad250 100644
--- a/app/Http/Middleware/AuthenticateAdminApi.php
+++ b/app/Http/Middleware/AuthenticateAdminApi.php
@@ -2,9 +2,10 @@

 namespace App\Http\Middleware;

-use App\Enums\AppInstallationStatus;
-use App\Models\OauthToken;
+use App\Enums\StoreUserRole;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
 use Closure;
 use Illuminate\Http\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -20,37 +21,16 @@ public function handle(Request $request, Closure $next, string ...$abilities): R
     {
         $store = $this->storeFromRoute($request);

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

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

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

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

@@ -60,9 +40,6 @@ public function handle(Request $request, Closure $next, string ...$abilities): R

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

-        $request->attributes->set('admin_api_oauth_token', $token);
-        app()->instance('admin_api_oauth_token', $token);
-
         return $next($request);
     }

@@ -80,24 +57,64 @@ private function storeFromRoute(Request $request): Store
     /**
      * @param  list<string>  $abilities
      */
-    private function hasAbilities(OauthToken $token, array $abilities): bool
+    private function hasAbilities(PersonalAccessToken $token, array $abilities): bool
     {
         if ($abilities === []) {
             return true;
         }

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

-        if (in_array('*', $tokenAbilities, true)) {
+        return true;
+    }
+
+    /**
+     * @param  list<string>  $abilities
+     */
+    private function userCanAccessStore(User $user, Store $store, array $abilities): bool
+    {
+        $role = $user->roleForStore($store);
+
+        if (! $role instanceof StoreUserRole) {
+            return false;
+        }
+
+        if ($abilities === []) {
             return true;
         }

         foreach ($abilities as $ability) {
-            if (! in_array($ability, $tokenAbilities, true)) {
+            if (! $this->roleAllowsAbility($role, $ability)) {
                 return false;
             }
         }

         return true;
     }
+
+    private function roleAllowsAbility(StoreUserRole $role, string $ability): bool
+    {
+        return match ($ability) {
+            'read-products',
+            'read-orders',
+            'read-customers',
+            'read-collections',
+            'read-discounts' => true,
+            'write-products',
+            'write-collections',
+            'write-discounts',
+            'write-content' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'write-orders' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'read-settings',
+            'write-settings',
+            'write-themes',
+            'read-analytics',
+            'manage-platform' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true),
+            default => false,
+        };
+    }
 }
diff --git a/app/Http/Middleware/AuthenticatePlatformApi.php b/app/Http/Middleware/AuthenticatePlatformApi.php
index a61ddc3c..8b003d15 100644
--- a/app/Http/Middleware/AuthenticatePlatformApi.php
+++ b/app/Http/Middleware/A…8686 chars truncated…PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
 use App\Models\WebhookDelivery;
 use App\Models\WebhookSubscription;
 use Illuminate\Support\Str;
@@ -62,37 +60,22 @@ public function verify(string $payload, string $signature, string $secret): bool

     /**
      * @param  list<string>  $abilities
-     * @return array{token: OauthToken, plain_text: string}
+     * @return array{token: PersonalAccessToken, plain_text: string}
      */
-    public function createApiToken(Store $store, string $name, array $abilities): array
+    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): array
     {
-        $app = AppModel::query()->firstOrCreate(
-            ['name' => 'Admin API'],
-            [
-                'status' => 'active',
-                'created_at' => now(),
-            ],
-        );
-
-        $installation = AppInstallation::withoutGlobalScopes()->firstOrCreate(
-            [
-                'store_id' => $store->getKey(),
-                'app_id' => $app->getKey(),
-            ],
-            [
-                'scopes_json' => $abilities,
-                'status' => AppInstallationStatus::Active,
-                'installed_at' => now(),
-            ],
-        );
-
+        $user ??= auth()->user() instanceof User
+            ? auth()->user()
+            : $store->users()
+                ->wherePivot('role', 'owner')
+                ->firstOrFail();
         $plainText = 'shop_'.Str::random(48);
-        $token = OauthToken::query()->create([
-            'installation_id' => $installation->getKey(),
+        $token = PersonalAccessToken::query()->create([
+            'tokenable_type' => $user->getMorphClass(),
+            'tokenable_id' => $user->getKey(),
             'name' => $name,
-            'access_token_hash' => hash('sha256', $plainText),
-            'refresh_token_hash' => null,
-            'abilities_json' => $abilities,
+            'token' => hash('sha256', $plainText),
+            'abilities' => $abilities,
             'expires_at' => now()->addYear(),
             'created_at' => now(),
         ]);
diff --git a/config/auth.php b/config/auth.php
index 33e9ba97..197dcb67 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -45,6 +45,11 @@
             'driver' => 'session',
             'provider' => 'customers',
         ],
+
+        'sanctum' => [
+            'driver' => 'sanctum-compatible',
+            'provider' => 'users',
+        ],
     ],

     /*
diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php
index 8c838cf2..f9af99d6 100644
--- a/resources/views/livewire/admin/developers/index.blade.php
+++ b/resources/views/livewire/admin/developers/index.blade.php
@@ -38,7 +38,7 @@

                         Name
-                        App
+                        Type
                         Last used
                         Created
                         Actions
@@ -48,7 +48,7 @@
                     @forelse ($tokens as $token)

getKey() }}">
                             {{ $token->name ?? 'API token' }}
-                            {{ $token->installation->app->name }}
+                            Admin API
                             {{ $token->last_used_at?->diffForHumans() ?? 'Never' }}
                             {{ $token->created_at?->toFormattedDateString() ?? 'Unknown' }}

diff --git a/routes/api.php b/routes/api.php
index 013219a0..45241ded 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -67,7 +67,7 @@
 Route::middleware('throttle:60,1')
     ->prefix('admin/v1/platform')
     ->name('api.admin.v1.platform.')
-    ->middleware('platform.api')
+    ->middleware(['auth:sanctum', 'platform.api'])
     ->group(function (): void {
         Route::post('organizations', [AdminPlatformOrganizationController::class, 'store'])->name('organizations.store');
         Route::post('stores', [AdminPlatformStoreController::class, 'store'])->name('stores.store');
@@ -76,6 +76,7 @@
 Route::middleware('throttle:60,1')
     ->prefix('admin/v1/stores/{store}')
     ->name('api.admin.v1.')
+    ->middleware('auth:sanctum')
     ->group(function (): void {
         Route::middleware('admin.api')->group(function (): void {
             Route::get('me', [AdminStoreMembershipController::class, 'show'])->name('stores.me');


command: find app/Events -maxdepth 1 -type f -print -exec sed -n '1,160p' {} \;

cmd: find app/Events -maxdepth 1 -type f -print -exec sed -n '1,160p' '{}' "\\;"

path: Events

aggregated_output:
app/Events/OrderRefunded.php
<?php

namespace App\Events;

use App\Models\Order;
use App\Models\Refund;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderRefunded
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
        public Refund $refund,
    ) {}
}
app/Events/ProductUpdated.php
<?php

namespace App\Events;

use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductUpdated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
    ) {}
}
app/Events/OrderCancelled.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCancelled
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
    ) {}
}
app/Events/ProductCreated.php
<?php

namespace App\Events;

use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
    ) {}
}
app/Events/ProductStatusChanged.php
<?php

namespace App\Events;

use App\Enums\ProductStatus;
use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductStatusChanged
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
        public ProductStatus $from,
        public ProductStatus $to,
    ) {}
}
app/Events/OrderCreated.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
    ) {}
}
app/Events/OrderPaid.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPaid
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
    ) {}
}
app/Events/FulfillmentShipped.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentShipped
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Fulfillment $fulfillment,
    ) {}
}
app/Events/ProductDeleted.php
<?php

namespace App\Events;

use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductDeleted
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
    ) {}
}
app/Events/FulfillmentDelivered.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentDelivered
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Fulfillment $fulfillment,
    ) {}
}
app/Events/FulfillmentCreated.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Fulfillment $fulfillment,
    ) {}
}
app/Events/CheckoutCompleted.php
<?php

namespace App\Events;

use App\Models\Checkout;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class CheckoutCompleted
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Checkout $checkout,
        public Order $order,
    ) {}
}


output:
Chunk ID: 4ca5ef
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 999
Output:
app/Events/OrderRefunded.php
<?php

namespace App\Events;

use App\Models\Order;
use App\Models\Refund;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderRefunded
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
        public Refund $refund,
    ) {}
}
app/Events/ProductUpdated.php
<?php

namespace App\Events;

use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductUpdated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
    ) {}
}
app/Events/OrderCancelled.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCancelled
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
    ) {}
}
app/Events/ProductCreated.php
<?php

namespace App\Events;

use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
    ) {}
}
app/Events/ProductStatusChanged.php
<?php

namespace App\Events;

use App\Enums\ProductStatus;
use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductStatusChanged
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
        public ProductStatus $from,
        public ProductStatus $to,
    ) {}
}
app/Events/OrderCreated.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
    ) {}
}
app/Events/OrderPaid.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPaid
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
    ) {}
}
app/Events/FulfillmentShipped.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentShipped
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Fulfillment $fulfillment,
    ) {}
}
app/Events/ProductDeleted.php
<?php

namespace App\Events;

use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductDeleted
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Product $product,
    ) {}
}
app/Events/FulfillmentDelivered.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentDelivered
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Fulfillment $fulfillment,
    ) {}
}
app/Events/FulfillmentCreated.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Fulfillment $fulfillment,
    ) {}
}
app/Events/CheckoutCompleted.php
<?php

namespace App\Events;

use App\Models\Checkout;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class CheckoutCompleted
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Checkout $checkout,
        public Order $order,
    ) {}
}


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

use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Jobs\DeliverWebhook;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\TaxSettings;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use App\Services\CartService;
use App\Services\CheckoutService;
use App\Services\ProductService;
use App\Services\WebhookService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

uses(RefreshDatabase::class);

function webhookActiveSubscription(Store $store, WebhookEventType $eventType): WebhookSubscription
{
    return WebhookSubscription::factory()->create([
        'store_id' => $store->getKey(),
        'event_type' => $eventType,
        'status' => WebhookSubscriptionStatus::Active,
    ]);
}

function expectQueuedWebhookDelivery(WebhookSubscription $subscription, WebhookEventType $eventType, callable $payloadMatches): void
{
    $delivery = $subscription->deliveries()->sole();

    expect($delivery)->toBeInstanceOf(WebhookDelivery::class)
        ->and($delivery->status)->toBe(WebhookDeliveryStatus::Pending)
        ->and($delivery->attempt_count)->toBe(1);

    Queue::assertPushed(DeliverWebhook::class, function (DeliverWebhook $job) use ($delivery, $eventType, $payloadMatches): bool {
        return $job->deliveryId === $delivery->getKey()
            && $job->eventType === $eventType->value
            && data_get($job->payload, 'id') === $delivery->event_id
            && data_get($job->payload, 'event_type') === $eventType->value
            && $payloadMatches($job->payload);
    });
    Queue::assertPushed(DeliverWebhook::class, 1);
}

function webhookCheckoutStore(): Store
{
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    TaxSettings::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'mode' => 'manual',
        'provider' => 'none',
        'prices_include_tax' => false,
        'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false],
    ]);

    return $store;
}

function webhookCheckoutVariant(Store $store): ProductVariant
{
    $product = Product::factory()
        ->withDefaultVariant(2500)
        ->create(['store_id' => $store->getKey()]);

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

    $variant->forceFill([
        'requires_shipping' => false,
        'weight_g' => 0,
    ])->save();

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update([
            'quantity_on_hand' => 10,
            'quantity_reserved' => 0,
        ]);

    return $variant->refresh();
}

test('dispatch creates delivery records and queues matching active subscriptions', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()->create();
    $subscription = WebhookSubscription::factory()->create([
        'store_id' => $store->getKey(),
        'event_type' => WebhookEventType::OrderCreated,
        'status' => WebhookSubscriptionStatus::Active,
    ]);
    WebhookSubscription::factory()->create([
        'store_id' => $store->getKey(),
        'event_type' => WebhookEventType::ProductUpdated,
        'status' => WebhookSubscriptionStatus::Active,
    ]);

    app(WebhookService::class)->dispatch($store, WebhookEventType::OrderCreated->value, [
        'order' => ['id' => 10],
    ]);

    Queue::assertPushed(DeliverWebhook::class, function (DeliverWebhook $job) use ($subscription): bool {
        return $job->eventType === WebhookEventType::OrderCreated->value
            && WebhookDelivery::query()->whereKey($job->deliveryId)->where('subscription_id', $subscription->getKey())->exists();
    });
    Queue::assertPushed(DeliverWebhook::class, 1);

    expect($subscription->deliveries()->count())->toBe(1);
});

test('product creation queues product created webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);
    $subscription = webhookActiveSubscription($store, WebhookEventType::ProductCreated);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Webhook Draft Product',
        'price_amount' => 1500,
    ]);

    expectQueuedWebhookDelivery(
        $subscription,
        WebhookEventType::ProductCreated,
        fn (array $payload): bool => data_get($payload, 'data.product.id') === $product->getKey()
            && data_get($payload, 'data.product.title') === 'Webhook Draft Product',
    );
});

test('product updates queue product updated webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);
    $product = app(ProductService::class)->create($store, [
        'title' => 'Original Product',
        'price_amount' => 1500,
    ]);
    $subscription = webhookActiveSubscription($store, WebhookEventType::ProductUpdated);

    $updated = app(ProductService::class)->update($product, [
        'title' => 'Updated Product',
    ]);

    expectQueuedWebhookDelivery(
        $subscription,
        WebhookEventType::ProductUpdated,
        fn (array $payload): bool => data_get($payload, 'data.product.id') === $updated->getKey()
            && data_get($payload, 'data.product.title') === 'Updated Product',
    );
});

test('product archive queues product deleted webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);
    $product = app(ProductService::class)->create($store, [
        'title' => 'Archived Product',
        'price_amount' => 1500,
    ]);
    $subscription = webhookActiveSubscription($store, WebhookEventType::ProductDeleted);

    app(ProductService::class)->transitionStatus($product, \App\Enums\ProductStatus::Archived);

    expectQueuedWebhookDelivery(
        $subscription,
        WebhookEventType::ProductDeleted,
        fn (array $payload): bool => data_get($payload, 'data.product.id') === $product->getKey()
            && data_get($payload, 'data.product.status') === \App\Enums\ProductStatus::Archived->value,
    );
});

test('checkout completion queues checkout completed webhooks', function (): void {
    Queue::fake([DeliverWebhook::class]);

    $store = webhookCheckoutStore();
    $variant = webhookCheckoutVariant($store);
    $cart = app(CartService::class)->create($store);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    $checkout = app(CheckoutService::class)->createFromCart($cart);
    $checkout = app(CheckoutService::class)->setAddress($checkout, [
        'email' => 'buyer@example.test',
        'shipping_address' => [
            'first_name' => 'Test',
            'last_name' => 'Buyer',
            'address1' => 'Main Street 1',
            'city' => 'Berlin',
            'country' => 'DE',
            'postal_code' => '10115',
        ],
    ]);
    $checkout = app(CheckoutService::class)->setShippingMethod($checkout, null);
    $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, 'credit_card');
    $subscription = webhookActiveSubscription($store, WebhookEventType::CheckoutCompleted);

    $order = app(CheckoutService::class)->completeCheckout($checkout, [
        'card_number' => '4242424242424242',
    ]);

    expectQueuedWebhookDelivery(
        $subscription,
        WebhookEventType::CheckoutCompleted,
        fn (array $payload): bool => data_get($payload, 'data.checkout.id') === $checkout->getKey()
            && data_get($payload, 'data.checkout.email') === 'buyer@example.test'
            && data_get($payload, 'data.checkout.total_amount') === 5000
            && data_get($payload, 'data.order.id') === $order->getKey(),
    );
});

test('deliver webhook posts signed json payload and records success', function (): void {
    Http::preventStrayRequests();
    Http::fake([
        'https://example.com/webhooks/orders' => Http::response(['ok' => true], 200),
    ]);

    $subscription = WebhookSubscription::factory()->create([
        'target_url' => 'https://example.com/webhooks/orders',
        'signing_secret_encrypted' => 'whsec_test_secret',
    ]);
    $delivery = WebhookDelivery::factory()->create([
        'subscription_id' => $subscription->getKey(),
        'status' => WebhookDeliveryStatus::Pending,
    ]);
    $payload = [
        'id' => $delivery->event_id,
        'api_version' => '2026-05',
        'event_type' => WebhookEventType::OrderCreated->value,
        'store_id' => $subscription->store_id,
        'data' => ['order' => ['id' => 10]],
    ];

    (new DeliverWebhook($delivery->getKey(), WebhookEventType::OrderCreated->value, $payload))
        ->handle(app(WebhookService::class));

    Http::assertSent(function ($request) use ($delivery, $subscription): bool {
        $timestamp = $request->header('X-Platform-Timestamp')[0] ?? '';
        $signature = $request->header('X-Platform-Signature')[0] ?? '';

        return $request->url() === 'https://example.com/webhooks/orders'
            && $request->header('X-Platform-Event')[0] === WebhookEventType::OrderCreated->value
            && $request->header('X-Platform-Delivery-Id')[0] === $delivery->event_id
            && app(WebhookService::class)->verify($timestamp.'.'.$request->body(), $signature, $subscription->fresh()->signing_secret_encrypted);
    });

    $delivery->refresh();

    expect($delivery->status)->toBe(WebhookDeliveryStatus::Success)
        ->and($delivery->response_code)->toBe(200)
        ->and($delivery->attempt_count)->toBe(1);
});

test('failed deliveries pause subscription after five consecutive failures', function (): void {
    Http::preventStrayRequests();
    Http::fake([
        'https://example.com/webhooks/failing' => Http::response('nope', 500),
    ]);

    $subscription = WebhookSubscription::factory()->create([
        'target_url' => 'https://example.com/webhooks/failing',
        'status' => WebhookSubscriptionStatus::Active,
    ]);

    WebhookDelivery::factory()->count(4)->create([
        'subscription_id' => $subscription->getKey(),
        'status' => WebhookDeliveryStatus::Failed,
        'last_attempt_at' => now()->subMinutes(5),
    ]);


output:
Chunk ID: 57a6e0
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 4672
Output:
diff --git a/app/Http/Middleware/AuthenticateAdminApi.php b/app/Http/Middleware/AuthenticateAdminApi.php
index 4f532e1a..b34ad250 100644
--- a/app/Http/Middleware/AuthenticateAdminApi.php
+++ b/app/Http/Middleware/AuthenticateAdminApi.php
@@ -2,9 +2,10 @@

 namespace App\Http\Middleware;

-use App\Enums\AppInstallationStatus;
-use App\Models\OauthToken;
+use App\Enums\StoreUserRole;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
 use Closure;
 use Illuminate\Http\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -20,37 +21,16 @@ public function handle(Request $request, Closure $next, string ...$abilities): R
     {
         $store = $this->storeFromRoute($request);

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

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

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

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

@@ -60,9 +40,6 @@ public function handle(Request $request, Closure $next, string ...$abilities): R

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

-        $request->attributes->set('admin_api_oauth_token', $token);
-        app()->instance('admin_api_oauth_token', $token);
-
         return $next($request);
     }

@@ -80,24 +57,64 @@ private function storeFromRoute(Request $request): Store
     /**
      * @param  list<string>  $abilities
      */
-    private function hasAbilities(OauthToken $token, array $abilities): bool
+    private function hasAbilities(PersonalAccessToken $token, array $abilities): bool
     {
         if ($abilities === []) {
             return true;
         }

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

-        if (in_array('*', $tokenAbilities, true)) {
+        return true;
+    }
+
+    /**
+     * @param  list<string>  $abilities
+     */
+    private function userCanAccessStore(User $user, Store $store, array $abilities): bool
+    {
+        $role = $user->roleForStore($store);
+
+        if (! $role instanceof StoreUserRole) {
+            return false;
+        }
+
+        if ($abilities === []) {
             return true;
         }

         foreach ($abilities as $ability) {
-            if (! in_array($ability, $tokenAbilities, true)) {
+            if (! $this->roleAllowsAbility($role, $ability)) {
                 return false;
             }
         }

         return true;
     }
+
+    private function roleAllowsAbility(StoreUserRole $role, string $ability): bool
+    {
+        return match ($ability) {
+            'read-products',
+            'read-orders',
+            'read-customers',
+            'read-collections',
+            'read-discounts' => true,
+            'write-products',
+            'write-collections',
+            'write-discounts',
+            'write-content' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'write-orders' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true),
+            'read-settings',
+            'write-settings',
+            'write-themes',
+            'read-analytics',
+            'manage-platform' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true),
+            default => false,
+        };
+    }
 }
diff --git a/app/Http/Middleware/AuthenticatePlatformApi.php b/app/Http/Middleware/AuthenticatePlatformApi.php
index a61ddc3c..8b003d15 100644
--- a/app/Http/Middleware/AuthenticatePlatformApi.php
+++ b/app/Http/Middleware/AuthenticatePlatformApi.php
@@ -2,9 +2,8 @@

 namespace App\Http\Middleware;

-use App\Enums\AppInstallationStatus;
 use App\Enums\StoreUserRole;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\User;
 use Closure;
 use Illuminate\Http\Request;
@@ -19,48 +18,19 @@ class AuthenticatePlatformApi
      */
     public function handle(Request $request, Closure $next): Response
     {
-        app()->forgetInstance('admin_api_oauth_token');
-
         $user = $request->user();
+        $token = $request->attributes->get('sanctum_personal_access_token');

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

-        $installation = $token->installation;
-
-        if ($installation === null || $installation->status !== AppInstallationStatus::Active) {
-            return response()->json(['message' => 'Forbidden.'], 403);
-        }
-
-        if (! $this->hasManagePlatformAbility($token)) {
+        if (! $this->userCanManagePlatform($user) || ! $token->can('manage-platform')) {
             return response()->json(['message' => 'Forbidden.'], 403);
         }

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

-        $request->attributes->set('admin_api_oauth_token', $token);
-        app()->instance('admin_api_oauth_token', $token);
-
         return $next($request);
     }

@@ -71,10 +41,4 @@ private function userCanManagePlatform(User $user): bool
             ->exists();
     }

-    private function hasManagePlatformAbility(OauthToken $token): bool
-    {
-        $abilities = $token->abilities_json ?? [];
-
-        return in_array('*', $abilities, true) || in_array('manage-platform', $abilities, true);
-    }
 }
diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php
index 41cdd227..b5744824 100644
--- a/app/Livewire/Admin/Developers/Index.php
+++ b/app/Livewire/Admin/Developers/Index.php
@@ -4,7 +4,7 @@

 use App\Enums\WebhookEventType;
 use App\Enums\WebhookSubscriptionStatus;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
 use App\Models\WebhookSubscription;
 use App\Services\WebhookService;
@@ -62,7 +62,7 @@ public function generateToken(WebhookService $webhooks): void
             'newTokenName' => 'token name',
         ]);

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

         $this->generatedToken = $result['plain_text'];
         $this->newTokenName = '';
@@ -136,15 +136,13 @@ public function deleteWebhook(int $webhookId): void
     }

     /**
-     * @return Collection<int, OauthToken>
+     * @return Collection<int, PersonalAccessToken>
      */
     public function tokens(): Collection
     {
-        return OauthToken::query()
-            ->with('installation.app')
-            ->whereHas('installation', function ($query): void {
-                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
-            })
+        return PersonalAccessToken::query()
+            ->where('tokenable_type', (new \App\Models\User)->getMorphClass())
+            ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id'))
             ->latest('created_at')
             ->get();
     }
@@ -175,12 +173,11 @@ public function render()
         ]);
     }

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

diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 128d7a1e..5184c036 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -4,6 +4,7 @@

 use App\Auth\CustomerUserProvider;
 use App\Contracts\PaymentProvider;
+use App\Events\CheckoutCompleted;
 use App\Events\FulfillmentCreated;
 use App\Events\FulfillmentDelivered;
 use App\Events\FulfillmentShipped;
@@ -11,13 +12,17 @@
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
 use App\Events\OrderRefunded;
-use App\Events\ProductStatusChanged;
+use App\Events\ProductCreated;
+use App\Events\ProductDeleted;
+use App\Events\ProductUpdated;
 use App\Http\Middleware\CheckStoreRole;
 use App\Http\Middleware\EnsureUserEmailIsVerified;
 use App\Http\Middleware\ResolveStore;
 use App\Listeners\DispatchWebhooks;
+use App\Models\PersonalAccessToken;
 use App\Models\Product;
 use App\Models\Store;
+use App\Models\User;
 use App\Observers\ProductObserver;
 use App\Services\AnalyticsService;
 use App\Services\NavigationService;
@@ -53,6 +58,30 @@ public function register(): void
             return new CustomerUserProvider($app['hash'], $config['model']);
         });

+        Auth::viaRequest('sanctum-compatible', function (Request $request): ?User {
+            app()->forgetInstance('sanctum_personal_access_token');
+
+            $plainTextToken = $request->bearerToken();
+
+            if (! is_string($plainTextToken) || $plainTextToken === '') {
+                return null;
+            }
+
+            $token = PersonalAccessToken::query()
+                ->with('tokenable')
+                ->where('token', hash('sha256', $this->plainTokenForHashing($plainTextToken)))
+                ->first();
+
+            if (! $token instanceof PersonalAccessToken || $token->isExpired() || ! $token->tokenable instanceof User) {
+                return null;
+            }
+
+            $request->attributes->set('sanctum_personal_access_token', $token);
+            app()->instance('sanctum_personal_access_token', $token);
+
+            return $token->tokenable;
+        });
+
         $this->app->singleton(ThemeSettingsService::class);
         $this->app->singleton(NavigationService::class);
         $this->app->singleton(SearchService::class);
@@ -162,10 +191,13 @@ protected function configureEventListeners(): void
             OrderPaid::class,
             OrderCancelled::class,
             OrderRefunded::class,
+            CheckoutCompleted::class,
             FulfillmentCreated::class,
             FulfillmentShipped::class,
             FulfillmentDelivered::class,
-            ProductStatusChanged::class,
+            ProductCreated::class,
+            ProductUpdated::class,
+            ProductDeleted::class,
         ] as $event) {
             Event::listen($event, DispatchWebhooks::class);
         }
@@ -179,4 +211,13 @@ protected function configureLivewireMiddleware(): void
             CheckStoreRole::class,
         ]);
     }
+
+    private function plainTokenForHashing(string $plainTextToken): string
+    {
+        if (str_contains($plainTextToken, '|')) {
+            return (string) str($plainTextToken)->after('|');
+        }
+
+        return $plainTextToken;
+    }
 }
diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php
index 1c2a3e05..5da8fed4 100644
--- a/app/Services/WebhookService.php
+++ b/app/Services/WebhookService.php
@@ -2,15 +2,13 @@

 namespace App\Services;

-use App\Enums\AppInstallationStatus;
 use App\Enums\WebhookDeliveryStatus;
 use App\Enums\WebhookEventType;
 use App\Enums\WebhookSubscriptionStatus;
 use App\Jobs\DeliverWebhook;
-use App\Models\App as AppModel;
-use App\Models\AppInstallation;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
+use App\Models\User;
 use App\Models\WebhookDelivery;
 use App\Models\WebhookSubscription;
 use Illuminate\Support\Str;
@@ -62,37 +60,22 @@ public function verify(string $payload, string $signature, string $secret): bool

     /**
      * @param  list<string>  $abilities
-     * @return array{token: OauthToken, plain_text: string}
+     * @return array{token: PersonalAccessToken, plain_text: string}
      */
-    public function createApiToken(Store $store, string $name, array $abilities): array
+    public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): array
     {
-        $app = AppModel::query()->firstOrCreate(
-            ['name' => 'Admin API'],
-            [
-                'status' => 'active',
-                'created_at' => now(),
-            ],
-        );
-
-        $installation = AppInstallation::withoutGlobalScopes()->firstOrCreate(
-            [
-                'store_id' => $store->getKey(),
-                'app_id' => $app->getKey(),
-            ],
-            [
-                'scopes_json' => $abilities,
-                'status' => AppInstallationStatus::Active,
-                'installed_at' => now(),
-            ],
-        );
-
+        $user ??= auth()->user() instanceof User
+            ? auth()->user()
+            : $store->users()
+                ->wherePivot('role', 'owner')
+                ->firstOrFail();
         $plainText = 'shop_'.Str::random(48);
-        $token = OauthToken::query()->create([
-            'installation_id' => $installation->getKey(),
+        $token = PersonalAccessToken::query()->create([
+            'tokenable_type' => $user->getMorphClass(),
+            'tokenable_id' => $user->getKey(),
             'name' => $name,
-            'access_token_hash' => hash('sha256', $plainText),
-            'refresh_token_hash' => null,
-            'abilities_json' => $abilities,
+            'token' => hash('sha256', $plainText),
+            'abilities' => $abilities,
             'expires_at' => now()->addYear(),
             'created_at' => now(),
         ]);
diff --git a/config/auth.php b/config/auth.php
index 33e9ba97..197dcb67 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -45,6 +45,11 @@
             'driver' => 'session',
             'provider' => 'customers',
         ],
+
+        'sanctum' => [
+            'driver' => 'sanctum-compatible',
+            'provider' => 'users',
+        ],
     ],

     /*
diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php
index 8c838cf2..f9af99d6 100644
--- a/resources/views/livewire/admin/developers/index.blade.php
+++ b/resources/views/livewire/admin/developers/index.blade.php
@@ -38,7 +38,7 @@

                         Name
-                        App
+                        Type
                         Last used
                         Created
                         Actions
@@ -48,7 +48,7 @@
                     @forelse ($tokens as $token)

getKey() }}">
                             {{ $token->name ?? 'API token' }}
-                            {{ $token->installation->app->name }}
+                            Admin API
                             {{ $token->last_used_at?->diffForHumans() ?? 'Never' }}
                             {{ $token->created_at?->toFormattedDateString() ?? 'Unknown' }}

diff --git a/routes/api.php b/routes/api.php
index 013219a0..45241ded 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -67,7 +67,7 @@
 Route::middleware('throttle:60,1')
     ->prefix('admin/v1/platform')
     ->name('api.admin.v1.platform.')
-    ->middleware('platform.api')
+    ->middleware(['auth:sanctum', 'platform.api'])
     ->group(function (): void {
         Route::post('organizations', [AdminPlatformOrganizationController::class, 'store'])->name('organizations.store');
         Route::post('stores', [AdminPlatformStoreController::class, 'store'])->name('stores.store');
@@ -76,6 +76,7 @@
 Route::middleware('throttle:60,1')
     ->prefix('admin/v1/stores/{store}')
     ->name('api.admin.v1.')
+    ->middleware('auth:sanctum')
     ->group(function (): void {
         Route::middleware('admin.api')->group(function (): void {
             Route::get('me', [AdminStoreMembershipController::class, 'show'])->name('stores.me');