From d9ccc39c1e41306e7f1aa8dc0367347db0681461 Mon Sep 17 00:00:00 2001 From: Nate Barlow Date: Sun, 4 Oct 2026 01:18:49 -0600 Subject: [PATCH 1/5] =?UTF-8?q?M22.2=20=C2=A72:=20contract=20defined=20by?= =?UTF-8?q?=20docs/API.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L5W7g1rrgyVqEwZSgLg2QM --- docs/strategies/22.2_API_KEYS_AND_CONTRACT.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md index dd341d40..df7051d6 100644 --- a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md +++ b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md @@ -15,12 +15,12 @@ Parallel work: §1 and §2 can run side by side. §3 waits for both. 3. [ ] Authenticate API requests by bearer key and resolve the issuer; a revoked or unknown key is refused. %<3023> 4. [ ] Rate limit per key; a limited request says when to retry. %<3024> -## 2. [ ] Contract %<3025> +## 2. [x] Contract %<3025> -1. [x] Write `docs/API.md`: create, read, edit draft, delete, and send; one request and one response example each. %<3026> -2. [ ] Define the invoice representation: lines, total, public link, payment details, mail state, payment state. %<3027> -3. [ ] Define the idempotency rule for create: the same request key returns the first invoice. %<3028> -4. [ ] Define the error format: one stable code plus the field or rule that failed, with the list of codes. %<3029> +1. [x] Write `docs/API.md`: create, read, edit draft, delete, and send; one request and one response example each. Defines §2.2–2.4 too. %<3026> +2. [x] Define the invoice representation: lines, total, public link, payment details, mail state, payment state. %<3027> +3. [x] Define the idempotency rule for create: the same request key returns the first invoice. %<3028> +4. [x] Define the error format: one stable code plus the field or rule that failed, with the list of codes. %<3029> ## 3. [ ] Verify %<3030> From a5a0aee72efeac4fce0c9664fd0ca17120e69357 Mon Sep 17 00:00:00 2001 From: Nate Barlow Date: Sun, 4 Oct 2026 01:20:20 -0600 Subject: [PATCH 2/5] =?UTF-8?q?M22.2=20=C2=A71.1:=20API=20key=20table=20an?= =?UTF-8?q?d=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the SHA-256 hash is stored; issue() returns the plain key once. Revoked keys are never found by their plain value. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L5W7g1rrgyVqEwZSgLg2QM --- app/Models/ApiKey.php | 57 +++++++++++++++++++ app/Models/User.php | 5 ++ ...026_10_04_120000_create_api_keys_table.php | 26 +++++++++ docs/strategies/22.2_API_KEYS_AND_CONTRACT.md | 2 +- tests/Feature/ApiKeyTest.php | 48 ++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 app/Models/ApiKey.php create mode 100644 database/migrations/2026_10_04_120000_create_api_keys_table.php create mode 100644 tests/Feature/ApiKeyTest.php diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php new file mode 100644 index 00000000..36914a93 --- /dev/null +++ b/app/Models/ApiKey.php @@ -0,0 +1,57 @@ + 'datetime', + 'revoked_at' => 'datetime', + ]; + + /** Make a key for the issuer. Returns [model, plain key]. */ + public static function issue(User $issuer, string $name): array + { + $plain = 'cz_' . Str::random(40); + + $key = $issuer->apiKeys()->create([ + 'name' => $name, + 'key_hash' => hash('sha256', $plain), + ]); + + return [$key, $plain]; + } + + /** The live key matching a plain key, or null. */ + public static function findByPlain(string $plain): ?self + { + return static::where('key_hash', hash('sha256', $plain))->whereNull('revoked_at')->first(); + } + + public function revoke(): void + { + $this->forceFill(['revoked_at' => now()])->save(); + } + + public function touchUsed(): void + { + $this->forceFill(['last_used_at' => now()])->save(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 910fbd5b..9094ef35 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -270,6 +270,11 @@ public function invoices() { return $this->hasMany(\App\Models\Invoice::class); } + public function apiKeys() + { + return $this->hasMany(ApiKey::class); + } + public function walletSetting() { return $this->hasOne(WalletSetting::class); diff --git a/database/migrations/2026_10_04_120000_create_api_keys_table.php b/database/migrations/2026_10_04_120000_create_api_keys_table.php new file mode 100644 index 00000000..a5d7d64a --- /dev/null +++ b/database/migrations/2026_10_04_120000_create_api_keys_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('key_hash', 64)->unique(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; diff --git a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md index df7051d6..5716bd09 100644 --- a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md +++ b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md @@ -10,7 +10,7 @@ Parallel work: §1 and §2 can run side by side. §3 waits for both. ## 1. [ ] Keys %<3020> -1. [ ] Add the API key table and model: issuer, name, key hash, last used, revoked at. %<3021> +1. [x] Add the API key table and model: issuer, name, key hash, last used, revoked at. %<3021> 2. [ ] Issuer settings page: make a key, see it once in full, see the list with last used, revoke one. %<3022> 3. [ ] Authenticate API requests by bearer key and resolve the issuer; a revoked or unknown key is refused. %<3023> 4. [ ] Rate limit per key; a limited request says when to retry. %<3024> diff --git a/tests/Feature/ApiKeyTest.php b/tests/Feature/ApiKeyTest.php new file mode 100644 index 00000000..80b56991 --- /dev/null +++ b/tests/Feature/ApiKeyTest.php @@ -0,0 +1,48 @@ +create(); + + [$key, $plain] = ApiKey::issue($issuer, 'Shop'); + + $this->assertStringStartsWith('cz_', $plain); + $this->assertSame(hash('sha256', $plain), $key->key_hash); + $this->assertSame('Shop', $key->name); + $this->assertTrue($key->user->is($issuer)); + $this->assertStringNotContainsString($plain, json_encode($key->fresh()->toArray())); + $this->assertTrue(ApiKey::findByPlain($plain)->is($key)); + } + + public function test_a_revoked_or_unknown_key_is_not_found(): void + { + $issuer = User::factory()->create(); + [$key, $plain] = ApiKey::issue($issuer, 'Old'); + + $key->revoke(); + + $this->assertNotNull($key->fresh()->revoked_at); + $this->assertNull(ApiKey::findByPlain($plain)); + $this->assertNull(ApiKey::findByPlain('cz_nothing')); + } + + public function test_using_a_key_stamps_last_used(): void + { + [$key] = ApiKey::issue(User::factory()->create(), 'Shop'); + + $this->assertNull($key->last_used_at); + $key->touchUsed(); + $this->assertNotNull($key->fresh()->last_used_at); + } +} From d8433cf299d46705b4b668a63fa6fc69e9f742e0 Mon Sep 17 00:00:00 2001 From: Nate Barlow Date: Sun, 4 Oct 2026 01:22:15 -0600 Subject: [PATCH 3/5] =?UTF-8?q?M22.2=20=C2=A71.2:=20API=20keys=20settings?= =?UTF-8?q?=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make a key and see it once, list keys with last used, revoke one. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L5W7g1rrgyVqEwZSgLg2QM --- .../Controllers/ApiKeySettingsController.php | 39 +++++++++ docs/strategies/22.2_API_KEYS_AND_CONTRACT.md | 2 +- resources/views/settings/api-keys.blade.php | 83 +++++++++++++++++++ .../views/settings/partials/tabs.blade.php | 5 ++ routes/web.php | 4 + tests/Feature/ApiKeySettingsTest.php | 54 ++++++++++++ 6 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/ApiKeySettingsController.php create mode 100644 resources/views/settings/api-keys.blade.php create mode 100644 tests/Feature/ApiKeySettingsTest.php diff --git a/app/Http/Controllers/ApiKeySettingsController.php b/app/Http/Controllers/ApiKeySettingsController.php new file mode 100644 index 00000000..1d6d029c --- /dev/null +++ b/app/Http/Controllers/ApiKeySettingsController.php @@ -0,0 +1,39 @@ + $request->user()->apiKeys()->orderBy('id')->get(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:100']]); + + [, $plain] = ApiKey::issue($request->user(), $data['name']); + + return Redirect::route('settings.api-keys.edit') + ->with('status', 'api-key-made') + ->with('api_key_plain', $plain); + } + + public function revoke(Request $request, ApiKey $apiKey): RedirectResponse + { + abort_unless($apiKey->user_id === $request->user()->id, 404); + + $apiKey->revoke(); + + return Redirect::route('settings.api-keys.edit')->with('status', 'api-key-revoked'); + } +} diff --git a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md index 5716bd09..6dda56ee 100644 --- a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md +++ b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md @@ -11,7 +11,7 @@ Parallel work: §1 and §2 can run side by side. §3 waits for both. ## 1. [ ] Keys %<3020> 1. [x] Add the API key table and model: issuer, name, key hash, last used, revoked at. %<3021> -2. [ ] Issuer settings page: make a key, see it once in full, see the list with last used, revoke one. %<3022> +2. [x] Issuer settings page: make a key, see it once in full, see the list with last used, revoke one. %<3022> 3. [ ] Authenticate API requests by bearer key and resolve the issuer; a revoked or unknown key is refused. %<3023> 4. [ ] Rate limit per key; a limited request says when to retry. %<3024> diff --git a/resources/views/settings/api-keys.blade.php b/resources/views/settings/api-keys.blade.php new file mode 100644 index 00000000..4276815c --- /dev/null +++ b/resources/views/settings/api-keys.blade.php @@ -0,0 +1,83 @@ + + +
+

+ Settings +

+
+ @include('settings.partials.tabs') +
+
+
+ +
+
+
+
+ @if (session('status') === 'api-key-made') +
+

Your new key. Copy it now; it will not be shown again.

+ {{ session('api_key_plain') }} +
+ @elseif (session('status') === 'api-key-revoked') +
+ Key revoked. +
+ @endif + +
+

API keys

+

+ A key lets your own software create and read invoices for this account. See the API reference. +

+
+ +
+ @csrf +
+ + + +
+ Make key +
+ + @if ($keys->isEmpty()) +

No keys yet.

+ @else + + + + + + + + + + + @foreach ($keys as $key) + + + + + + + @endforeach + +
NameMadeLast used
{{ $key->name }}{{ $key->created_at->format('D, Y-m-d H:i') }}{{ $key->last_used_at?->format('D, Y-m-d H:i') ?? 'Never used' }} + @if ($key->revoked_at) + Revoked {{ $key->revoked_at->format('Y-m-d') }} + @else +
+ @csrf + @method('DELETE') + Revoke +
+ @endif +
+ @endif +
+
+
+
+
diff --git a/resources/views/settings/partials/tabs.blade.php b/resources/views/settings/partials/tabs.blade.php index 833c8123..c519020e 100644 --- a/resources/views/settings/partials/tabs.blade.php +++ b/resources/views/settings/partials/tabs.blade.php @@ -21,6 +21,11 @@ 'href' => route('settings.notifications.edit'), 'active' => request()->routeIs('settings.notifications.*'), ], + [ + 'label' => 'API keys', + 'href' => route('settings.api-keys.edit'), + 'active' => request()->routeIs('settings.api-keys.*'), + ], ]; @endphp diff --git a/routes/web.php b/routes/web.php index 4bbad34b..6696f3a8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\InvoiceController; use App\Http\Controllers\WalletSettingsController; use App\Http\Controllers\InvoiceSettingsController; +use App\Http\Controllers\ApiKeySettingsController; use App\Http\Controllers\NotificationSettingsController; use App\Http\Controllers\HealthController; use App\Http\Controllers\LegalController; @@ -104,6 +105,9 @@ Route::get('/settings/notifications', [NotificationSettingsController::class, 'edit'])->name('settings.notifications.edit'); Route::patch('/settings/notifications', [NotificationSettingsController::class, 'update'])->name('settings.notifications.update'); Route::post('/settings/notifications/test-email', [NotificationSettingsController::class, 'sendPreview'])->name('settings.notifications.preview'); + Route::get('/settings/api-keys', [ApiKeySettingsController::class, 'edit'])->name('settings.api-keys.edit'); + Route::post('/settings/api-keys', [ApiKeySettingsController::class, 'store'])->name('settings.api-keys.store'); + Route::delete('/settings/api-keys/{apiKey}', [ApiKeySettingsController::class, 'revoke'])->name('settings.api-keys.revoke'); Route::get('/wallet/settings', [WalletSettingsController::class, 'edit'])->name('wallet.settings.edit'); Route::post('/wallet/settings', [WalletSettingsController::class, 'update']) ->middleware('throttle:10,1') diff --git a/tests/Feature/ApiKeySettingsTest.php b/tests/Feature/ApiKeySettingsTest.php new file mode 100644 index 00000000..3c970906 --- /dev/null +++ b/tests/Feature/ApiKeySettingsTest.php @@ -0,0 +1,54 @@ +create(); + + $response = $this->actingAs($issuer)->post(route('settings.api-keys.store'), ['name' => 'Shop']); + $response->assertRedirect(route('settings.api-keys.edit')); + + $plain = session('api_key_plain'); + $this->assertStringStartsWith('cz_', $plain); + $this->assertTrue(ApiKey::findByPlain($plain)->user->is($issuer)); + + $this->actingAs($issuer)->get(route('settings.api-keys.edit'))->assertOk()->assertSee($plain)->assertSee('Shop'); + $this->actingAs($issuer)->get(route('settings.api-keys.edit'))->assertOk()->assertDontSee($plain); + } + + public function test_page_lists_keys_with_last_used_and_revoking_one(): void + { + $issuer = User::factory()->create(); + [$used, $plainUsed] = ApiKey::issue($issuer, 'Website'); + $used->touchUsed(); + [$spare] = ApiKey::issue($issuer, 'Spare'); + + $this->actingAs($issuer)->get(route('settings.api-keys.edit'))->assertOk() + ->assertSeeInOrder(['Website', 'Spare'])->assertSee('Never used'); + + $this->actingAs($issuer)->delete(route('settings.api-keys.revoke', $spare)) + ->assertRedirect(route('settings.api-keys.edit')); + + $this->assertNotNull($spare->fresh()->revoked_at); + $this->assertNotNull(ApiKey::findByPlain($plainUsed)); + $this->actingAs($issuer)->get(route('settings.api-keys.edit'))->assertOk()->assertSee('Revoked'); + } + + public function test_another_issuers_key_cannot_be_revoked(): void + { + [$key] = ApiKey::issue(User::factory()->create(), 'Theirs'); + + $this->actingAs(User::factory()->create())->delete(route('settings.api-keys.revoke', $key))->assertNotFound(); + $this->assertNull($key->fresh()->revoked_at); + } +} From e75f6b52fcb5dda2940a421d1fbdbd3042c65d7c Mon Sep 17 00:00:00 2001 From: Nate Barlow Date: Sun, 4 Oct 2026 01:26:11 -0600 Subject: [PATCH 4/5] =?UTF-8?q?M22.2=20=C2=A71.3=E2=80=931.4:=20bearer=20k?= =?UTF-8?q?ey=20auth=20and=20per-key=20rate=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L5W7g1rrgyVqEwZSgLg2QM --- app/Http/Middleware/AuthenticateApiKey.php | 47 ++++++++++++++++ bootstrap/app.php | 2 + config/api.php | 6 +++ docs/strategies/22.2_API_KEYS_AND_CONTRACT.md | 8 +-- tests/Feature/ApiKeyAuthTest.php | 54 +++++++++++++++++++ 5 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 app/Http/Middleware/AuthenticateApiKey.php create mode 100644 config/api.php create mode 100644 tests/Feature/ApiKeyAuthTest.php diff --git a/app/Http/Middleware/AuthenticateApiKey.php b/app/Http/Middleware/AuthenticateApiKey.php new file mode 100644 index 00000000..bd3ab750 --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiKey.php @@ -0,0 +1,47 @@ +bearerToken(); + $key = $plain ? ApiKey::findByPlain($plain) : null; + + if (! $key) { + return response()->json(['error' => [ + 'code' => 'unauthorized', + 'message' => 'Missing, unknown, or revoked API key.', + ]], 401); + } + + $bucket = 'api-key:' . $key->id; + $limit = config('api.rate_limit_per_minute'); + if (RateLimiter::tooManyAttempts($bucket, $limit)) { + $retry = RateLimiter::availableIn($bucket); + + return response()->json(['error' => [ + 'code' => 'rate_limited', + 'message' => "Too many requests. Retry in {$retry} seconds.", + ]], 429, ['Retry-After' => $retry]); + } + RateLimiter::hit($bucket, 60); + + $key->touchUsed(); + $request->setUserResolver(fn () => $key->user); + $request->attributes->set('api_key', $key); + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 8eda1e9f..103f070c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -45,6 +45,8 @@ ReassignInvoiceAddresses::class, ]) ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias(['api.key' => \App\Http\Middleware\AuthenticateApiKey::class]); + // A session whose account lost approval (gate on) or was banned is // dropped on its next request, not at its next login. $middleware->web(append: [ diff --git a/config/api.php b/config/api.php new file mode 100644 index 00000000..c35513ae --- /dev/null +++ b/config/api.php @@ -0,0 +1,6 @@ + (int) env('API_RATE_LIMIT_PER_MINUTE', 60), +]; diff --git a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md index 6dda56ee..61a36cad 100644 --- a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md +++ b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md @@ -1,6 +1,6 @@ # M22 Phase 2 — API Keys and Contract -Status: Approved; §1 active. +Status: Approved; §3 active. Parent: [M22](../milestones/22_LINE_ITEMS_DEVELOPER_API.md), Phase 2. Spec: [DEVELOPER_API.md](../specs/DEVELOPER_API.md). @@ -8,12 +8,12 @@ Goal: an issuer can make and revoke API keys, requests with a key are authentica Parallel work: §1 and §2 can run side by side. §3 waits for both. -## 1. [ ] Keys %<3020> +## 1. [x] Keys %<3020> 1. [x] Add the API key table and model: issuer, name, key hash, last used, revoked at. %<3021> 2. [x] Issuer settings page: make a key, see it once in full, see the list with last used, revoke one. %<3022> -3. [ ] Authenticate API requests by bearer key and resolve the issuer; a revoked or unknown key is refused. %<3023> -4. [ ] Rate limit per key; a limited request says when to retry. %<3024> +3. [x] Authenticate API requests by bearer key and resolve the issuer; a revoked or unknown key is refused. %<3023> +4. [x] Rate limit per key; a limited request says when to retry. %<3024> ## 2. [x] Contract %<3025> diff --git a/tests/Feature/ApiKeyAuthTest.php b/tests/Feature/ApiKeyAuthTest.php new file mode 100644 index 00000000..3f2aef54 --- /dev/null +++ b/tests/Feature/ApiKeyAuthTest.php @@ -0,0 +1,54 @@ +get('/api/v1/_probe', fn () => ['issuer' => request()->user()->id]); + } + + public function test_requests_without_a_live_key_are_refused(): void + { + $this->getJson('/api/v1/_probe')->assertStatus(401)->assertJsonPath('error.code', 'unauthorized'); + + $this->withToken('cz_unknown')->getJson('/api/v1/_probe')->assertStatus(401)->assertJsonPath('error.code', 'unauthorized'); + + [$key, $plain] = ApiKey::issue(User::factory()->create(), 'Old'); + $key->revoke(); + $this->withToken($plain)->getJson('/api/v1/_probe')->assertStatus(401); + } + + public function test_a_live_key_resolves_its_issuer_and_stamps_last_used(): void + { + $issuer = User::factory()->create(); + [$key, $plain] = ApiKey::issue($issuer, 'Shop'); + + $this->withToken($plain)->getJson('/api/v1/_probe')->assertOk()->assertJson(['issuer' => $issuer->id]); + + $this->assertNotNull($key->fresh()->last_used_at); + } + + public function test_requests_over_the_limit_say_when_to_retry(): void + { + config(['api.rate_limit_per_minute' => 2]); + RateLimiter::clear('api-key'); + [, $plain] = ApiKey::issue(User::factory()->create(), 'Busy'); + + $this->withToken($plain)->getJson('/api/v1/_probe')->assertOk(); + $this->withToken($plain)->getJson('/api/v1/_probe')->assertOk(); + $this->withToken($plain)->getJson('/api/v1/_probe') + ->assertStatus(429)->assertHeader('Retry-After')->assertJsonPath('error.code', 'rate_limited'); + } +} From 7697d37df13b5986687af3687603bb752d3af4c5 Mon Sep 17 00:00:00 2001 From: Nate Barlow Date: Sun, 4 Oct 2026 01:33:07 -0600 Subject: [PATCH 5/5] =?UTF-8?q?M22.2=20=C2=A73.1=E2=80=933.3:=20tests,=20s?= =?UTF-8?q?uite,=20browser=20check;=20hold=20the=20Make=20key=20button=20s?= =?UTF-8?q?till=20on=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L5W7g1rrgyVqEwZSgLg2QM --- docs/strategies/22.2_API_KEYS_AND_CONTRACT.md | 6 +++--- resources/views/settings/api-keys.blade.php | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md index 61a36cad..f88bb68d 100644 --- a/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md +++ b/docs/strategies/22.2_API_KEYS_AND_CONTRACT.md @@ -24,9 +24,9 @@ Parallel work: §1 and §2 can run side by side. §3 waits for both. ## 3. [ ] Verify %<3030> -1. [ ] [Agent] Targeted tests: key creation, one-time reveal, revoke, refused requests, rate limit, and the error format. %<3031> -2. [ ] [Agent] Full Sail suite. %<3032> -3. [ ] [Subagent] Browser check of the key settings page on desktop and mobile widths. %<3033> +1. [x] [Agent] Targeted tests: key creation, one-time reveal, revoke, refused requests, rate limit, and the error format. 9 pass. %<3031> +2. [x] [Agent] Full Sail suite. 662 pass. %<3032> +3. [x] [Subagent] Browser check of the key settings page on desktop and mobile widths. Pass; error line moved below the form row so the button holds still. %<3033> 4. [ ] [User] Review the key settings page on dev and the contract in `docs/API.md`. %<3034> 5. [ ] [Agent] Record the dev verdict in the milestone doc. %<3035> diff --git a/resources/views/settings/api-keys.blade.php b/resources/views/settings/api-keys.blade.php index 4276815c..b54accac 100644 --- a/resources/views/settings/api-keys.blade.php +++ b/resources/views/settings/api-keys.blade.php @@ -32,14 +32,16 @@

-
+ @csrf -
- - - +
+
+ + +
+ Make key
- Make key + @if ($keys->isEmpty())