feat(agent/api): RFC foundation — API keys, webhooks, rate limiting, docs split
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 <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=844
This commit is contained in:
parent
429d1c0897
commit
5385385314
22 changed files with 1734 additions and 0 deletions
44
php/Migrations/2026_04_25_000100_create_api_keys_table.php
Normal file
44
php/Migrations/2026_04_25_000100_create_api_keys_table.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
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
|
||||
{
|
||||
if (Schema::hasTable('api_keys')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('api_keys', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
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
|
||||
{
|
||||
if (Schema::hasTable('webhook_endpoints')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('webhook_endpoints', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
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
|
||||
{
|
||||
if (Schema::hasTable('webhook_deliveries')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('webhook_deliveries', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
46
php/Mod/Api/Boot.php
Normal file
46
php/Mod/Api/Boot.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api;
|
||||
|
||||
use Core\Events\ApiRoutesRegistering;
|
||||
use Core\Mod\Agentic\Mod\Api\Documentation\Middleware\ProtectDocumentation;
|
||||
use Core\Mod\Agentic\Mod\Api\RateLimit\RateLimitService;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class Boot extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Events this module listens to for lazy loading.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
26
php/Mod/Api/Controllers/DocsController.php
Normal file
26
php/Mod/Api/Controllers/DocsController.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DocsController
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->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);
|
||||
}
|
||||
}
|
||||
33
php/Mod/Api/Documentation/DocumentationController.php
Normal file
33
php/Mod/Api/Documentation/DocumentationController.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Documentation;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DocumentationController
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Documentation\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ProtectDocumentation
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('api-docs.enabled', true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$config = config('api-docs.access', []);
|
||||
$publicEnvironments = $config['public_environments'] ?? ['local', 'testing', 'staging'];
|
||||
|
||||
if (in_array(app()->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);
|
||||
}
|
||||
}
|
||||
16
php/Mod/Api/FOLLOWUP.md
Normal file
16
php/Mod/Api/FOLLOWUP.md
Normal file
|
|
@ -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.
|
||||
82
php/Mod/Api/Jobs/DeliverWebhookJob.php
Normal file
82
php/Mod/Api/Jobs/DeliverWebhookJob.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Jobs;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Api\Models\WebhookDelivery;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class DeliverWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public bool $deleteWhenMissingModels = true;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(
|
||||
public WebhookDelivery $delivery
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$delivery = $this->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);
|
||||
}
|
||||
}
|
||||
94
php/Mod/Api/Middleware/AuthenticateApiKey.php
Normal file
94
php/Mod/Api/Middleware/AuthenticateApiKey.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Agentic\Mod\Api\Models\ApiKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiKey
|
||||
{
|
||||
public function handle(Request $request, Closure $next, ?string $scope = null): Response
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if ($token === null || $token === '') {
|
||||
return $this->unauthorised('API key required. Use Authorization: Bearer <api_key>');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
192
php/Mod/Api/Models/ApiKey.php
Normal file
192
php/Mod/Api/Models/ApiKey.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Models;
|
||||
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApiKey extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public const HASH_BCRYPT = 'bcrypt';
|
||||
|
||||
public const SCOPE_READ = 'read';
|
||||
|
||||
public const SCOPE_WRITE = 'write';
|
||||
|
||||
public const SCOPE_DELETE = 'delete';
|
||||
|
||||
public const DEFAULT_SCOPES = [
|
||||
self::SCOPE_READ,
|
||||
self::SCOPE_WRITE,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'user_id',
|
||||
'name',
|
||||
'key',
|
||||
'hash_algorithm',
|
||||
'prefix',
|
||||
'scopes',
|
||||
'allowed_ips',
|
||||
'last_used_at',
|
||||
'expires_at',
|
||||
'grace_period_ends_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scopes' => '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<int, string>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
169
php/Mod/Api/Models/WebhookDelivery.php
Normal file
169
php/Mod/Api/Models/WebhookDelivery.php
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WebhookDelivery extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_RETRYING = 'retrying';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Retry delays keyed by the failed attempt number.
|
||||
*/
|
||||
public const RETRY_DELAYS = [
|
||||
1 => 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<string, string>, 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<int, int>
|
||||
*/
|
||||
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());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
183
php/Mod/Api/Models/WebhookEndpoint.php
Normal file
183
php/Mod/Api/Models/WebhookEndpoint.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Models;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Api\Services\WebhookSignature;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class WebhookEndpoint extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'url',
|
||||
'secret',
|
||||
'previous_secret',
|
||||
'previous_secret_expires_at',
|
||||
'events',
|
||||
'is_active',
|
||||
'description',
|
||||
'last_triggered_at',
|
||||
'failure_count',
|
||||
'disabled_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'events' => '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', '*');
|
||||
});
|
||||
}
|
||||
}
|
||||
39
php/Mod/Api/RateLimit/RateLimit.php
Normal file
39
php/Mod/Api/RateLimit/RateLimit.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\RateLimit;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
|
||||
readonly class RateLimit
|
||||
{
|
||||
public string $key;
|
||||
|
||||
public int $limit;
|
||||
|
||||
public int $window;
|
||||
|
||||
public float $burst;
|
||||
|
||||
public function __construct(
|
||||
string|int $key = '',
|
||||
?int $limit = null,
|
||||
int $window = 60,
|
||||
float $burst = 1.0,
|
||||
) {
|
||||
if (is_int($key) && $limit === null) {
|
||||
$this->key = '';
|
||||
$this->limit = $key;
|
||||
} else {
|
||||
$this->key = (string) $key;
|
||||
$this->limit = $limit ?? 60;
|
||||
}
|
||||
|
||||
$this->window = $window;
|
||||
$this->burst = $burst;
|
||||
}
|
||||
}
|
||||
52
php/Mod/Api/RateLimit/RateLimitResult.php
Normal file
52
php/Mod/Api/RateLimit/RateLimitResult.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\RateLimit;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
readonly class RateLimitResult
|
||||
{
|
||||
public Carbon $resetsAt;
|
||||
|
||||
public function __construct(
|
||||
public bool $allowed,
|
||||
public int $limit,
|
||||
public int $remaining,
|
||||
public ?int $retryAfter,
|
||||
public Carbon $resetAt,
|
||||
) {
|
||||
$this->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<string, int>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
296
php/Mod/Api/RateLimit/RateLimitService.php
Normal file
296
php/Mod/Api/RateLimit/RateLimitService.php
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\RateLimit;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class RateLimitService
|
||||
{
|
||||
protected const CACHE_PREFIX = 'rate_limit:';
|
||||
|
||||
public function __construct(
|
||||
protected CacheRepository $cache,
|
||||
) {}
|
||||
|
||||
public function checkLimit(mixed $apiKey = null, string $endpoint = 'global', ?RateLimit $rateLimit = null): RateLimitResult
|
||||
{
|
||||
$rateLimit ??= $this->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<int, int>
|
||||
*/
|
||||
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<int, int> $hits
|
||||
*/
|
||||
protected function storeWindowHits(string $cacheKey, array $hits, int $window): void
|
||||
{
|
||||
$this->cache->put($cacheKey, $hits, $window + 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $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;
|
||||
}
|
||||
}
|
||||
16
php/Mod/Api/Routes/api.php
Normal file
16
php/Mod/Api/Routes/api.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Module Foundation Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Ticket #844 stops after the shared gateway foundation. Route expansion for
|
||||
| webhook administration, delivery inspection, and documentation UIs follows
|
||||
| in the next slice.
|
||||
|
|
||||
*/
|
||||
49
php/Mod/Api/Services/WebhookService.php
Normal file
49
php/Mod/Api/Services/WebhookService.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Services;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Api\Jobs\DeliverWebhookJob;
|
||||
use Core\Mod\Agentic\Mod\Api\Models\WebhookDelivery;
|
||||
use Core\Mod\Agentic\Mod\Api\Models\WebhookEndpoint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class WebhookService
|
||||
{
|
||||
/**
|
||||
* Dispatch an event to every active endpoint subscribed to the event type.
|
||||
*
|
||||
* Example:
|
||||
* `dispatch(12, 'mcp.tool.executed', ['tool' => 'brain_recall'])`
|
||||
*
|
||||
* @return array<int, WebhookDelivery>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
52
php/Mod/Api/Services/WebhookSignature.php
Normal file
52
php/Mod/Api/Services/WebhookSignature.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Api\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WebhookSignature
|
||||
{
|
||||
public const DEFAULT_TOLERANCE = 300;
|
||||
|
||||
public function generateSecret(): string
|
||||
{
|
||||
return Str::random(64);
|
||||
}
|
||||
|
||||
public function sign(string $payload, string $secret, int $timestamp): string
|
||||
{
|
||||
return hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
|
||||
}
|
||||
|
||||
public function verify(
|
||||
string $payload,
|
||||
string $signature,
|
||||
string $secret,
|
||||
int $timestamp,
|
||||
int $tolerance = self::DEFAULT_TOLERANCE
|
||||
): bool {
|
||||
if (! $this->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;
|
||||
}
|
||||
}
|
||||
47
php/tests/Feature/Mod/Api/ApiKeyFoundationTest.php
Normal file
47
php/tests/Feature/Mod/Api/ApiKeyFoundationTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Mod\Api\Boot as ApiBoot;
|
||||
use Core\Mod\Agentic\Mod\Api\Models\ApiKey;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->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();
|
||||
});
|
||||
});
|
||||
59
php/tests/Feature/Mod/Api/RateLimitFoundationTest.php
Normal file
59
php/tests/Feature/Mod/Api/RateLimitFoundationTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Agentic\Mod\Api\RateLimit\RateLimit;
|
||||
use Core\Mod\Agentic\Mod\Api\RateLimit\RateLimitService;
|
||||
use Illuminate\Cache\ArrayStore;
|
||||
use Illuminate\Cache\Repository;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-04-25 12:00:00'));
|
||||
|
||||
$this->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);
|
||||
});
|
||||
});
|
||||
109
php/tests/Feature/Mod/Api/WebhookFoundationTest.php
Normal file
109
php/tests/Feature/Mod/Api/WebhookFoundationTest.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Agentic\Mod\Api\Boot as ApiBoot;
|
||||
use Core\Mod\Agentic\Mod\Api\Jobs\DeliverWebhookJob;
|
||||
use Core\Mod\Agentic\Mod\Api\Models\WebhookDelivery;
|
||||
use Core\Mod\Agentic\Mod\Api\Models\WebhookEndpoint;
|
||||
use Core\Mod\Agentic\Mod\Api\Services\WebhookService;
|
||||
use Core\Mod\Agentic\Mod\Api\Services\WebhookSignature;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue