From 5385385314bd3e621e3666741620ecaef75dceb3 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 21:01:54 +0100 Subject: [PATCH] =?UTF-8?q?feat(agent/api):=20RFC=20foundation=20=E2=80=94?= =?UTF-8?q?=20API=20keys,=20webhooks,=20rate=20limiting,=20docs=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation slice for Mantis #844 php/Mod/Api RFC implementation: * New php/Mod/Api/ package: Boot, Controllers, Documentation, Jobs, Middleware, Models, RateLimit, Routes, Services * Models: ApiKey, WebhookEndpoint, WebhookDelivery * WebhookService::dispatch() with DB::transaction + afterCommit * DeliverWebhookJob with retry/backoff * WebhookSignature with timing-safe verification + 5-minute tolerance + dual-secret rotation support * Sliding-window rate limiter in RateLimit/RateLimitService.php * AuthenticateApiKey middleware: hk_ prefix + Sanctum fallback * DocsController / DocumentationController split * 3 root migrations: api_keys, webhook_endpoints, webhook_deliveries * Foundation tests under php/tests/Feature/Mod/Api/ * FOLLOWUP.md tracks remaining RFC scope php -l clean across 21 PHP files. Pest unrunnable in sandbox (no vendor/). Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=844 --- ...026_04_25_000100_create_api_keys_table.php | 44 +++ ..._000101_create_webhook_endpoints_table.php | 45 +++ ...000102_create_webhook_deliveries_table.php | 42 +++ php/Mod/Api/Boot.php | 46 +++ php/Mod/Api/Controllers/DocsController.php | 26 ++ .../Documentation/DocumentationController.php | 33 ++ .../Middleware/ProtectDocumentation.php | 43 +++ php/Mod/Api/FOLLOWUP.md | 16 + php/Mod/Api/Jobs/DeliverWebhookJob.php | 82 +++++ php/Mod/Api/Middleware/AuthenticateApiKey.php | 94 ++++++ php/Mod/Api/Models/ApiKey.php | 192 ++++++++++++ php/Mod/Api/Models/WebhookDelivery.php | 169 ++++++++++ php/Mod/Api/Models/WebhookEndpoint.php | 183 +++++++++++ php/Mod/Api/RateLimit/RateLimit.php | 39 +++ php/Mod/Api/RateLimit/RateLimitResult.php | 52 +++ php/Mod/Api/RateLimit/RateLimitService.php | 296 ++++++++++++++++++ php/Mod/Api/Routes/api.php | 16 + php/Mod/Api/Services/WebhookService.php | 49 +++ php/Mod/Api/Services/WebhookSignature.php | 52 +++ .../Feature/Mod/Api/ApiKeyFoundationTest.php | 47 +++ .../Mod/Api/RateLimitFoundationTest.php | 59 ++++ .../Feature/Mod/Api/WebhookFoundationTest.php | 109 +++++++ 22 files changed, 1734 insertions(+) create mode 100644 php/Migrations/2026_04_25_000100_create_api_keys_table.php create mode 100644 php/Migrations/2026_04_25_000101_create_webhook_endpoints_table.php create mode 100644 php/Migrations/2026_04_25_000102_create_webhook_deliveries_table.php create mode 100644 php/Mod/Api/Boot.php create mode 100644 php/Mod/Api/Controllers/DocsController.php create mode 100644 php/Mod/Api/Documentation/DocumentationController.php create mode 100644 php/Mod/Api/Documentation/Middleware/ProtectDocumentation.php create mode 100644 php/Mod/Api/FOLLOWUP.md create mode 100644 php/Mod/Api/Jobs/DeliverWebhookJob.php create mode 100644 php/Mod/Api/Middleware/AuthenticateApiKey.php create mode 100644 php/Mod/Api/Models/ApiKey.php create mode 100644 php/Mod/Api/Models/WebhookDelivery.php create mode 100644 php/Mod/Api/Models/WebhookEndpoint.php create mode 100644 php/Mod/Api/RateLimit/RateLimit.php create mode 100644 php/Mod/Api/RateLimit/RateLimitResult.php create mode 100644 php/Mod/Api/RateLimit/RateLimitService.php create mode 100644 php/Mod/Api/Routes/api.php create mode 100644 php/Mod/Api/Services/WebhookService.php create mode 100644 php/Mod/Api/Services/WebhookSignature.php create mode 100644 php/tests/Feature/Mod/Api/ApiKeyFoundationTest.php create mode 100644 php/tests/Feature/Mod/Api/RateLimitFoundationTest.php create mode 100644 php/tests/Feature/Mod/Api/WebhookFoundationTest.php diff --git a/php/Migrations/2026_04_25_000100_create_api_keys_table.php b/php/Migrations/2026_04_25_000100_create_api_keys_table.php new file mode 100644 index 0000000..ce00813 --- /dev/null +++ b/php/Migrations/2026_04_25_000100_create_api_keys_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('name'); + $table->string('key', 255)->comment('Bcrypt hash of the secret portion only'); + $table->string('hash_algorithm', 16)->default('bcrypt'); + $table->string('prefix', 11)->comment('hk_xxxxxxxx'); + $table->json('scopes')->nullable(); + $table->json('allowed_ips')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('grace_period_ends_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['prefix', 'deleted_at']); + $table->index(['workspace_id', 'expires_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; diff --git a/php/Migrations/2026_04_25_000101_create_webhook_endpoints_table.php b/php/Migrations/2026_04_25_000101_create_webhook_endpoints_table.php new file mode 100644 index 0000000..228fb27 --- /dev/null +++ b/php/Migrations/2026_04_25_000101_create_webhook_endpoints_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('url'); + $table->string('secret', 255)->comment('Current signing secret'); + $table->string('previous_secret', 255)->nullable()->comment('Dual-secret rotation fallback'); + $table->timestamp('previous_secret_expires_at')->nullable(); + $table->json('events')->comment('Subscribed event types or ["*"]'); + $table->boolean('is_active')->default(true); + $table->string('description')->nullable(); + $table->timestamp('last_triggered_at')->nullable(); + $table->unsignedInteger('failure_count')->default(0); + $table->timestamp('disabled_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['workspace_id', 'is_active']); + $table->index(['is_active', 'disabled_at']); + $table->index('previous_secret_expires_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_endpoints'); + } +}; diff --git a/php/Migrations/2026_04_25_000102_create_webhook_deliveries_table.php b/php/Migrations/2026_04_25_000102_create_webhook_deliveries_table.php new file mode 100644 index 0000000..4548bd7 --- /dev/null +++ b/php/Migrations/2026_04_25_000102_create_webhook_deliveries_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('webhook_endpoint_id')->constrained('webhook_endpoints')->cascadeOnDelete(); + $table->string('event_id', 32)->index(); + $table->string('event_type', 128)->index(); + $table->json('payload'); + $table->unsignedSmallInteger('response_code')->nullable(); + $table->text('response_body')->nullable(); + $table->unsignedTinyInteger('attempt')->default(1); + $table->string('status', 16)->default('pending'); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('next_retry_at')->nullable(); + $table->timestamps(); + + $table->index(['webhook_endpoint_id', 'status']); + $table->index(['status', 'next_retry_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/php/Mod/Api/Boot.php b/php/Mod/Api/Boot.php new file mode 100644 index 0000000..2bd1a87 --- /dev/null +++ b/php/Mod/Api/Boot.php @@ -0,0 +1,46 @@ + + */ + public static array $listens = [ + ApiRoutesRegistering::class => 'onApiRoutes', + ]; + + public function register(): void + { + $this->app->singleton(RateLimitService::class, function ($app): RateLimitService { + return new RateLimitService($app->make(CacheRepository::class)); + }); + + $this->app->singleton(Services\WebhookSignature::class); + $this->app->singleton(Services\WebhookService::class); + } + + public function onApiRoutes(ApiRoutesRegistering $event): void + { + $event->middleware('api.auth', Middleware\AuthenticateApiKey::class); + $event->middleware('auth.api', Middleware\AuthenticateApiKey::class); + $event->middleware('api.docs.protect', ProtectDocumentation::class); + + if (file_exists(__DIR__.'/Routes/api.php')) { + $event->routes(fn () => require __DIR__.'/Routes/api.php'); + } + } +} diff --git a/php/Mod/Api/Controllers/DocsController.php b/php/Mod/Api/Controllers/DocsController.php new file mode 100644 index 0000000..fb846b7 --- /dev/null +++ b/php/Mod/Api/Controllers/DocsController.php @@ -0,0 +1,26 @@ +json([ + 'message' => 'Public API documentation portal follows in the next RFC slice.', + ], 501); + } + + public function openapi(): JsonResponse + { + return response()->json([ + 'message' => 'Public OpenAPI export follows in the next RFC slice.', + ], 501); + } +} diff --git a/php/Mod/Api/Documentation/DocumentationController.php b/php/Mod/Api/Documentation/DocumentationController.php new file mode 100644 index 0000000..08affcd --- /dev/null +++ b/php/Mod/Api/Documentation/DocumentationController.php @@ -0,0 +1,33 @@ +json([ + 'message' => 'Admin documentation tooling is reserved for the follow-up slice.', + ], 501); + } + + public function openApiJson(): JsonResponse + { + return response()->json([ + 'message' => 'OpenAPI generation is not included in the foundation slice.', + ], 501); + } + + public function clearCache(): JsonResponse + { + return response()->json([ + 'message' => 'Documentation cache clearing is not included in the foundation slice.', + ], 501); + } +} diff --git a/php/Mod/Api/Documentation/Middleware/ProtectDocumentation.php b/php/Mod/Api/Documentation/Middleware/ProtectDocumentation.php new file mode 100644 index 0000000..11ac377 --- /dev/null +++ b/php/Mod/Api/Documentation/Middleware/ProtectDocumentation.php @@ -0,0 +1,43 @@ +environment(), $publicEnvironments, true)) { + return $next($request); + } + + $ipWhitelist = $config['ip_whitelist'] ?? []; + if ($ipWhitelist !== []) { + if (! in_array($request->ip(), $ipWhitelist, true)) { + abort(403, 'Access denied.'); + } + + return $next($request); + } + + if (($config['require_auth'] ?? false) && ! $request->user()) { + abort(403, 'Documentation access requires authentication.'); + } + + return $next($request); + } +} diff --git a/php/Mod/Api/FOLLOWUP.md b/php/Mod/Api/FOLLOWUP.md new file mode 100644 index 0000000..ed06f71 --- /dev/null +++ b/php/Mod/Api/FOLLOWUP.md @@ -0,0 +1,16 @@ +# API Follow-Up + +Foundation delivered in this slice: +- `ApiKey`, `WebhookEndpoint`, and `WebhookDelivery` models with root migrations. +- `WebhookService::dispatch()` wrapped in `DB::transaction()` with queued jobs using `->afterCommit()`. +- `DeliverWebhookJob`, `WebhookSignature`, `RateLimitService`, and API key middleware with Sanctum fallback. +- New `Boot` event listener for `ApiRoutesRegistering`. +- Canonical controller split: `DocsController` for public work and `DocumentationController` for protected admin work. + +Remaining RFC work: +- Register the new API module provider in the package entry point so the nested module boots without explicit test registration. +- Build the REST surface: webhook CRUD, API key CRUD, delivery inspection, retry endpoints, and gateway controllers. +- Wire real documentation views, OpenAPI generation, and protected admin docs routes. +- Add rate-limit middleware integration, response headers, and per-endpoint policy wiring on the route layer. +- Extend webhook delivery operations with queue maintenance, replay tooling, and the remaining backoff policy edge cases. +- Add broader coverage for middleware auth flows, docs protection, and end-to-end queue delivery. diff --git a/php/Mod/Api/Jobs/DeliverWebhookJob.php b/php/Mod/Api/Jobs/DeliverWebhookJob.php new file mode 100644 index 0000000..5a68263 --- /dev/null +++ b/php/Mod/Api/Jobs/DeliverWebhookJob.php @@ -0,0 +1,82 @@ +delivery->fresh(['endpoint']); + + if (! $delivery instanceof WebhookDelivery) { + return; + } + + $endpoint = $delivery->endpoint; + if ($endpoint === null || ! $endpoint->shouldReceive($delivery->event_type)) { + $delivery->forceFill(['status' => WebhookDelivery::STATUS_CANCELLED])->save(); + + return; + } + + $delivery->forceFill(['status' => WebhookDelivery::STATUS_QUEUED])->save(); + $payload = $delivery->getDeliveryPayload(); + + try { + $response = Http::timeout(10) + ->withHeaders($payload['headers']) + ->withBody($payload['body'], 'application/json') + ->post($endpoint->url); + + if ($response->successful()) { + $delivery->markSuccess($response->status(), $response->body()); + + return; + } + + $this->handleFailure($delivery, $response->status(), $response->body()); + } catch (ConnectionException $exception) { + $this->handleFailure($delivery, 0, 'Connection failed: '.$exception->getMessage()); + } catch (\Throwable $exception) { + $this->handleFailure($delivery, 0, 'Unexpected error: '.$exception->getMessage()); + } + } + + protected function handleFailure(WebhookDelivery $delivery, int $statusCode, ?string $responseBody): void + { + $delivery->markFailed($statusCode, $responseBody); + $delivery->refresh(); + + if (! $delivery->canRetry() || $delivery->next_retry_at === null) { + return; + } + + self::dispatch($delivery->fresh())->delay($delivery->next_retry_at); + } +} diff --git a/php/Mod/Api/Middleware/AuthenticateApiKey.php b/php/Mod/Api/Middleware/AuthenticateApiKey.php new file mode 100644 index 0000000..15c267e --- /dev/null +++ b/php/Mod/Api/Middleware/AuthenticateApiKey.php @@ -0,0 +1,94 @@ +bearerToken(); + + if ($token === null || $token === '') { + return $this->unauthorised('API key required. Use Authorization: Bearer '); + } + + if (str_starts_with($token, 'hk_')) { + return $this->authenticateApiKey($request, $next, $token, $scope); + } + + return $this->authenticateSanctum($request, $next); + } + + protected function authenticateApiKey(Request $request, Closure $next, string $token, ?string $scope): Response + { + $apiKey = ApiKey::findByPlainKey($token); + + if (! $apiKey instanceof ApiKey) { + return $this->unauthorised('Invalid API key'); + } + + if ($apiKey->isExpired()) { + return $this->unauthorised('API key has expired'); + } + + if ($apiKey->hasIpRestrictions() && ! in_array((string) $request->ip(), $apiKey->getAllowedIps(), true)) { + return $this->forbidden('IP address not allowed for this API key'); + } + + if ($scope !== null && ! $apiKey->hasScope($scope)) { + return $this->forbidden("API key missing required scope: {$scope}"); + } + + $apiKey->recordUsage(); + + $request->setUserResolver(fn () => $apiKey->user); + $request->attributes->set('api_key', $apiKey); + $request->attributes->set('workspace', $apiKey->workspace); + $request->attributes->set('workspace_id', $apiKey->workspace_id); + $request->attributes->set('auth_type', 'api_key'); + + return $next($request); + } + + protected function authenticateSanctum(Request $request, Closure $next): Response + { + if (! $request->user()) { + $guard = auth('sanctum'); + + if (! $guard->check()) { + return $this->unauthorised('Invalid authentication token'); + } + + $request->setUserResolver(fn () => $guard->user()); + } + + $request->attributes->set('auth_type', 'sanctum'); + + return $next($request); + } + + protected function unauthorised(string $message): Response + { + return response()->json([ + 'error' => 'unauthorised', + 'message' => $message, + ], 401); + } + + protected function forbidden(string $message): Response + { + return response()->json([ + 'error' => 'forbidden', + 'message' => $message, + ], 403); + } +} diff --git a/php/Mod/Api/Models/ApiKey.php b/php/Mod/Api/Models/ApiKey.php new file mode 100644 index 0000000..083d1b0 --- /dev/null +++ b/php/Mod/Api/Models/ApiKey.php @@ -0,0 +1,192 @@ + 'array', + 'allowed_ips' => 'array', + 'last_used_at' => 'datetime', + 'expires_at' => 'datetime', + 'grace_period_ends_at' => 'datetime', + ]; + + protected $hidden = [ + 'key', + ]; + + /** + * Create a bcrypt-backed API key using the hk_xxxxxxxx_xxxxx format. + * + * Example: + * `ApiKey::generate(12, null, 'MCP Gateway')` + */ + public static function generate( + int $workspaceId, + ?int $userId, + string $name, + array $scopes = self::DEFAULT_SCOPES, + ?DateTimeInterface $expiresAt = null + ): array { + $key = Str::random(48); + $prefix = 'hk_'.Str::random(8); + + $apiKey = static::query()->create([ + 'workspace_id' => $workspaceId, + 'user_id' => $userId, + 'name' => $name, + 'key' => password_hash($key, PASSWORD_BCRYPT), + 'hash_algorithm' => self::HASH_BCRYPT, + 'prefix' => $prefix, + 'scopes' => array_values($scopes), + 'expires_at' => $expiresAt, + ]); + + return [ + 'api_key' => $apiKey, + 'plain_key' => "{$prefix}_{$key}", + ]; + } + + /** + * Find a stored API key using the plain hk_ token format. + * + * The lookup is prefix-first, then password_verify() on each candidate. + * We never hash the plain token and query the hash column directly. + */ + public static function findByPlainKey(string $plainKey): ?static + { + if (! str_starts_with($plainKey, 'hk_')) { + return null; + } + + $parts = explode('_', $plainKey, 3); + if (count($parts) !== 3 || strlen($parts[1]) !== 8 || strlen($parts[2]) !== 48) { + return null; + } + + $prefix = $parts[0].'_'.$parts[1]; + $secret = $parts[2]; + + $candidates = static::query() + ->where('prefix', $prefix) + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->where(function ($query) { + $query->whereNull('grace_period_ends_at') + ->orWhere('grace_period_ends_at', '>', now()); + }) + ->get(); + + foreach ($candidates as $candidate) { + if ($candidate->verifyKey($secret)) { + return $candidate; + } + } + + return null; + } + + public function verifyKey(string $plainKey): bool + { + return password_verify($plainKey, $this->key); + } + + public function recordUsage(): void + { + $this->forceFill(['last_used_at' => now()])->save(); + } + + public function revoke(): void + { + $this->delete(); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes ?? [], true); + } + + public function hasScopes(array $scopes): bool + { + foreach ($scopes as $scope) { + if (! $this->hasScope((string) $scope)) { + return false; + } + } + + return true; + } + + public function hasIpRestrictions(): bool + { + return ($this->allowed_ips ?? []) !== []; + } + + /** + * @return array + */ + public function getAllowedIps(): array + { + return array_values($this->allowed_ips ?? []); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/php/Mod/Api/Models/WebhookDelivery.php b/php/Mod/Api/Models/WebhookDelivery.php new file mode 100644 index 0000000..4a7d7b3 --- /dev/null +++ b/php/Mod/Api/Models/WebhookDelivery.php @@ -0,0 +1,169 @@ + 300, + 2 => 900, + 3 => 3600, + 4 => 14400, + 5 => 86400, + ]; + + protected $fillable = [ + 'webhook_endpoint_id', + 'event_id', + 'event_type', + 'payload', + 'response_code', + 'response_body', + 'attempt', + 'status', + 'delivered_at', + 'next_retry_at', + ]; + + protected $casts = [ + 'payload' => 'array', + 'delivered_at' => 'datetime', + 'next_retry_at' => 'datetime', + ]; + + public static function createForEvent( + WebhookEndpoint $endpoint, + string $eventType, + array $data, + ?int $workspaceId = null + ): static { + return static::query()->create([ + 'webhook_endpoint_id' => $endpoint->getKey(), + 'event_id' => 'evt_'.Str::random(24), + 'event_type' => $eventType, + 'payload' => [ + 'id' => 'evt_'.Str::random(24), + 'type' => $eventType, + 'created_at' => now()->toIso8601String(), + 'workspace_id' => $workspaceId, + 'data' => $data, + ], + 'status' => self::STATUS_PENDING, + 'attempt' => 1, + ]); + } + + public function markSuccess(int $responseCode, ?string $responseBody = null): void + { + $this->forceFill([ + 'status' => self::STATUS_SUCCESS, + 'response_code' => $responseCode, + 'response_body' => $responseBody !== null ? Str::limit($responseBody, 10000) : null, + 'delivered_at' => now(), + 'next_retry_at' => null, + ])->save(); + + $this->endpoint?->recordSuccess(); + } + + public function markFailed(int $responseCode, ?string $responseBody = null): void + { + $this->endpoint?->recordFailure(); + + if ($this->attempt >= self::MAX_ATTEMPTS) { + $this->forceFill([ + 'status' => self::STATUS_FAILED, + 'response_code' => $responseCode, + 'response_body' => $responseBody !== null ? Str::limit($responseBody, 10000) : null, + 'next_retry_at' => null, + ])->save(); + + return; + } + + $delay = self::RETRY_DELAYS[$this->attempt] ?? end(self::RETRY_DELAYS); + + $this->forceFill([ + 'status' => self::STATUS_RETRYING, + 'response_code' => $responseCode, + 'response_body' => $responseBody !== null ? Str::limit($responseBody, 10000) : null, + 'attempt' => $this->attempt + 1, + 'next_retry_at' => now()->addSeconds((int) $delay), + ])->save(); + } + + public function canRetry(): bool + { + return $this->attempt < self::MAX_ATTEMPTS + && $this->status !== self::STATUS_SUCCESS; + } + + /** + * @return array{headers: array, body: string} + */ + public function getDeliveryPayload(?int $timestamp = null): array + { + $timestamp ??= time(); + $body = json_encode($this->payload, JSON_THROW_ON_ERROR); + + return [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Webhook-Id' => $this->event_id, + 'X-Webhook-Event' => $this->event_type, + 'X-Webhook-Timestamp' => (string) $timestamp, + 'X-Webhook-Signature' => $this->endpoint->generateSignature($body, $timestamp), + ], + 'body' => $body, + ]; + } + + /** + * @return array + */ + public static function retrySchedule(): array + { + return array_values(self::RETRY_DELAYS); + } + + public function endpoint(): BelongsTo + { + return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id'); + } + + public function scopeNeedsDelivery($query) + { + return $query->where(function ($builder) { + $builder->where('status', self::STATUS_PENDING) + ->orWhere(function ($retrying) { + $retrying->where('status', self::STATUS_RETRYING) + ->where('next_retry_at', '<=', now()); + }); + }); + } +} diff --git a/php/Mod/Api/Models/WebhookEndpoint.php b/php/Mod/Api/Models/WebhookEndpoint.php new file mode 100644 index 0000000..7795fba --- /dev/null +++ b/php/Mod/Api/Models/WebhookEndpoint.php @@ -0,0 +1,183 @@ + 'array', + 'is_active' => 'boolean', + 'previous_secret_expires_at' => 'datetime', + 'last_triggered_at' => 'datetime', + 'disabled_at' => 'datetime', + ]; + + protected $hidden = [ + 'secret', + 'previous_secret', + ]; + + public static function createForWorkspace( + int $workspaceId, + string $url, + array $events, + ?string $description = null + ): static { + $signature = app(WebhookSignature::class); + + return static::query()->create([ + 'workspace_id' => $workspaceId, + 'url' => $url, + 'secret' => $signature->generateSecret(), + 'events' => array_values($events), + 'is_active' => true, + 'description' => $description, + 'failure_count' => 0, + ]); + } + + public function shouldReceive(string $eventType): bool + { + if (! $this->is_active || $this->disabled_at !== null) { + return false; + } + + return in_array($eventType, $this->events ?? [], true) + || in_array('*', $this->events ?? [], true); + } + + public function generateSignature(string $payload, int $timestamp): string + { + return app(WebhookSignature::class)->sign($payload, $this->secret, $timestamp); + } + + public function verifySignature( + string $payload, + string $signature, + int $timestamp, + int $tolerance = WebhookSignature::DEFAULT_TOLERANCE + ): bool { + $signer = app(WebhookSignature::class); + + if ($signer->verify($payload, $signature, $this->secret, $timestamp, $tolerance)) { + return true; + } + + if (! $this->hasPreviousSecret()) { + return false; + } + + return $signer->verify($payload, $signature, (string) $this->previous_secret, $timestamp, $tolerance); + } + + public function rotateSecret(int $gracePeriodSeconds = 86400): string + { + $signer = app(WebhookSignature::class); + $newSecret = $signer->generateSecret(); + + $this->forceFill([ + 'previous_secret' => $this->secret, + 'previous_secret_expires_at' => now()->addSeconds($gracePeriodSeconds), + 'secret' => $newSecret, + ])->save(); + + return $newSecret; + } + + public function recordSuccess(): void + { + $this->forceFill([ + 'last_triggered_at' => now(), + 'failure_count' => 0, + ])->save(); + } + + public function recordFailure(): void + { + $failureCount = $this->failure_count + 1; + + $updates = [ + 'failure_count' => $failureCount, + 'last_triggered_at' => now(), + ]; + + if ($failureCount >= 10) { + $updates['is_active'] = false; + $updates['disabled_at'] = now(); + } + + $this->forceFill($updates)->save(); + } + + public function enable(): void + { + $this->forceFill([ + 'is_active' => true, + 'disabled_at' => null, + 'failure_count' => 0, + ])->save(); + } + + public function hasPreviousSecret(): bool + { + return $this->previous_secret !== null + && $this->previous_secret_expires_at !== null + && $this->previous_secret_expires_at->isFuture(); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'webhook_endpoint_id'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true) + ->whereNull('disabled_at'); + } + + public function scopeForWorkspace($query, int $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + public function scopeForEvent($query, string $eventType) + { + return $query->where(function ($builder) use ($eventType) { + $builder->whereJsonContains('events', $eventType) + ->orWhereJsonContains('events', '*'); + }); + } +} diff --git a/php/Mod/Api/RateLimit/RateLimit.php b/php/Mod/Api/RateLimit/RateLimit.php new file mode 100644 index 0000000..c9fa0ff --- /dev/null +++ b/php/Mod/Api/RateLimit/RateLimit.php @@ -0,0 +1,39 @@ +key = ''; + $this->limit = $key; + } else { + $this->key = (string) $key; + $this->limit = $limit ?? 60; + } + + $this->window = $window; + $this->burst = $burst; + } +} diff --git a/php/Mod/Api/RateLimit/RateLimitResult.php b/php/Mod/Api/RateLimit/RateLimitResult.php new file mode 100644 index 0000000..701c4e9 --- /dev/null +++ b/php/Mod/Api/RateLimit/RateLimitResult.php @@ -0,0 +1,52 @@ +resetsAt = $resetAt; + } + + public static function allowed(int $limit, int $remaining, Carbon $resetAt): self + { + return new self(true, $limit, $remaining, null, $resetAt); + } + + public static function denied(int $limit, int $retryAfter, Carbon $resetAt): self + { + return new self(false, $limit, 0, $retryAfter, $resetAt); + } + + /** + * @return array + */ + public function headers(): array + { + $headers = [ + 'X-RateLimit-Limit' => $this->limit, + 'X-RateLimit-Remaining' => $this->remaining, + 'X-RateLimit-Reset' => $this->resetAt->timestamp, + ]; + + if (! $this->allowed && $this->retryAfter !== null) { + $headers['Retry-After'] = $this->retryAfter; + } + + return $headers; + } +} diff --git a/php/Mod/Api/RateLimit/RateLimitService.php b/php/Mod/Api/RateLimit/RateLimitService.php new file mode 100644 index 0000000..cfa2011 --- /dev/null +++ b/php/Mod/Api/RateLimit/RateLimitService.php @@ -0,0 +1,296 @@ +defaultRateLimit(); + + return $this->check( + key: $this->resolveLimitKey($apiKey, $endpoint, $rateLimit), + limit: $rateLimit->limit, + window: $rateLimit->window, + burst: $rateLimit->burst, + ); + } + + public function recordHit(mixed $apiKey = null, string $endpoint = 'global', ?RateLimit $rateLimit = null): RateLimitResult + { + $rateLimit ??= $this->defaultRateLimit(); + + return $this->hit( + key: $this->resolveLimitKey($apiKey, $endpoint, $rateLimit), + limit: $rateLimit->limit, + window: $rateLimit->window, + burst: $rateLimit->burst, + ); + } + + public function resetFor(mixed $apiKey = null, string $endpoint = 'global', ?RateLimit $rateLimit = null): void + { + $rateLimit ??= $this->defaultRateLimit(); + + $this->reset($this->resolveLimitKey($apiKey, $endpoint, $rateLimit)); + } + + public function check(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult + { + $cacheKey = $this->getCacheKey($key); + $effectiveLimit = $this->effectiveLimit($limit, $burst); + $this->guardWindow($window); + + $now = Carbon::now(); + $windowStart = $now->timestamp - $window; + $hits = $this->getWindowHits($cacheKey, $windowStart); + $currentCount = count($hits); + $remaining = max(0, $effectiveLimit - $currentCount); + $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit); + + if ($currentCount >= $effectiveLimit) { + $oldestHit = min($hits); + $retryAfter = max(1, ($oldestHit + $window) - $now->timestamp); + + return RateLimitResult::denied($limit, $retryAfter, $resetsAt); + } + + return RateLimitResult::allowed($limit, $remaining, $resetsAt); + } + + public function hit(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult + { + $cacheKey = $this->getCacheKey($key); + $effectiveLimit = $this->effectiveLimit($limit, $burst); + $this->guardWindow($window); + + $now = Carbon::now(); + $windowStart = $now->timestamp - $window; + $hits = $this->getWindowHits($cacheKey, $windowStart); + + if (count($hits) >= $effectiveLimit) { + $oldestHit = min($hits); + $retryAfter = max(1, ($oldestHit + $window) - $now->timestamp); + + return RateLimitResult::denied( + $limit, + $retryAfter, + $this->calculateResetTime($hits, $window, $effectiveLimit), + ); + } + + $hits[] = $now->timestamp; + $this->storeWindowHits($cacheKey, $hits, $window); + + return RateLimitResult::allowed( + $limit, + max(0, $effectiveLimit - count($hits)), + $this->calculateResetTime($hits, $window, $effectiveLimit), + ); + } + + public function remaining(string $key, int $limit, int $window, float $burst = 1.0): int + { + $this->guardWindow($window); + + return max( + 0, + $this->effectiveLimit($limit, $burst) + - count($this->getWindowHits($this->getCacheKey($key), Carbon::now()->timestamp - $window)), + ); + } + + public function reset(string $key): void + { + $this->cache->forget($this->getCacheKey($key)); + } + + public function attempts(string $key, int $window): int + { + $this->guardWindow($window); + + return count($this->getWindowHits($this->getCacheKey($key), Carbon::now()->timestamp - $window)); + } + + public function buildEndpointKey(string $identifier, string $endpoint): string + { + return "endpoint:{$identifier}:{$endpoint}"; + } + + public function buildWorkspaceKey(int $workspaceId, ?string $suffix = null): string + { + return $suffix === null + ? "workspace:{$workspaceId}" + : "workspace:{$workspaceId}:{$suffix}"; + } + + public function buildApiKeyKey(int|string $apiKeyId, ?string $suffix = null): string + { + return $suffix === null + ? "api_key:{$apiKeyId}" + : "api_key:{$apiKeyId}:{$suffix}"; + } + + public function buildIpKey(string $ip, ?string $suffix = null): string + { + return $suffix === null + ? "ip:{$ip}" + : "ip:{$ip}:{$suffix}"; + } + + /** + * @return array + */ + protected function getWindowHits(string $cacheKey, int $windowStart): array + { + $hits = $this->cache->get($cacheKey, []); + + if (! is_array($hits)) { + return []; + } + + $windowHits = []; + + foreach ($hits as $timestamp) { + if (! is_int($timestamp) && ! is_float($timestamp) && ! is_string($timestamp)) { + continue; + } + + $timestamp = (int) $timestamp; + + if ($timestamp >= $windowStart) { + $windowHits[] = $timestamp; + } + } + + return $windowHits; + } + + /** + * @param array $hits + */ + protected function storeWindowHits(string $cacheKey, array $hits, int $window): void + { + $this->cache->put($cacheKey, $hits, $window + 60); + } + + /** + * @param array $hits + */ + protected function calculateResetTime(array $hits, int $window, int $limit): Carbon + { + if ($hits === [] || count($hits) < $limit) { + return Carbon::now()->addSeconds($window); + } + + return Carbon::createFromTimestamp(min($hits) + $window); + } + + protected function getCacheKey(string $key): string + { + return self::CACHE_PREFIX.$key; + } + + protected function resolveLimitKey(mixed $apiKey, string $endpoint, RateLimit $rateLimit): string + { + if ($rateLimit->key !== '') { + return $rateLimit->key; + } + + return $this->buildEndpointKey( + $this->normaliseApiKey($apiKey), + $this->normaliseEndpoint($endpoint), + ); + } + + protected function defaultRateLimit(): RateLimit + { + $config = config('api.rate_limiting', []); + + if (! is_array($config) || $config === []) { + return new RateLimit(limit: 60, window: 60, burst: 1.0); + } + + return new RateLimit( + limit: (int) ($config['default_limit'] ?? $config['limit'] ?? 60), + window: (int) ($config['default_window'] ?? $config['window'] ?? 60), + burst: (float) ($config['default_burst'] ?? $config['burst'] ?? 1.0), + ); + } + + protected function effectiveLimit(int $limit, float $burst): int + { + if ($limit < 1) { + throw new InvalidArgumentException('Rate limit must be at least 1.'); + } + + if ($burst <= 0) { + throw new InvalidArgumentException('Rate limit burst must be greater than zero.'); + } + + return max(1, (int) floor($limit * $burst)); + } + + protected function guardWindow(int $window): void + { + if ($window < 1) { + throw new InvalidArgumentException('Rate limit window must be at least 1 second.'); + } + } + + protected function normaliseApiKey(mixed $apiKey): string + { + if ($apiKey === null || $apiKey === '') { + return 'api_key:anonymous'; + } + + if (is_int($apiKey) || is_string($apiKey)) { + $identifier = (string) $apiKey; + + return str_contains($identifier, ':') ? $identifier : "api_key:{$identifier}"; + } + + if (is_object($apiKey)) { + if (method_exists($apiKey, 'getKey')) { + $key = $apiKey->getKey(); + + if (is_int($key) || is_string($key)) { + return "api_key:{$key}"; + } + } + + if (isset($apiKey->id) && (is_int($apiKey->id) || is_string($apiKey->id))) { + return "api_key:{$apiKey->id}"; + } + + if (method_exists($apiKey, '__toString')) { + return (string) $apiKey; + } + + return 'api_key:object:'.spl_object_id($apiKey); + } + + return 'api_key:'.md5(serialize($apiKey)); + } + + protected function normaliseEndpoint(string $endpoint): string + { + $endpoint = trim($endpoint); + + return $endpoint === '' ? 'global' : $endpoint; + } +} diff --git a/php/Mod/Api/Routes/api.php b/php/Mod/Api/Routes/api.php new file mode 100644 index 0000000..2f1937d --- /dev/null +++ b/php/Mod/Api/Routes/api.php @@ -0,0 +1,16 @@ + 'brain_recall'])` + * + * @return array + */ + public function dispatch(int $workspaceId, string $eventType, array $data): array + { + $endpoints = WebhookEndpoint::query() + ->forWorkspace($workspaceId) + ->active() + ->forEvent($eventType) + ->get(); + + if ($endpoints->isEmpty()) { + return []; + } + + $deliveries = []; + + DB::transaction(function () use ($data, $endpoints, $eventType, $workspaceId, &$deliveries): void { + foreach ($endpoints as $endpoint) { + $delivery = WebhookDelivery::createForEvent($endpoint, $eventType, $data, $workspaceId); + $deliveries[] = $delivery; + + DeliverWebhookJob::dispatch($delivery)->afterCommit(); + } + }); + + return $deliveries; + } +} diff --git a/php/Mod/Api/Services/WebhookSignature.php b/php/Mod/Api/Services/WebhookSignature.php new file mode 100644 index 0000000..f9be6b5 --- /dev/null +++ b/php/Mod/Api/Services/WebhookSignature.php @@ -0,0 +1,52 @@ +isTimestampValid($timestamp, $tolerance)) { + return false; + } + + return hash_equals($this->sign($payload, $secret, $timestamp), $signature); + } + + public function verifySignatureOnly( + string $payload, + string $signature, + string $secret, + int $timestamp + ): bool { + return hash_equals($this->sign($payload, $secret, $timestamp), $signature); + } + + public function isTimestampValid(int $timestamp, int $tolerance = self::DEFAULT_TOLERANCE): bool + { + return abs(time() - $timestamp) <= $tolerance; + } +} diff --git a/php/tests/Feature/Mod/Api/ApiKeyFoundationTest.php b/php/tests/Feature/Mod/Api/ApiKeyFoundationTest.php new file mode 100644 index 0000000..9e7c187 --- /dev/null +++ b/php/tests/Feature/Mod/Api/ApiKeyFoundationTest.php @@ -0,0 +1,47 @@ +app->register(ApiBoot::class); +}); + +describe('ApiKey foundation', function () { + it('generates bcrypt-backed hk keys with the required format', function (): void { + $workspace = createWorkspace(); + + $result = ApiKey::generate($workspace->id, null, 'Gateway Key'); + + expect($result['plain_key'])->toMatch('/^hk_[A-Za-z0-9]{8}_[A-Za-z0-9]{48}$/') + ->and($result['api_key']->prefix)->toBe(substr($result['plain_key'], 0, 11)) + ->and(password_get_info($result['api_key']->key)['algoName'])->toBe('bcrypt') + ->and(password_verify(explode('_', $result['plain_key'], 3)[2], $result['api_key']->key))->toBeTrue(); + }); + + it('finds a key by prefix and candidate verification rather than hashing in the query', function (): void { + $workspace = createWorkspace(); + $result = ApiKey::generate($workspace->id, null, 'Lookup Key'); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $found = ApiKey::findByPlainKey($result['plain_key']); + $queries = collect(DB::getQueryLog())->pluck('query')->implode("\n"); + + expect($found?->is($result['api_key']))->toBeTrue() + ->and($queries)->toContain('prefix'); + }); + + it('rejects malformed and expired keys', function (): void { + $workspace = createWorkspace(); + $expired = ApiKey::generate($workspace->id, null, 'Expired Key', expiresAt: now()->subMinute()); + + expect(ApiKey::findByPlainKey(''))->toBeNull() + ->and(ApiKey::findByPlainKey('hk_short'))->toBeNull() + ->and(ApiKey::findByPlainKey($expired['plain_key']))->toBeNull(); + }); +}); diff --git a/php/tests/Feature/Mod/Api/RateLimitFoundationTest.php b/php/tests/Feature/Mod/Api/RateLimitFoundationTest.php new file mode 100644 index 0000000..bee1fa6 --- /dev/null +++ b/php/tests/Feature/Mod/Api/RateLimitFoundationTest.php @@ -0,0 +1,59 @@ +service = new RateLimitService(new Repository(new ArrayStore)); +}); + +afterEach(function (): void { + Carbon::setTestNow(); +}); + +describe('RateLimit foundation', function () { + it('tracks hits separately from limit checks using a sliding window', function (): void { + $rateLimit = new RateLimit(limit: 2, window: 60); + $bucket = $this->service->buildEndpointKey('api_key:demo', 'docs.index'); + + $checked = $this->service->checkLimit('demo', 'docs.index', $rateLimit); + $recorded = $this->service->recordHit('demo', 'docs.index', $rateLimit); + + expect($checked->allowed)->toBeTrue() + ->and($checked->remaining)->toBe(2) + ->and($recorded->allowed)->toBeTrue() + ->and($recorded->remaining)->toBe(1) + ->and($this->service->attempts($bucket, 60))->toBe(1); + }); + + it('denies requests once the current window is full', function (): void { + $rateLimit = new RateLimit(limit: 2, window: 60); + + $this->service->recordHit('demo', 'docs.index', $rateLimit); + $this->service->recordHit('demo', 'docs.index', $rateLimit); + + $result = $this->service->checkLimit('demo', 'docs.index', $rateLimit); + + expect($result->allowed)->toBeFalse() + ->and($result->remaining)->toBe(0) + ->and($result->retryAfter)->toBe(60); + }); + + it('expires old hits and rejects invalid limit definitions', function (): void { + $rateLimit = new RateLimit(limit: 2, window: 60); + + $this->service->recordHit('demo', 'docs.index', $rateLimit); + Carbon::setTestNow(Carbon::parse('2026-04-25 12:01:01')); + + expect($this->service->checkLimit('demo', 'docs.index', $rateLimit)->allowed)->toBeTrue(); + expect(fn () => $this->service->checkLimit('demo', 'docs.index', new RateLimit(limit: 0, window: 60))) + ->toThrow(InvalidArgumentException::class); + }); +}); diff --git a/php/tests/Feature/Mod/Api/WebhookFoundationTest.php b/php/tests/Feature/Mod/Api/WebhookFoundationTest.php new file mode 100644 index 0000000..aeb96b1 --- /dev/null +++ b/php/tests/Feature/Mod/Api/WebhookFoundationTest.php @@ -0,0 +1,109 @@ +app->register(ApiBoot::class); + $this->workspace = createWorkspace(); +}); + +afterEach(function (): void { + Carbon::setTestNow(); +}); + +describe('Webhook foundation', function () { + it('queues deliveries after the transaction commits', function (): void { + Queue::fake(); + + WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhooks/core', + ['mcp.tool.executed'], + ); + + $deliveries = app(WebhookService::class)->dispatch( + $this->workspace->id, + 'mcp.tool.executed', + ['tool' => 'brain_recall'], + ); + + expect($deliveries)->toHaveCount(1) + ->and(WebhookDelivery::count())->toBe(1); + + Queue::assertPushed(DeliverWebhookJob::class, function (DeliverWebhookJob $job): bool { + return $job->delivery->event_type === 'mcp.tool.executed'; + }); + }); + + it('accepts both current and previous secrets during the rotation window', function (): void { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhooks/core', + ['mcp.tool.executed'], + ); + + $payload = '{"tool":"brain_recall"}'; + $timestamp = time(); + $signer = app(WebhookSignature::class); + $oldSecret = $endpoint->getRawOriginal('secret'); + $oldSignature = $signer->sign($payload, (string) $oldSecret, $timestamp); + + $newSecret = $endpoint->rotateSecret(300); + $endpoint->refresh(); + + expect($endpoint->verifySignature($payload, $oldSignature, $timestamp))->toBeTrue() + ->and($endpoint->verifySignature($payload, $signer->sign($payload, $newSecret, $timestamp), $timestamp))->toBeTrue(); + + $endpoint->update(['previous_secret_expires_at' => now()->subSecond()]); + + expect($endpoint->fresh()->verifySignature($payload, $oldSignature, $timestamp))->toBeFalse(); + }); + + it('auto-disables endpoints after ten consecutive failures', function (): void { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhooks/core', + ['mcp.tool.executed'], + ); + + foreach (range(1, 10) as $attempt) { + $endpoint->recordFailure(); + } + + expect($endpoint->fresh()->failure_count)->toBe(10) + ->and($endpoint->fresh()->is_active)->toBeFalse() + ->and($endpoint->fresh()->disabled_at)->not->toBeNull(); + }); + + it('schedules the first retry five minutes after a failed delivery', function (): void { + Carbon::setTestNow(Carbon::parse('2026-04-25 12:00:00')); + + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhooks/core', + ['mcp.tool.executed'], + ); + + $delivery = WebhookDelivery::createForEvent( + $endpoint, + 'mcp.tool.executed', + ['tool' => 'brain_recall'], + $this->workspace->id, + ); + + $delivery->markFailed(500, 'Upstream timeout'); + + expect($delivery->fresh()->attempt)->toBe(2) + ->and($delivery->fresh()->status)->toBe(WebhookDelivery::STATUS_RETRYING) + ->and($delivery->fresh()->next_retry_at?->equalTo(now()->addMinutes(5)))->toBeTrue(); + }); +});