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:
Snider 2026-04-25 21:01:54 +01:00
parent 429d1c0897
commit 5385385314
22 changed files with 1734 additions and 0 deletions

View 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');
}
};

View file

@ -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');
}
};

View file

@ -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
View 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');
}
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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
View 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.

View 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);
}
}

View 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);
}
}

View 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');
}
}

View 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());
});
});
}
}

View 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', '*');
});
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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.
|
*/

View 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;
}
}

View 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;
}
}

View 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();
});
});

View 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);
});
});

View 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();
});
});