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.
+
+
+
+
+
+ @if ($keys->isEmpty())
+
No keys yet.
+ @else
+
+
+
+ | Name |
+ Made |
+ Last used |
+ |
+
+
+
+ @foreach ($keys as $key)
+
+ | {{ $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
+
+ @endif
+ |
+
+ @endforeach
+
+
+ @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 @@
-