# Kiosk Plan 1 — API Auth + Idempotency Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Lay the API foundation for kiosk buy submission — a dedicated kiosk-device auth principal, the `users` schema changes for phone-keyed universal loyalty, and hardened (principal-scoped) request idempotency — without touching the kiosk client or the buy/loyalty domain.

**Architecture:** The kiosk authenticates as a *device* (new `kiosk_devices` table + `AuthenticateKioskDevice` middleware), never as a `User`, so kiosk credentials can never satisfy `auth:sanctum`/`store.owner` seller routes. The `users` table gains a unique `phone`, a nullable `email`, and a `loyalty_points` balance; existing marketplace auth keeps requiring email. The existing global `IdempotencyMiddleware` is rescoped so a cached response can only replay for the same method + path + caller (Authorization header) + body, closing a cross-principal replay hole.

**Tech Stack:** Laravel 11, Sanctum (unchanged for marketplace), SQLite in-memory for tests, PHPUnit, Pint, PHPStan. Run via Sail from the `alqove-api` repo root.

**Out of scope (later plans):** the `buys` and `loyalty_transactions` tables, `LoyaltyWriter`, queue/lookup/submit endpoints and the customer match-or-create-by-phone + email-merge rule (Plan 2); all kiosk-client work (Plans 3–4). Plan 1 only makes nullable-email safe and provides the device principal those plans build on.

**Conventions (verified in repo):**
- All PHP files begin `declare(strict_types=1);`, one class per file, typed signatures.
- Migrations are anonymous classes: `return new class extends Migration { ... };`.
- UUID PKs use `App\Support\Traits\HasUuid`.
- Feature tests: `use RefreshDatabase;` and `$this->seed(RoleAndPermissionSeeder::class);` in `setUp()`.
- Test command (from `alqove-api` repo root): `docker compose exec laravel.test php artisan test`.
- Lint/static: `docker compose exec laravel.test ./vendor/bin/pint` and `... ./vendor/bin/phpstan analyse`.

---

## File structure

**Create:**
- `database/migrations/2026_06_02_000001_add_kiosk_fields_to_users_table.php` — phone/email/loyalty_points.
- `database/migrations/2026_06_02_000002_create_kiosk_devices_table.php`
- `app/Models/KioskDevice.php`
- `database/factories/KioskDeviceFactory.php`
- `app/Http/Middleware/AuthenticateKioskDevice.php`
- `app/Modules/Kiosk/Controllers/KioskController.php` — a guarded `ping` proving device resolution (real endpoints arrive in Plan 2).
- `app/Modules/Kiosk/routes.php`
- `app/Modules/Kiosk/README.md`
- `app/Console/Commands/ProvisionKioskDevice.php`
- `app/Console/Commands/RevokeKioskDevice.php`
- `tests/Feature/Kiosk/UserKioskSchemaTest.php`
- `tests/Feature/Kiosk/KioskDeviceAuthTest.php`
- `tests/Feature/Kiosk/KioskProvisioningTest.php`
- `tests/Feature/Kiosk/IdempotencyScopingTest.php`

**Modify:**
- `app/Models/User.php` — `$fillable`.
- `app/Modules/Auth/Resources/UserResource.php` — null-guard email, expose phone.
- `app/Http/Middleware/IdempotencyMiddleware.php` — principal/body-scoped cache key.
- `bootstrap/app.php` — register `kiosk.device` middleware alias.
- `routes/api.php` — require the Kiosk module routes.

---

## Task 1: `users` schema — phone, nullable email, loyalty_points

**Files:**
- Create: `database/migrations/2026_06_02_000001_add_kiosk_fields_to_users_table.php`
- Modify: `app/Models/User.php`
- Test: `tests/Feature/Kiosk/UserKioskSchemaTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Kiosk;

use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserKioskSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_be_created_without_email(): void
    {
        $user = User::create([
            'name' => 'Walk In',
            'phone' => '+15125550147',
        ]);

        $this->assertNull($user->email);
        $this->assertSame('+15125550147', $user->phone);
        $this->assertSame(0, $user->loyalty_points);
    }

    public function test_phone_must_be_unique(): void
    {
        User::create(['name' => 'A', 'phone' => '+15125550147']);

        $this->expectException(QueryException::class);
        User::create(['name' => 'B', 'phone' => '+15125550147']);
    }

    public function test_loyalty_points_defaults_to_zero_and_is_an_integer(): void
    {
        $user = User::create(['name' => 'C', 'phone' => '+15125559900']);

        $this->assertIsInt($user->fresh()->loyalty_points);
        $this->assertSame(0, $user->fresh()->loyalty_points);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=UserKioskSchemaTest`
Expected: FAIL (column `phone`/`loyalty_points` does not exist).

- [ ] **Step 3: Create the migration**

`database/migrations/2026_06_02_000001_add_kiosk_fields_to_users_table.php`:
```php
<?php

declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('phone')->nullable()->unique()->after('email');
            $table->unsignedInteger('loyalty_points')->default(0)->after('phone');
        });

        // Make email nullable (walk-in kiosk customers may skip it). Unique
        // constraint is unchanged; multiple NULLs are permitted in SQLite/MySQL.
        Schema::table('users', function (Blueprint $table) {
            $table->string('email')->nullable()->change();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropUnique(['phone']);
            $table->dropColumn(['phone', 'loyalty_points']);
            $table->string('email')->nullable(false)->change();
        });
    }
};
```

- [ ] **Step 4: Update the model `$fillable` and add an integer cast**

In `app/Models/User.php`, change `$fillable` and `casts()`:
```php
    protected $fillable = [
        'name',
        'email',
        'phone',
        'avatar',
        'password',
        'store_id',
        'loyalty_points',
    ];
```
```php
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'loyalty_points' => 'integer',
        ];
    }
```

> Note: `email` becoming nullable requires `doctrine/dbal` for `->change()` only on older Laravel; Laravel 11 supports native column changes — no extra package. If `change()` errors about an unknown column type, confirm the project's Laravel patch version supports native change (it does on 11.x).

- [ ] **Step 5: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=UserKioskSchemaTest`
Expected: PASS (3 tests).

- [ ] **Step 6: Commit**

```bash
git add database/migrations/2026_06_02_000001_add_kiosk_fields_to_users_table.php app/Models/User.php tests/Feature/Kiosk/UserKioskSchemaTest.php
git commit -m "feat(kiosk): add phone, nullable email, loyalty_points to users"
```

---

## Task 2: UserResource — null-guard email, expose phone

**Files:**
- Modify: `app/Modules/Auth/Resources/UserResource.php`
- Test: `tests/Feature/Kiosk/UserKioskSchemaTest.php` (add a case)

- [ ] **Step 1: Add the failing test**

Append to `UserKioskSchemaTest`:
```php
    public function test_user_resource_handles_null_email_and_exposes_phone(): void
    {
        $user = User::create(['name' => 'Walk In', 'phone' => '+15125550147']);

        $resource = (new \App\Modules\Auth\Resources\UserResource($user))
            ->toArray(request());

        $this->assertNull($resource['email']);
        $this->assertSame('+15125550147', $resource['phone']);
    }
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=UserKioskSchemaTest`
Expected: FAIL (`phone` key missing).

- [ ] **Step 3: Update the resource**

`app/Modules/Auth/Resources/UserResource.php` `toArray`:
```php
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'email' => $this->email,
            'phone' => $this->phone,
            'name' => $this->name,
            'avatar' => $this->avatar,
            'roles' => $this->getRoleNames()->toArray(),
            'store_id' => $this->store_id,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
```

> `$this->email` is already null-safe (it just returns null); the test guards against a future change that would dereference it. Adding `phone` is the substantive change.

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=UserKioskSchemaTest`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add app/Modules/Auth/Resources/UserResource.php tests/Feature/Kiosk/UserKioskSchemaTest.php
git commit -m "feat(kiosk): expose phone and null-safe email in UserResource"
```

---

## Task 3: Regression — marketplace register/login still require email

**Files:**
- Test: `tests/Feature/Kiosk/UserKioskSchemaTest.php` (add cases)

No production code changes — this task proves nullable-email did **not** loosen marketplace auth.

- [ ] **Step 1: Add the regression tests**

Append to `UserKioskSchemaTest` (add `use Database\Seeders\RoleAndPermissionSeeder;` and a `setUp` seeding roles — see below):
```php
    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(\Database\Seeders\RoleAndPermissionSeeder::class);
    }

    public function test_register_still_requires_email(): void
    {
        $response = $this->postJson('/v1/auth/register', [
            'name' => 'No Email',
            'password' => 'securepassword123',
            'password_confirmation' => 'securepassword123',
        ]);

        $response->assertStatus(422)->assertJsonValidationErrors(['email']);
    }

    public function test_login_still_requires_email(): void
    {
        $response = $this->postJson('/v1/auth/login', [
            'password' => 'securepassword123',
        ]);

        $response->assertStatus(422)->assertJsonValidationErrors(['email']);
    }
```

> The other tests in this class don't need roles seeded, but seeding in `setUp` is harmless and matches repo convention.

- [ ] **Step 2: Run test to verify behavior**

Run: `docker compose exec laravel.test php artisan test --filter=UserKioskSchemaTest`
Expected: PASS (6 tests). If register/login do NOT 422 on missing email, that's a real regression — stop and fix `RegisterRequest`/`LoginRequest` to keep `email` `required`.

- [ ] **Step 3: Commit**

```bash
git add tests/Feature/Kiosk/UserKioskSchemaTest.php
git commit -m "test(kiosk): assert marketplace auth still requires email"
```

---

## Task 4: `kiosk_devices` table + KioskDevice model

**Files:**
- Create: `database/migrations/2026_06_02_000002_create_kiosk_devices_table.php`
- Create: `app/Models/KioskDevice.php`
- Test: `tests/Feature/Kiosk/KioskDeviceAuthTest.php` (model case first)

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Kiosk;

use App\Models\KioskDevice;
use App\Models\Store;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class KioskDeviceAuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_kiosk_device_belongs_to_a_store(): void
    {
        $store = Store::factory()->create();
        $device = KioskDevice::create([
            'store_id' => $store->id,
            'name' => 'Front Counter',
            'token_hash' => hash('sha256', 'plaintext'),
        ]);

        $this->assertSame($store->id, $device->store->id);
        $this->assertNull($device->revoked_at);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=KioskDeviceAuthTest`
Expected: FAIL (`KioskDevice` class / `kiosk_devices` table missing).

- [ ] **Step 3: Create the migration**

`database/migrations/2026_06_02_000002_create_kiosk_devices_table.php`:
```php
<?php

declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('kiosk_devices', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('name');
            $table->string('token_hash', 64)->unique();
            $table->timestamp('last_seen_at')->nullable();
            $table->timestamp('revoked_at')->nullable();
            $table->timestamps();

            $table->index('store_id');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('kiosk_devices');
    }
};
```

- [ ] **Step 4: Create the model**

`app/Models/KioskDevice.php`:
```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * @property string $id
 * @property string $store_id
 * @property string $name
 * @property string $token_hash
 * @property Carbon|null $last_seen_at
 * @property Carbon|null $revoked_at
 */
class KioskDevice extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = [
        'store_id',
        'name',
        'token_hash',
        'last_seen_at',
        'revoked_at',
    ];

    protected function casts(): array
    {
        return [
            'last_seen_at' => 'datetime',
            'revoked_at' => 'datetime',
        ];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=KioskDeviceAuthTest`
Expected: PASS (1 test).

- [ ] **Step 6: Commit**

```bash
git add database/migrations/2026_06_02_000002_create_kiosk_devices_table.php app/Models/KioskDevice.php tests/Feature/Kiosk/KioskDeviceAuthTest.php
git commit -m "feat(kiosk): add kiosk_devices table and KioskDevice model"
```

---

## Task 5: KioskDeviceFactory

**Files:**
- Create: `database/factories/KioskDeviceFactory.php`
- Test: `tests/Feature/Kiosk/KioskDeviceAuthTest.php` (add a case)

- [ ] **Step 1: Add the failing test**

Append to `KioskDeviceAuthTest`:
```php
    public function test_factory_creates_a_device_with_a_token_hash(): void
    {
        $device = KioskDevice::factory()->create();

        $this->assertNotEmpty($device->token_hash);
        $this->assertNotNull($device->store_id);
    }
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=KioskDeviceAuthTest`
Expected: FAIL (no factory for `KioskDevice`).

- [ ] **Step 3: Create the factory**

`database/factories/KioskDeviceFactory.php`:
```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\KioskDevice;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends Factory<KioskDevice>
 */
class KioskDeviceFactory extends Factory
{
    public function definition(): array
    {
        return [
            'id' => fake()->uuid(),
            'store_id' => Store::factory(),
            'name' => 'Kiosk '.fake()->numberBetween(1, 99),
            'token_hash' => hash('sha256', Str::random(48)),
            'last_seen_at' => null,
            'revoked_at' => null,
        ];
    }

    public function revoked(): static
    {
        return $this->state(fn (array $attributes) => [
            'revoked_at' => now(),
        ]);
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=KioskDeviceAuthTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add database/factories/KioskDeviceFactory.php tests/Feature/Kiosk/KioskDeviceAuthTest.php
git commit -m "feat(kiosk): add KioskDeviceFactory"
```

---

## Task 6: AuthenticateKioskDevice middleware + alias + guarded ping route

**Files:**
- Create: `app/Http/Middleware/AuthenticateKioskDevice.php`
- Create: `app/Modules/Kiosk/Controllers/KioskController.php`
- Create: `app/Modules/Kiosk/routes.php`
- Create: `app/Modules/Kiosk/README.md`
- Modify: `bootstrap/app.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Kiosk/KioskDeviceAuthTest.php` (add cases)

- [ ] **Step 1: Add the failing tests**

Append to `KioskDeviceAuthTest`:
```php
    public function test_valid_device_token_can_reach_kiosk_ping(): void
    {
        $store = Store::factory()->create();
        $plain = 'secret-token-value';
        KioskDevice::factory()->create([
            'store_id' => $store->id,
            'token_hash' => hash('sha256', $plain),
        ]);

        $response = $this->withToken($plain)->getJson('/v1/kiosk/ping');

        $response->assertOk()
            ->assertJsonPath('data.store_id', $store->id);
    }

    public function test_missing_token_is_rejected(): void
    {
        $this->getJson('/v1/kiosk/ping')->assertStatus(401);
    }

    public function test_revoked_device_token_is_rejected(): void
    {
        $plain = 'revoked-token';
        KioskDevice::factory()->revoked()->create([
            'token_hash' => hash('sha256', $plain),
        ]);

        $this->withToken($plain)->getJson('/v1/kiosk/ping')->assertStatus(401);
    }

    public function test_kiosk_token_cannot_reach_seller_routes(): void
    {
        $store = Store::factory()->create();
        $plain = 'kiosk-only-token';
        KioskDevice::factory()->create([
            'store_id' => $store->id,
            'token_hash' => hash('sha256', $plain),
        ]);

        // A seller-management route guarded by auth:sanctum + store.owner.
        $response = $this->withToken($plain)
            ->putJson("/v1/stores/{$store->id}", ['name' => 'Hijacked']);

        $response->assertStatus(401); // device token is not a Sanctum token
    }
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `docker compose exec laravel.test php artisan test --filter=KioskDeviceAuthTest`
Expected: FAIL (`/v1/kiosk/ping` 404 / route missing).

- [ ] **Step 3: Create the middleware**

`app/Http/Middleware/AuthenticateKioskDevice.php`:
```php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Models\KioskDevice;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AuthenticateKioskDevice
{
    public function handle(Request $request, Closure $next): Response
    {
        $token = $request->bearerToken();

        if (! $token) {
            abort(401, 'Kiosk device token required.');
        }

        $device = KioskDevice::query()
            ->whereNull('revoked_at')
            ->where('token_hash', hash('sha256', $token))
            ->first();

        if (! $device) {
            abort(401, 'Invalid or revoked kiosk device token.');
        }

        $device->forceFill(['last_seen_at' => now()])->saveQuietly();
        $request->attributes->set('kiosk_device', $device);

        return $next($request);
    }
}
```

- [ ] **Step 4: Create the controller**

`app/Modules/Kiosk/Controllers/KioskController.php`:
```php
<?php

declare(strict_types=1);

namespace App\Modules\Kiosk\Controllers;

use App\Models\KioskDevice;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class KioskController
{
    public function ping(Request $request): JsonResponse
    {
        /** @var KioskDevice $device */
        $device = $request->attributes->get('kiosk_device');

        return response()->json([
            'data' => [
                'store_id' => $device->store_id,
                'device' => $device->name,
            ],
        ]);
    }
}
```

- [ ] **Step 5: Create the module routes**

`app/Modules/Kiosk/routes.php`:
```php
<?php

declare(strict_types=1);

use App\Modules\Kiosk\Controllers\KioskController;
use Illuminate\Support\Facades\Route;

Route::middleware('kiosk.device')->group(function () {
    Route::get('/kiosk/ping', [KioskController::class, 'ping']);
});
```

- [ ] **Step 6: Create the module README**

`app/Modules/Kiosk/README.md`:
```markdown
# Kiosk Module

Endpoints for the in-store check-in kiosk (Alqove Inflow). Authenticated via a
dedicated kiosk **device** token (not a user/Sanctum token), resolved by
`AuthenticateKioskDevice` middleware (alias `kiosk.device`). The device's
`store_id` is the authoritative store for every request.

## Provisioning
- `php artisan kiosk:provision {store} --name="Front Counter"` — prints the
  device token once. Store it in the kiosk's config.
- `php artisan kiosk:revoke {device}` — revokes a device token.

Plan 1 ships only `GET /v1/kiosk/ping` (auth smoke). Lookup, buy submission,
and queue status arrive in Plan 2.
```

- [ ] **Step 7: Register the middleware alias**

In `bootstrap/app.php`, add the import at the top with the other middleware imports:
```php
use App\Http\Middleware\AuthenticateKioskDevice;
```
and add the alias inside `$middleware->alias([...])`:
```php
        $middleware->alias([
            'store.owner' => EnsureStoreOwner::class,
            'admin' => EnsureAdmin::class,
            'kiosk.device' => AuthenticateKioskDevice::class,
        ]);
```

- [ ] **Step 8: Require the module routes**

In `routes/api.php`, inside the `Route::prefix('v1')->group(...)`, add alongside the other `require` lines:
```php
    require app_path('Modules/Kiosk/routes.php');
```

- [ ] **Step 9: Run tests to verify they pass**

Run: `docker compose exec laravel.test php artisan test --filter=KioskDeviceAuthTest`
Expected: PASS (6 tests). In particular `test_kiosk_token_cannot_reach_seller_routes` is 401, proving isolation.

- [ ] **Step 10: Commit**

```bash
git add app/Http/Middleware/AuthenticateKioskDevice.php app/Modules/Kiosk bootstrap/app.php routes/api.php tests/Feature/Kiosk/KioskDeviceAuthTest.php
git commit -m "feat(kiosk): device auth middleware, guarded ping route, route isolation"
```

---

## Task 7: `kiosk:provision` command

**Files:**
- Create: `app/Console/Commands/ProvisionKioskDevice.php`
- Test: `tests/Feature/Kiosk/KioskProvisioningTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Kiosk;

use App\Models\KioskDevice;
use App\Models\Store;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class KioskProvisioningTest extends TestCase
{
    use RefreshDatabase;

    public function test_provision_creates_a_device_and_prints_a_usable_token(): void
    {
        $store = Store::factory()->create();

        $this->artisan('kiosk:provision', ['store' => $store->id, '--name' => 'Front Counter'])
            ->assertSuccessful();

        $device = KioskDevice::where('store_id', $store->id)->firstOrFail();
        $this->assertSame('Front Counter', $device->name);
        $this->assertSame(64, strlen($device->token_hash)); // sha256 hex
    }

    public function test_provision_fails_for_unknown_store(): void
    {
        $this->artisan('kiosk:provision', ['store' => 'not-a-real-id'])
            ->assertFailed();
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=KioskProvisioningTest`
Expected: FAIL (command `kiosk:provision` not found).

- [ ] **Step 3: Create the command**

`app/Console/Commands/ProvisionKioskDevice.php`:
```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\KioskDevice;
use App\Models\Store;
use Illuminate\Console\Command;
use Illuminate\Support\Str;

class ProvisionKioskDevice extends Command
{
    protected $signature = 'kiosk:provision {store : Store UUID} {--name=Kiosk : Human-readable device name}';

    protected $description = 'Provision a kiosk device for a store and print its token once.';

    public function handle(): int
    {
        $store = Store::find($this->argument('store'));

        if (! $store) {
            $this->error('Store not found.');

            return self::FAILURE;
        }

        $plain = Str::random(48);

        $device = KioskDevice::create([
            'store_id' => $store->id,
            'name' => (string) $this->option('name'),
            'token_hash' => hash('sha256', $plain),
        ]);

        $this->info('Kiosk device provisioned.');
        $this->line('Device ID: '.$device->id);
        $this->line('Store:     '.$store->name);
        $this->line('Token (shown once, store it now): '.$plain);

        return self::SUCCESS;
    }
}
```

> Laravel 11 auto-discovers commands in `app/Console/Commands/`; no manual registration needed.

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=KioskProvisioningTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add app/Console/Commands/ProvisionKioskDevice.php tests/Feature/Kiosk/KioskProvisioningTest.php
git commit -m "feat(kiosk): kiosk:provision artisan command"
```

---

## Task 8: `kiosk:revoke` command

**Files:**
- Create: `app/Console/Commands/RevokeKioskDevice.php`
- Test: `tests/Feature/Kiosk/KioskProvisioningTest.php` (add a case)

- [ ] **Step 1: Add the failing test**

Append to `KioskProvisioningTest`:
```php
    public function test_revoke_sets_revoked_at_and_blocks_the_token(): void
    {
        $plain = 'will-be-revoked';
        $device = KioskDevice::factory()->create(['token_hash' => hash('sha256', $plain)]);

        $this->artisan('kiosk:revoke', ['device' => $device->id])->assertSuccessful();

        $this->assertNotNull($device->fresh()->revoked_at);
        $this->withToken($plain)->getJson('/v1/kiosk/ping')->assertStatus(401);
    }
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=KioskProvisioningTest`
Expected: FAIL (command `kiosk:revoke` not found).

- [ ] **Step 3: Create the command**

`app/Console/Commands/RevokeKioskDevice.php`:
```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\KioskDevice;
use Illuminate\Console\Command;

class RevokeKioskDevice extends Command
{
    protected $signature = 'kiosk:revoke {device : Kiosk device UUID}';

    protected $description = 'Revoke a kiosk device token.';

    public function handle(): int
    {
        $device = KioskDevice::find($this->argument('device'));

        if (! $device) {
            $this->error('Kiosk device not found.');

            return self::FAILURE;
        }

        $device->update(['revoked_at' => now()]);

        $this->info("Revoked kiosk device {$device->id} ({$device->name}).");

        return self::SUCCESS;
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=KioskProvisioningTest`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add app/Console/Commands/RevokeKioskDevice.php tests/Feature/Kiosk/KioskProvisioningTest.php
git commit -m "feat(kiosk): kiosk:revoke artisan command"
```

---

## Task 9: Scope the idempotency middleware by method + path + caller + body

**Files:**
- Modify: `app/Http/Middleware/IdempotencyMiddleware.php`
- Test: `tests/Feature/Kiosk/IdempotencyScopingTest.php`

The current cache key is `idempotency:{key}` with no method/path/principal/body
scope, so a leaked or reused key could replay a cached response across routes or
callers. Scope it by a fingerprint of method + path + `Authorization` header +
raw body. (Using the Authorization header captures the caller without needing
auth resolved, since this middleware runs before route auth.)

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Kiosk;

use App\Http\Middleware\IdempotencyMiddleware;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;

class IdempotencyScopingTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        // A counter-backed echo route so we can detect whether the handler
        // actually ran (cache replay = handler NOT run again).
        Route::post('/v1/__idem_probe', function () {
            $count = cache()->increment('idem_probe_calls');

            return response()->json(['calls' => $count]);
        })->middleware(IdempotencyMiddleware::class);
    }

    public function test_same_key_same_request_replays_cached_response(): void
    {
        $headers = ['Idempotency-Key' => 'KEY-1', 'Authorization' => 'Bearer A'];

        $first = $this->postJson('/v1/__idem_probe', ['v' => 1], $headers);
        $second = $this->postJson('/v1/__idem_probe', ['v' => 1], $headers);

        $first->assertOk()->assertJsonPath('calls', 1);
        $second->assertOk()->assertJsonPath('calls', 1); // replayed, not re-run
    }

    public function test_same_key_different_body_is_not_replayed(): void
    {
        $headers = ['Idempotency-Key' => 'KEY-1', 'Authorization' => 'Bearer A'];

        $this->postJson('/v1/__idem_probe', ['v' => 1], $headers)->assertJsonPath('calls', 1);
        $this->postJson('/v1/__idem_probe', ['v' => 2], $headers)->assertJsonPath('calls', 2);
    }

    public function test_same_key_different_caller_is_not_replayed(): void
    {
        $this->postJson('/v1/__idem_probe', ['v' => 1], ['Idempotency-Key' => 'KEY-1', 'Authorization' => 'Bearer A'])
            ->assertJsonPath('calls', 1);
        $this->postJson('/v1/__idem_probe', ['v' => 1], ['Idempotency-Key' => 'KEY-1', 'Authorization' => 'Bearer B'])
            ->assertJsonPath('calls', 2);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=IdempotencyScopingTest`
Expected: FAIL — with the current unscoped key, `test_same_key_different_body_is_not_replayed` and `test_same_key_different_caller_is_not_replayed` get `calls => 1` (wrongly replayed).

- [ ] **Step 3: Update the middleware**

Replace the cache-key construction in `app/Http/Middleware/IdempotencyMiddleware.php`. The full updated `handle`:
```php
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->isMethod('POST') && ! $request->isMethod('PUT') && ! $request->isMethod('PATCH')) {
            return $next($request);
        }

        $idempotencyKey = $request->header('Idempotency-Key');

        if (! $idempotencyKey) {
            return $next($request);
        }

        // Scope the cache key so a reused/leaked key cannot replay a cached
        // response across a different method, path, caller, or request body.
        $fingerprint = hash('sha256', implode('|', [
            $request->method(),
            $request->path(),
            (string) $request->header('Authorization', ''),
            (string) $request->getContent(),
        ]));

        $cacheKey = "idempotency:{$idempotencyKey}:{$fingerprint}";

        $cached = Cache::get($cacheKey);

        if ($cached !== null) {
            return response($cached['content'], $cached['status'])
                ->withHeaders($cached['headers']);
        }

        $response = $next($request);

        Cache::put($cacheKey, [
            'content' => $response->getContent(),
            'status' => $response->getStatusCode(),
            'headers' => array_map(
                fn ($values) => $values[0] ?? null,
                $response->headers->all(),
            ),
        ], now()->addHours(24));

        return $response;
    }
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=IdempotencyScopingTest`
Expected: PASS (3 tests).

- [ ] **Step 5: Run the existing idempotency/checkout tests for regression**

Run: `docker compose exec laravel.test php artisan test --filter=Idempotency` then `... --filter=Checkout`
Expected: PASS. A legitimate retry (same key, same body, same caller, same path) still replays; only cross-scope reuse stops replaying.

- [ ] **Step 6: Commit**

```bash
git add app/Http/Middleware/IdempotencyMiddleware.php tests/Feature/Kiosk/IdempotencyScopingTest.php
git commit -m "fix(idempotency): scope replay cache by method, path, caller and body"
```

---

## Task 10: Full suite + lint + static analysis

**Files:** none (verification only).

- [ ] **Step 1: Run the complete test suite**

Run: `docker compose exec laravel.test php artisan test`
Expected: PASS — all pre-existing tests plus the new `tests/Feature/Kiosk/*` (≈ 17 new tests across 4 files).

- [ ] **Step 2: Run Pint**

Run: `docker compose exec laravel.test ./vendor/bin/pint`
Expected: no style violations (auto-fixes if any; re-run tests if files changed).

- [ ] **Step 3: Run PHPStan**

Run: `docker compose exec laravel.test ./vendor/bin/phpstan analyse`
Expected: no new errors. Fix any introduced by the new files (typed properties/returns already provided above).

- [ ] **Step 4: Commit any lint/static fixes**

```bash
git add -A
git commit -m "chore(kiosk): pint + phpstan fixes for Plan 1"
```

---

## Done definition

- Migrations: `users` gains unique nullable `phone`, nullable `email`, default-0 `loyalty_points`; new `kiosk_devices` table.
- `KioskDevice` model + factory; `AuthenticateKioskDevice` middleware aliased `kiosk.device`; `GET /v1/kiosk/ping` guarded and proven isolated from seller routes.
- `kiosk:provision` / `kiosk:revoke` commands.
- `IdempotencyMiddleware` scoped by method + path + caller + body; existing checkout idempotency unaffected.
- `docker compose exec laravel.test php artisan test` green; Pint + PHPStan clean.
- Marketplace register/login still require email (regression-guarded).
