refactor: update namespaces and remove deprecated biolinks route; enhance API documentation attributes

This commit is contained in:
Snider 2026-01-26 14:24:42 +00:00
parent 02125e8234
commit e498a1701e
57 changed files with 7226 additions and 484 deletions

View file

@ -1,89 +1,7 @@
# Core-API TODO
## Webhook Signing (Outbound)
**Priority:** Medium
**Context:** No request signing for outbound webhooks. Recipients cannot verify requests came from our platform.
### Implementation
```php
// When sending webhooks
$payload = json_encode($data);
$signature = hash_hmac('sha256', $payload, $webhookSecret);
$response = Http::withHeaders([
'X-Signature' => $signature,
'X-Timestamp' => now()->timestamp,
])->post($url, $data);
```
### Requirements
- Generate per-endpoint webhook secrets
- Sign all outbound webhook requests
- Include timestamp to prevent replay attacks
- Document verification for recipients
*No outstanding items.*
---
## OpenAPI/Swagger Documentation
**Priority:** Low
**Context:** No auto-generated API documentation.
### Options
1. **dedoc/scramble** - Auto-generates from routes/controllers
2. **darkaonline/l5-swagger** - Annotation-based
3. **Custom** - Generate from route definitions
### Requirements
- Auto-discover API routes from modules
- Support module-specific doc sections
- Serve at `/api/docs` endpoint
- Include authentication examples
---
## API Key Security
**Priority:** Medium (Security)
**Context:** API keys use SHA-256 without salt.
### Current
```php
$hashedKey = hash('sha256', $rawKey);
```
### Recommended
```php
// Use Argon2 or bcrypt
$hashedKey = Hash::make($rawKey);
// Verify
Hash::check($providedKey, $storedHash);
```
### Notes
- Migration needed for existing keys
- Consider key rotation mechanism
- Add key scopes/permissions
---
## Rate Limiting Improvements
**Priority:** Medium
**Context:** Basic rate limiting exists but needs granularity.
### Requirements
- Per-endpoint rate limits
- Per-workspace rate limits
- Burst allowance configuration
- Rate limit headers in responses
*See `changelog/2026/jan/` for completed features.*

View file

@ -0,0 +1,122 @@
# Core-API - January 2026
## Features Implemented
### Webhook Signing (Outbound)
HMAC-SHA256 signatures with timestamp for replay attack protection.
**Files:**
- `Services/WebhookSignature.php` - Sign/verify service
- `Models/WebhookEndpoint.php` - Signature methods
- `Models/WebhookDelivery.php` - Headers in payload
**Headers:**
| Header | Description |
|--------|-------------|
| `X-Webhook-Signature` | HMAC-SHA256 (64 hex chars) |
| `X-Webhook-Timestamp` | Unix timestamp |
| `X-Webhook-Event` | Event type |
| `X-Webhook-Id` | Unique delivery ID |
**Verification:**
```php
$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
hash_equals($signature, $headerSignature);
```
---
### API Key Security
Secure bcrypt hashing with backward compatibility for legacy SHA-256 keys.
**Files:**
- `Models/ApiKey.php` - Secure hashing, rotation, grace periods
- `Migrations/2026_01_27_*` - Added hash_algorithm column
**Features:**
- New keys use `Hash::make()` (bcrypt)
- Legacy keys continue working
- Key rotation with grace periods
- Scopes: `legacyHash()`, `secureHash()`, `inGracePeriod()`
---
### Rate Limiting
Granular rate limiting with sliding window algorithm.
**Files:**
- `RateLimit/RateLimitService.php` - Sliding window service
- `RateLimit/RateLimitResult.php` - Result DTO
- `RateLimit/RateLimit.php` - PHP 8 attribute
- `Middleware/RateLimitApi.php` - Enhanced middleware
- `Exceptions/RateLimitExceededException.php`
**Features:**
- Per-endpoint limits via `#[RateLimit]` attribute or config
- Per-workspace isolation
- Tier-based limits (free/starter/pro/agency/enterprise)
- Burst allowance (e.g., 20% over limit)
- Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
**Usage:**
```php
#[RateLimit(limit: 100, window: 60, burst: 1.2)]
public function index() { ... }
```
---
### OpenAPI/Swagger Documentation
Auto-generated API documentation with multiple UI options.
**Files:**
- `Documentation/OpenApiBuilder.php` - Spec generator
- `Documentation/DocumentationController.php` - Routes
- `Documentation/Attributes/` - ApiTag, ApiResponse, ApiSecurity, ApiParameter, ApiHidden
- `Documentation/Extensions/` - WorkspaceHeader, RateLimit, ApiKeyAuth
- `Documentation/Views/` - Swagger, Scalar, ReDoc
**Routes:**
| Route | Description |
|-------|-------------|
| `GET /api/docs` | Default UI (Scalar) |
| `GET /api/docs/swagger` | Swagger UI |
| `GET /api/docs/scalar` | Scalar API Reference |
| `GET /api/docs/redoc` | ReDoc |
| `GET /api/docs/openapi.json` | OpenAPI spec (JSON) |
| `GET /api/docs/openapi.yaml` | OpenAPI spec (YAML) |
**Usage:**
```php
#[ApiTag('Users')]
#[ApiResponse(200, UserResource::class)]
#[ApiParameter('page', 'query', 'integer')]
public function index() { ... }
```
**Config:** `API_DOCS_ENABLED`, `API_DOCS_TITLE`, `API_DOCS_REQUIRE_AUTH`
---
### Documentation Genericization
Removed vendor-specific branding from API documentation.
**Files:**
- `Website/Api/View/Blade/guides/authentication.blade.php`
- `Website/Api/View/Blade/guides/errors.blade.php`
- `Website/Api/View/Blade/guides/index.blade.php`
- `Website/Api/View/Blade/guides/qrcodes.blade.php`
- `Website/Api/View/Blade/guides/quickstart.blade.php`
**Changes:**
- Replaced "Host UK API" with generic "API"
- Removed specific domain references (lt.hn)
- Replaced sign-up URLs with generic account requirements
- Made example URLs vendor-neutral
**Impact:** Framework documentation is now vendor-agnostic and suitable for open-source distribution.

View file

@ -5,7 +5,8 @@
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"host-uk/core": "@dev"
"host-uk/core": "@dev",
"symfony/yaml": "^7.0"
},
"autoload": {
"psr-4": {

View file

@ -6,6 +6,9 @@ namespace Core\Mod\Api;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Mod\Api\Documentation\DocumentationServiceProvider;
use Core\Mod\Api\RateLimit\RateLimitService;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
@ -42,6 +45,14 @@ class Boot extends ServiceProvider
__DIR__.'/config.php',
$this->moduleName
);
// Register RateLimitService as a singleton
$this->app->singleton(RateLimitService::class, function ($app) {
return new RateLimitService($app->make(CacheRepository::class));
});
// Register API Documentation provider
$this->app->register(DocumentationServiceProvider::class);
}
/**
@ -61,6 +72,7 @@ class Boot extends ServiceProvider
// Middleware aliases registered via event
$event->middleware('api.auth', Middleware\AuthenticateApiKey::class);
$event->middleware('api.scope', Middleware\CheckApiScope::class);
$event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class);
$event->middleware('api.rate', Middleware\RateLimitApi::class);
$event->middleware('auth.api', Middleware\AuthenticateApiKey::class);
@ -75,7 +87,12 @@ class Boot extends ServiceProvider
// Register middleware aliases for CLI context (artisan route:list etc)
$event->middleware('api.auth', Middleware\AuthenticateApiKey::class);
$event->middleware('api.scope', Middleware\CheckApiScope::class);
$event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class);
$event->middleware('api.rate', Middleware\RateLimitApi::class);
$event->middleware('auth.api', Middleware\AuthenticateApiKey::class);
// Register console commands
$event->command(Console\Commands\CleanupExpiredGracePeriods::class);
$event->command(Console\Commands\CheckApiUsageAlerts::class);
}
}

View file

@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Console\Commands;
use Core\Mod\Api\Models\ApiKey;
use Core\Mod\Api\Notifications\HighApiUsageNotification;
use Core\Mod\Api\RateLimit\RateLimitService;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Check API usage levels and send alerts when approaching limits.
*
* Notifies workspace owners when:
* - 80% of rate limit is used (warning)
* - 95% of rate limit is used (critical)
*
* Uses cache to prevent duplicate notifications within a cooldown period.
*/
class CheckApiUsageAlerts extends Command
{
/**
* Cache key prefix for notification cooldowns.
*/
protected const CACHE_PREFIX = 'api_usage_alert:';
/**
* Default hours between notifications of the same level.
*/
protected const DEFAULT_COOLDOWN_HOURS = 6;
/**
* The name and signature of the console command.
*/
protected $signature = 'api:check-usage-alerts
{--dry-run : Show what alerts would be sent without sending}
{--workspace= : Check a specific workspace by ID}';
/**
* The console command description.
*/
protected $description = 'Check API usage levels and send alerts when approaching rate limits';
/**
* Alert thresholds (percentage of limit).
* Loaded from config in constructor.
*/
protected array $thresholds = [];
/**
* Cooldown hours between notifications.
*/
protected int $cooldownHours;
/**
* Execute the console command.
*/
public function handle(RateLimitService $rateLimitService): int
{
// Check if alerts are enabled
if (! config('api.alerts.enabled', true)) {
$this->info('API usage alerts are disabled.');
return Command::SUCCESS;
}
// Load thresholds from config (sorted by severity, critical first)
$this->thresholds = config('api.alerts.thresholds', [
'critical' => 95,
'warning' => 80,
]);
arsort($this->thresholds);
$this->cooldownHours = config('api.alerts.cooldown_hours', self::DEFAULT_COOLDOWN_HOURS);
$dryRun = $this->option('dry-run');
$specificWorkspace = $this->option('workspace');
if ($dryRun) {
$this->warn('DRY RUN MODE - No notifications will be sent');
$this->newLine();
}
// Get workspaces with active API keys
$query = Workspace::whereHas('apiKeys', function ($q) {
$q->active();
});
if ($specificWorkspace) {
$query->where('id', $specificWorkspace);
}
$workspaces = $query->get();
if ($workspaces->isEmpty()) {
$this->info('No workspaces with active API keys found.');
return Command::SUCCESS;
}
$alertsSent = 0;
$alertsSkipped = 0;
foreach ($workspaces as $workspace) {
$result = $this->checkWorkspaceUsage($workspace, $rateLimitService, $dryRun);
$alertsSent += $result['sent'];
$alertsSkipped += $result['skipped'];
}
$this->newLine();
$this->info("Alerts sent: {$alertsSent}");
$this->info("Alerts skipped (cooldown): {$alertsSkipped}");
return Command::SUCCESS;
}
/**
* Check usage for a workspace and send alerts if needed.
*
* @return array{sent: int, skipped: int}
*/
protected function checkWorkspaceUsage(
Workspace $workspace,
RateLimitService $rateLimitService,
bool $dryRun
): array {
$sent = 0;
$skipped = 0;
// Get rate limit config for this workspace's tier
$tier = $this->getWorkspaceTier($workspace);
$limitConfig = $this->getTierLimitConfig($tier);
if (! $limitConfig) {
return ['sent' => 0, 'skipped' => 0];
}
// Check usage for each active API key
$apiKeys = $workspace->apiKeys()->active()->get();
foreach ($apiKeys as $apiKey) {
$key = $rateLimitService->buildApiKeyKey($apiKey->id);
$attempts = $rateLimitService->attempts($key, $limitConfig['window']);
$limit = (int) floor($limitConfig['limit'] * ($limitConfig['burst'] ?? 1.0));
if ($limit === 0) {
continue;
}
$percentage = ($attempts / $limit) * 100;
// Check thresholds (critical first, then warning)
foreach ($this->thresholds as $level => $threshold) {
if ($percentage >= $threshold) {
$cacheKey = $this->getCacheKey($workspace->id, $apiKey->id, $level);
if (Cache::has($cacheKey)) {
$this->line(" [SKIP] {$workspace->name} - Key {$apiKey->prefix}: {$level} (cooldown)");
$skipped++;
break; // Don't check lower thresholds
}
$this->line(" [ALERT] {$workspace->name} - Key {$apiKey->prefix}: {$level} ({$percentage}%)");
if (! $dryRun) {
$this->sendAlert($workspace, $apiKey, $level, $attempts, $limit, $limitConfig);
Cache::put($cacheKey, true, now()->addHours($this->cooldownHours));
}
$sent++;
break; // Only send one alert per key (highest severity)
}
}
}
return ['sent' => $sent, 'skipped' => $skipped];
}
/**
* Send alert notification to workspace owner.
*/
protected function sendAlert(
Workspace $workspace,
ApiKey $apiKey,
string $level,
int $currentUsage,
int $limit,
array $limitConfig
): void {
$owner = $workspace->owner();
if (! $owner) {
$this->warn(" No owner found for workspace {$workspace->name}");
return;
}
$period = $this->formatPeriod($limitConfig['window']);
$owner->notify(new HighApiUsageNotification(
workspace: $workspace,
level: $level,
currentUsage: $currentUsage,
limit: $limit,
period: $period,
));
}
/**
* Get workspace tier for rate limiting.
*/
protected function getWorkspaceTier(Workspace $workspace): string
{
// Check for active package
$package = $workspace->workspacePackages()
->active()
->with('package')
->first();
return $package?->package?->slug ?? 'free';
}
/**
* Get rate limit config for a tier.
*
* @return array{limit: int, window: int, burst: float}|null
*/
protected function getTierLimitConfig(string $tier): ?array
{
$config = config("api.rate_limits.tiers.{$tier}");
if (! $config) {
$config = config('api.rate_limits.tiers.free');
}
if (! $config) {
$config = config('api.rate_limits.authenticated');
}
if (! $config) {
return null;
}
return [
'limit' => $config['limit'] ?? $config['requests'] ?? 60,
'window' => $config['window'] ?? (($config['per_minutes'] ?? 1) * 60),
'burst' => $config['burst'] ?? 1.0,
];
}
/**
* Format window period for display.
*/
protected function formatPeriod(int $seconds): string
{
if ($seconds < 60) {
return "{$seconds} seconds";
}
$minutes = $seconds / 60;
if ($minutes === 1.0) {
return 'minute';
}
if ($minutes < 60) {
return "{$minutes} minutes";
}
$hours = $minutes / 60;
if ($hours === 1.0) {
return 'hour';
}
return "{$hours} hours";
}
/**
* Get cache key for notification cooldown.
*/
protected function getCacheKey(int $workspaceId, int $apiKeyId, string $level): string
{
return self::CACHE_PREFIX."{$workspaceId}:{$apiKeyId}:{$level}";
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Mod\Api\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Mod\Api\Models\ApiKey;
use Mod\Tenant\Models\User;
@ -13,6 +14,9 @@ use Mod\Tenant\Models\Workspace;
/**
* Factory for generating ApiKey test instances.
*
* By default, creates keys with secure bcrypt hashing.
* Use legacyHash() to create keys with SHA-256 for migration testing.
*
* @extends Factory<ApiKey>
*/
class ApiKeyFactory extends Factory
@ -32,6 +36,8 @@ class ApiKeyFactory extends Factory
/**
* Define the model's default state.
*
* Creates keys with secure bcrypt hashing by default.
*
* @return array<string, mixed>
*/
public function definition(): array
@ -44,12 +50,15 @@ class ApiKeyFactory extends Factory
'workspace_id' => Workspace::factory(),
'user_id' => User::factory(),
'name' => fake()->words(2, true).' API Key',
'key' => hash('sha256', $plainKey),
'key' => Hash::make($plainKey),
'hash_algorithm' => ApiKey::HASH_BCRYPT,
'prefix' => $prefix,
'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
'server_scopes' => null,
'last_used_at' => null,
'expires_at' => null,
'grace_period_ends_at' => null,
'rotated_from_id' => null,
];
}
@ -65,6 +74,8 @@ class ApiKeyFactory extends Factory
/**
* Create a key with specific known credentials for testing.
*
* This method uses ApiKey::generate() which creates secure bcrypt keys.
*
* @return array{api_key: ApiKey, plain_key: string}
*/
public static function createWithPlainKey(
@ -85,6 +96,57 @@ class ApiKeyFactory extends Factory
);
}
/**
* Create a key with legacy SHA-256 hashing for migration testing.
*
* @return array{api_key: ApiKey, plain_key: string}
*/
public static function createLegacyKey(
?Workspace $workspace = null,
?User $user = null,
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
?\DateTimeInterface $expiresAt = null
): array {
$workspace ??= Workspace::factory()->create();
$user ??= User::factory()->create();
$plainKey = Str::random(48);
$prefix = 'hk_'.Str::random(8);
$apiKey = ApiKey::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'name' => fake()->words(2, true).' API Key',
'key' => hash('sha256', $plainKey),
'hash_algorithm' => ApiKey::HASH_SHA256,
'prefix' => $prefix,
'scopes' => $scopes,
'expires_at' => $expiresAt,
]);
return [
'api_key' => $apiKey,
'plain_key' => "{$prefix}_{$plainKey}",
];
}
/**
* Create key with legacy SHA-256 hashing (for migration testing).
*/
public function legacyHash(): static
{
return $this->state(function (array $attributes) {
// Extract the plain key from the stored state
$parts = explode('_', $this->plainKey ?? '', 3);
$plainKey = $parts[2] ?? Str::random(48);
return [
'key' => hash('sha256', $plainKey),
'hash_algorithm' => ApiKey::HASH_SHA256,
];
});
}
/**
* Indicate that the key has been used recently.
*/
@ -166,4 +228,26 @@ class ApiKeyFactory extends Factory
'deleted_at' => now()->subDay(),
]);
}
/**
* Create a key in a rotation grace period.
*
* @param int $hoursRemaining Hours until grace period ends
*/
public function inGracePeriod(int $hoursRemaining = 12): static
{
return $this->state(fn (array $attributes) => [
'grace_period_ends_at' => now()->addHours($hoursRemaining),
]);
}
/**
* Create a key with an expired grace period.
*/
public function gracePeriodExpired(): static
{
return $this->state(fn (array $attributes) => [
'grace_period_ends_at' => now()->subHours(1),
]);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Attributes;
use Attribute;
/**
* API Hidden attribute for excluding endpoints from documentation.
*
* Apply to controller classes or methods to hide them from the generated
* OpenAPI documentation.
*
* Example usage:
*
* // Hide entire controller
* #[ApiHidden]
* class InternalController extends Controller {}
*
* // Hide specific method
* class UserController extends Controller
* {
* #[ApiHidden]
* public function internalMethod() {}
* }
*
* // Hide with reason (for code documentation)
* #[ApiHidden('Internal use only')]
* public function debug() {}
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class ApiHidden
{
/**
* @param string|null $reason Optional reason for hiding (documentation only)
*/
public function __construct(
public ?string $reason = null,
) {}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Attributes;
use Attribute;
/**
* API Parameter attribute for documenting endpoint parameters.
*
* Apply to controller methods to document query parameters, path parameters,
* or header parameters in OpenAPI documentation.
*
* Example usage:
*
* #[ApiParameter('page', 'query', 'integer', 'Page number', required: false, example: 1)]
* #[ApiParameter('per_page', 'query', 'integer', 'Items per page', required: false, example: 25)]
* #[ApiParameter('filter[status]', 'query', 'string', 'Filter by status', enum: ['active', 'inactive'])]
* public function index(Request $request)
* {
* // ...
* }
*
* // Document header parameters
* #[ApiParameter('X-Custom-Header', 'header', 'string', 'Custom header value')]
* public function withHeader() {}
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class ApiParameter
{
/**
* @param string $name Parameter name
* @param string $in Parameter location: 'query', 'path', 'header', 'cookie'
* @param string $type Data type: 'string', 'integer', 'boolean', 'number', 'array'
* @param string|null $description Parameter description
* @param bool $required Whether parameter is required
* @param mixed $example Example value
* @param mixed $default Default value
* @param array|null $enum Allowed values (for enumerated parameters)
* @param string|null $format Format hint (e.g., 'date', 'email', 'uuid')
*/
public function __construct(
public string $name,
public string $in = 'query',
public string $type = 'string',
public ?string $description = null,
public bool $required = false,
public mixed $example = null,
public mixed $default = null,
public ?array $enum = null,
public ?string $format = null,
) {}
/**
* Convert to OpenAPI parameter schema.
*/
public function toSchema(): array
{
$schema = [
'type' => $this->type,
];
if ($this->format !== null) {
$schema['format'] = $this->format;
}
if ($this->enum !== null) {
$schema['enum'] = $this->enum;
}
if ($this->default !== null) {
$schema['default'] = $this->default;
}
if ($this->example !== null) {
$schema['example'] = $this->example;
}
return $schema;
}
/**
* Convert to full OpenAPI parameter object.
*/
public function toOpenApi(): array
{
$param = [
'name' => $this->name,
'in' => $this->in,
'required' => $this->required || $this->in === 'path',
'schema' => $this->toSchema(),
];
if ($this->description !== null) {
$param['description'] = $this->description;
}
return $param;
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Attributes;
use Attribute;
/**
* API Response attribute for documenting endpoint responses.
*
* Apply to controller methods to document possible responses in OpenAPI.
*
* Example usage:
*
* #[ApiResponse(200, UserResource::class, 'User retrieved successfully')]
* #[ApiResponse(404, null, 'User not found')]
* #[ApiResponse(422, null, 'Validation failed')]
* public function show(User $user)
* {
* return new UserResource($user);
* }
*
* // For paginated responses
* #[ApiResponse(200, UserResource::class, 'Users list', paginated: true)]
* public function index()
* {
* return UserResource::collection(User::paginate());
* }
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class ApiResponse
{
/**
* @param int $status HTTP status code
* @param string|null $resource Resource class for response body (null for no body)
* @param string|null $description Description of the response
* @param bool $paginated Whether this is a paginated collection response
* @param array<string> $headers Additional response headers to document
*/
public function __construct(
public int $status,
public ?string $resource = null,
public ?string $description = null,
public bool $paginated = false,
public array $headers = [],
) {}
/**
* Get the description or generate from status code.
*/
public function getDescription(): string
{
if ($this->description !== null) {
return $this->description;
}
return match ($this->status) {
200 => 'Successful response',
201 => 'Resource created',
202 => 'Request accepted',
204 => 'No content',
301 => 'Moved permanently',
302 => 'Found (redirect)',
304 => 'Not modified',
400 => 'Bad request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not found',
405 => 'Method not allowed',
409 => 'Conflict',
422 => 'Validation error',
429 => 'Too many requests',
500 => 'Internal server error',
502 => 'Bad gateway',
503 => 'Service unavailable',
default => 'Response',
};
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Attributes;
use Attribute;
/**
* API Security attribute for documenting authentication requirements.
*
* Apply to controller classes or methods to specify authentication requirements.
*
* Example usage:
*
* // Require API key authentication
* #[ApiSecurity('apiKey')]
* class ProtectedController extends Controller {}
*
* // Require bearer token
* #[ApiSecurity('bearer')]
* public function profile() {}
*
* // Require specific scopes
* #[ApiSecurity('apiKey', scopes: ['read', 'write'])]
* public function update() {}
*
* // Mark endpoint as public (no auth required)
* #[ApiSecurity(null)]
* public function publicEndpoint() {}
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class ApiSecurity
{
/**
* @param string|null $scheme Security scheme name (null for no auth)
* @param array<string> $scopes Required OAuth2 scopes (if applicable)
*/
public function __construct(
public ?string $scheme,
public array $scopes = [],
) {}
/**
* Check if this marks the endpoint as public.
*/
public function isPublic(): bool
{
return $this->scheme === null;
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Attributes;
use Attribute;
/**
* API Tag attribute for grouping endpoints in documentation.
*
* Apply to controller classes to group their endpoints under a specific tag
* in the OpenAPI documentation.
*
* Example usage:
*
* #[ApiTag('Users', 'User management endpoints')]
* class UserController extends Controller
* {
* // All methods will be tagged with 'Users'
* }
*
* // Or use on specific methods to override class-level tag
* #[ApiTag('Admin')]
* public function adminOnly() {}
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class ApiTag
{
/**
* @param string $name The tag name displayed in documentation
* @param string|null $description Optional description of the tag
*/
public function __construct(
public string $name,
public ?string $description = null,
) {}
}

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\Yaml\Yaml;
/**
* API Documentation Controller.
*
* Serves OpenAPI documentation in multiple formats and provides
* interactive documentation UIs (Swagger, Scalar, ReDoc).
*/
class DocumentationController
{
public function __construct(
protected OpenApiBuilder $builder,
) {}
/**
* Show the main documentation page.
*
* Redirects to the configured default UI.
*/
public function index(Request $request): View
{
$defaultUi = config('api-docs.ui.default', 'scalar');
return match ($defaultUi) {
'swagger' => $this->swagger($request),
'redoc' => $this->redoc($request),
default => $this->scalar($request),
};
}
/**
* Show Swagger UI.
*/
public function swagger(Request $request): View
{
$config = config('api-docs.ui.swagger', []);
return view('api-docs::swagger', [
'specUrl' => route('api.docs.openapi.json'),
'config' => $config,
]);
}
/**
* Show Scalar API Reference.
*/
public function scalar(Request $request): View
{
$config = config('api-docs.ui.scalar', []);
return view('api-docs::scalar', [
'specUrl' => route('api.docs.openapi.json'),
'config' => $config,
]);
}
/**
* Show ReDoc documentation.
*/
public function redoc(Request $request): View
{
return view('api-docs::redoc', [
'specUrl' => route('api.docs.openapi.json'),
]);
}
/**
* Get OpenAPI specification as JSON.
*/
public function openApiJson(Request $request): JsonResponse
{
$spec = $this->builder->build();
return response()->json($spec)
->header('Cache-Control', $this->getCacheControl());
}
/**
* Get OpenAPI specification as YAML.
*/
public function openApiYaml(Request $request): Response
{
$spec = $this->builder->build();
// Convert to YAML
$yaml = Yaml::dump($spec, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
return response($yaml)
->header('Content-Type', 'application/x-yaml')
->header('Cache-Control', $this->getCacheControl());
}
/**
* Clear the documentation cache.
*/
public function clearCache(Request $request): JsonResponse
{
$this->builder->clearCache();
return response()->json([
'message' => 'Documentation cache cleared successfully.',
]);
}
/**
* Get cache control header value.
*/
protected function getCacheControl(): string
{
if (app()->environment('local', 'testing')) {
return 'no-cache, no-store, must-revalidate';
}
$ttl = config('api-docs.cache.ttl', 3600);
return "public, max-age={$ttl}";
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation;
use Core\Mod\Api\Documentation\Middleware\ProtectDocumentation;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
/**
* API Documentation Service Provider.
*
* Registers documentation routes, views, configuration, and services.
*/
class DocumentationServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// Merge configuration
$this->mergeConfigFrom(
__DIR__.'/config.php',
'api-docs'
);
// Register OpenApiBuilder as singleton
$this->app->singleton(OpenApiBuilder::class, function ($app) {
return new OpenApiBuilder;
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Skip route registration during console commands (except route:list)
if ($this->shouldRegisterRoutes()) {
$this->registerRoutes();
}
// Register views
$this->loadViewsFrom(__DIR__.'/Views', 'api-docs');
// Publish configuration
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/config.php' => config_path('api-docs.php'),
], 'api-docs-config');
$this->publishes([
__DIR__.'/Views' => resource_path('views/vendor/api-docs'),
], 'api-docs-views');
}
}
/**
* Check if routes should be registered.
*/
protected function shouldRegisterRoutes(): bool
{
// Always register if not in console
if (! $this->app->runningInConsole()) {
return true;
}
// Register for artisan route:list command
$command = $_SERVER['argv'][1] ?? null;
return $command === 'route:list' || $command === 'route:cache';
}
/**
* Register documentation routes.
*/
protected function registerRoutes(): void
{
$path = config('api-docs.path', '/api/docs');
Route::middleware(['web', ProtectDocumentation::class])
->prefix($path)
->group(__DIR__.'/Routes/docs.php');
}
}

View file

@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Examples;
/**
* Common API Examples.
*
* Provides example requests and responses for documentation.
*/
class CommonExamples
{
/**
* Get example for pagination parameters.
*/
public static function paginationParams(): array
{
return [
'page' => [
'name' => 'page',
'in' => 'query',
'description' => 'Page number for pagination',
'required' => false,
'schema' => [
'type' => 'integer',
'minimum' => 1,
'default' => 1,
'example' => 1,
],
],
'per_page' => [
'name' => 'per_page',
'in' => 'query',
'description' => 'Number of items per page',
'required' => false,
'schema' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
'default' => 25,
'example' => 25,
],
],
];
}
/**
* Get example for sorting parameters.
*/
public static function sortingParams(): array
{
return [
'sort' => [
'name' => 'sort',
'in' => 'query',
'description' => 'Field to sort by (prefix with - for descending)',
'required' => false,
'schema' => [
'type' => 'string',
'example' => '-created_at',
],
],
];
}
/**
* Get example for filtering parameters.
*/
public static function filteringParams(): array
{
return [
'filter' => [
'name' => 'filter',
'in' => 'query',
'description' => 'Filter parameters in the format filter[field]=value',
'required' => false,
'style' => 'deepObject',
'explode' => true,
'schema' => [
'type' => 'object',
'additionalProperties' => [
'type' => 'string',
],
],
'example' => [
'status' => 'active',
'created_at[gte]' => '2024-01-01',
],
],
];
}
/**
* Get example paginated response.
*/
public static function paginatedResponse(string $dataExample = '[]'): array
{
return [
'data' => json_decode($dataExample, true) ?? [],
'links' => [
'first' => 'https://api.example.com/resource?page=1',
'last' => 'https://api.example.com/resource?page=10',
'prev' => null,
'next' => 'https://api.example.com/resource?page=2',
],
'meta' => [
'current_page' => 1,
'from' => 1,
'last_page' => 10,
'per_page' => 25,
'to' => 25,
'total' => 250,
],
];
}
/**
* Get example error response.
*/
public static function errorResponse(int $status, string $message, ?array $errors = null): array
{
$response = ['message' => $message];
if ($errors !== null) {
$response['errors'] = $errors;
}
return $response;
}
/**
* Get example validation error response.
*/
public static function validationErrorResponse(): array
{
return [
'message' => 'The given data was invalid.',
'errors' => [
'email' => [
'The email field is required.',
],
'name' => [
'The name field must be at least 2 characters.',
],
],
];
}
/**
* Get example rate limit headers.
*/
public static function rateLimitHeaders(int $limit = 1000, int $remaining = 999): array
{
return [
'X-RateLimit-Limit' => (string) $limit,
'X-RateLimit-Remaining' => (string) $remaining,
'X-RateLimit-Reset' => (string) (time() + 60),
];
}
/**
* Get example authentication headers.
*/
public static function authHeaders(string $type = 'api_key'): array
{
return match ($type) {
'api_key' => [
'X-API-Key' => 'hk_1234567890abcdefghijklmnop',
],
'bearer' => [
'Authorization' => 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
],
default => [],
};
}
/**
* Get example workspace header.
*/
public static function workspaceHeader(): array
{
return [
'X-Workspace-ID' => '550e8400-e29b-41d4-a716-446655440000',
];
}
/**
* Get example CURL request.
*/
public static function curlExample(
string $method,
string $endpoint,
?array $body = null,
array $headers = []
): string {
$curl = "curl -X {$method} \\\n";
$curl .= " 'https://api.example.com{$endpoint}' \\\n";
foreach ($headers as $name => $value) {
$curl .= " -H '{$name}: {$value}' \\\n";
}
if ($body !== null) {
$curl .= " -H 'Content-Type: application/json' \\\n";
$curl .= " -d '".json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."'";
}
return rtrim($curl, " \\\n");
}
/**
* Get example JavaScript fetch request.
*/
public static function fetchExample(
string $method,
string $endpoint,
?array $body = null,
array $headers = []
): string {
$allHeaders = array_merge([
'Content-Type' => 'application/json',
], $headers);
$options = [
'method' => strtoupper($method),
'headers' => $allHeaders,
];
if ($body !== null) {
$options['body'] = 'JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT).')';
}
$code = "const response = await fetch('https://api.example.com{$endpoint}', {\n";
$code .= " method: '{$options['method']}',\n";
$code .= ' headers: '.json_encode($allHeaders, JSON_PRETTY_PRINT).",\n";
if ($body !== null) {
$code .= ' body: JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT)."),\n";
}
$code .= "});\n\n";
$code .= 'const data = await response.json();';
return $code;
}
/**
* Get example PHP request.
*/
public static function phpExample(
string $method,
string $endpoint,
?array $body = null,
array $headers = []
): string {
$code = "<?php\n\n";
$code .= "\$client = new \\GuzzleHttp\\Client();\n\n";
$code .= "\$response = \$client->request('{$method}', 'https://api.example.com{$endpoint}', [\n";
if (! empty($headers)) {
$code .= " 'headers' => [\n";
foreach ($headers as $name => $value) {
$code .= " '{$name}' => '{$value}',\n";
}
$code .= " ],\n";
}
if ($body !== null) {
$code .= " 'json' => ".var_export($body, true).",\n";
}
$code .= "]);\n\n";
$code .= '$data = json_decode($response->getBody(), true);';
return $code;
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation;
use Illuminate\Routing\Route;
/**
* OpenAPI Extension Interface.
*
* Extensions allow customizing the OpenAPI specification generation
* by modifying the spec or individual operations.
*/
interface Extension
{
/**
* Extend the complete OpenAPI specification.
*
* Called after the spec is built but before it's cached or returned.
*
* @param array $spec The OpenAPI specification array
* @param array $config Documentation configuration
* @return array Modified specification
*/
public function extend(array $spec, array $config): array;
/**
* Extend an individual operation.
*
* Called for each route operation during path building.
*
* @param array $operation The operation array
* @param Route $route The Laravel route
* @param string $method HTTP method (lowercase)
* @param array $config Documentation configuration
* @return array Modified operation
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array;
}

View file

@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Extensions;
use Core\Mod\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* API Key Authentication Extension.
*
* Enhances API key authentication documentation with examples
* and detailed instructions.
*/
class ApiKeyAuthExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
$apiKeyConfig = $config['auth']['api_key'] ?? [];
if (! ($apiKeyConfig['enabled'] ?? true)) {
return $spec;
}
// Enhance API key security scheme description
if (isset($spec['components']['securitySchemes']['apiKeyAuth'])) {
$spec['components']['securitySchemes']['apiKeyAuth']['description'] = $this->buildApiKeyDescription($apiKeyConfig);
}
// Add authentication guide to info.description
$authGuide = $this->buildAuthenticationGuide($config);
if (! empty($authGuide)) {
$spec['info']['description'] = ($spec['info']['description'] ?? '')."\n\n".$authGuide;
}
// Add example schemas for authentication-related responses
$spec['components']['schemas']['UnauthorizedError'] = [
'type' => 'object',
'properties' => [
'message' => [
'type' => 'string',
'example' => 'Unauthenticated.',
],
],
];
$spec['components']['schemas']['ForbiddenError'] = [
'type' => 'object',
'properties' => [
'message' => [
'type' => 'string',
'example' => 'This action is unauthorized.',
],
],
];
// Add common auth error responses to components
$spec['components']['responses']['Unauthorized'] = [
'description' => 'Authentication required or invalid credentials',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/UnauthorizedError',
],
'examples' => [
'missing_key' => [
'summary' => 'Missing API Key',
'value' => ['message' => 'API key is required.'],
],
'invalid_key' => [
'summary' => 'Invalid API Key',
'value' => ['message' => 'Invalid API key.'],
],
'expired_key' => [
'summary' => 'Expired API Key',
'value' => ['message' => 'API key has expired.'],
],
],
],
],
];
$spec['components']['responses']['Forbidden'] = [
'description' => 'Insufficient permissions for this action',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ForbiddenError',
],
'examples' => [
'insufficient_scope' => [
'summary' => 'Missing Required Scope',
'value' => ['message' => 'API key lacks required scope: write'],
],
'workspace_access' => [
'summary' => 'Workspace Access Denied',
'value' => ['message' => 'API key does not have access to this workspace.'],
],
],
],
],
];
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
// Add 401/403 responses to authenticated endpoints
if (! empty($operation['security'])) {
$hasApiKeyAuth = false;
foreach ($operation['security'] as $security) {
if (isset($security['apiKeyAuth'])) {
$hasApiKeyAuth = true;
break;
}
}
if ($hasApiKeyAuth) {
// Add 401 response if not present
if (! isset($operation['responses']['401'])) {
$operation['responses']['401'] = [
'$ref' => '#/components/responses/Unauthorized',
];
}
// Add 403 response if not present
if (! isset($operation['responses']['403'])) {
$operation['responses']['403'] = [
'$ref' => '#/components/responses/Forbidden',
];
}
}
}
return $operation;
}
/**
* Build detailed API key description.
*/
protected function buildApiKeyDescription(array $config): string
{
$headerName = $config['name'] ?? 'X-API-Key';
$baseDescription = $config['description'] ?? 'API key for authentication.';
return <<<MARKDOWN
$baseDescription
## Usage
Include your API key in the `$headerName` header:
```
$headerName: your_api_key_here
```
## Key Format
API keys follow the format: `hk_xxxxxxxxxxxxxxxx`
- Prefix `hk_` identifies it as a Host UK API key
- Keys are 32+ characters long
- Keys should be kept secret and never committed to version control
## Scopes
API keys can be created with specific scopes:
- `read` - Read access to resources
- `write` - Create and update resources
- `delete` - Delete resources
## Key Management
- Create and manage API keys in your workspace settings
- Keys can be revoked at any time
- Set expiration dates for temporary access
- Monitor usage via the API dashboard
MARKDOWN;
}
/**
* Build authentication guide for API description.
*/
protected function buildAuthenticationGuide(array $config): string
{
$apiKeyConfig = $config['auth']['api_key'] ?? [];
$bearerConfig = $config['auth']['bearer'] ?? [];
$sections = [];
$sections[] = '## Authentication';
$sections[] = '';
$sections[] = 'This API supports multiple authentication methods:';
$sections[] = '';
if ($apiKeyConfig['enabled'] ?? true) {
$headerName = $apiKeyConfig['name'] ?? 'X-API-Key';
$sections[] = '### API Key Authentication';
$sections[] = '';
$sections[] = "For server-to-server integration, use API key authentication via the `$headerName` header.";
$sections[] = '';
$sections[] = '```http';
$sections[] = 'GET /api/endpoint HTTP/1.1';
$sections[] = 'Host: api.example.com';
$sections[] = "$headerName: hk_your_api_key_here";
$sections[] = '```';
$sections[] = '';
}
if ($bearerConfig['enabled'] ?? true) {
$sections[] = '### Bearer Token Authentication';
$sections[] = '';
$sections[] = 'For user-authenticated requests (SPAs, mobile apps), use bearer token authentication.';
$sections[] = '';
$sections[] = '```http';
$sections[] = 'GET /api/endpoint HTTP/1.1';
$sections[] = 'Host: api.example.com';
$sections[] = 'Authorization: Bearer your_token_here';
$sections[] = '```';
$sections[] = '';
}
return implode("\n", $sections);
}
}

View file

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Extensions;
use Core\Mod\Api\Documentation\Extension;
use Core\Mod\Api\RateLimit\RateLimit;
use Illuminate\Routing\Route;
use ReflectionClass;
/**
* Rate Limit Extension.
*
* Documents rate limit headers in API responses and extracts rate limit
* information from the #[RateLimit] attribute.
*/
class RateLimitExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
$rateLimitConfig = $config['rate_limits'] ?? [];
if (! ($rateLimitConfig['enabled'] ?? true)) {
return $spec;
}
// Add rate limit headers to components
$headers = $rateLimitConfig['headers'] ?? [
'X-RateLimit-Limit' => 'Maximum number of requests allowed per window',
'X-RateLimit-Remaining' => 'Number of requests remaining in the current window',
'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets',
];
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
foreach ($headers as $name => $description) {
$headerKey = str_replace(['-', ' '], '', strtolower($name));
$spec['components']['headers'][$headerKey] = [
'description' => $description,
'schema' => [
'type' => 'integer',
],
];
}
// Add 429 response schema to components
$spec['components']['responses']['RateLimitExceeded'] = [
'description' => 'Rate limit exceeded',
'headers' => [
'X-RateLimit-Limit' => [
'$ref' => '#/components/headers/xratelimitlimit',
],
'X-RateLimit-Remaining' => [
'$ref' => '#/components/headers/xratelimitremaining',
],
'X-RateLimit-Reset' => [
'$ref' => '#/components/headers/xratelimitreset',
],
'Retry-After' => [
'description' => 'Seconds to wait before retrying',
'schema' => ['type' => 'integer'],
],
],
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'message' => [
'type' => 'string',
'example' => 'Too Many Requests',
],
'retry_after' => [
'type' => 'integer',
'description' => 'Seconds until rate limit resets',
'example' => 30,
],
],
],
],
],
];
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
$rateLimitConfig = $config['rate_limits'] ?? [];
if (! ($rateLimitConfig['enabled'] ?? true)) {
return $operation;
}
// Check if route has rate limiting middleware
if (! $this->hasRateLimiting($route)) {
return $operation;
}
// Add rate limit headers to successful responses
foreach ($operation['responses'] as $status => &$response) {
if ((int) $status >= 200 && (int) $status < 300) {
$response['headers'] = $response['headers'] ?? [];
$response['headers']['X-RateLimit-Limit'] = [
'$ref' => '#/components/headers/xratelimitlimit',
];
$response['headers']['X-RateLimit-Remaining'] = [
'$ref' => '#/components/headers/xratelimitremaining',
];
$response['headers']['X-RateLimit-Reset'] = [
'$ref' => '#/components/headers/xratelimitreset',
];
}
}
// Add 429 response
$operation['responses']['429'] = [
'$ref' => '#/components/responses/RateLimitExceeded',
];
// Extract rate limit from attribute and add to description
$rateLimit = $this->extractRateLimit($route);
if ($rateLimit !== null) {
$limitInfo = sprintf(
'**Rate Limit:** %d requests per %d seconds',
$rateLimit['limit'],
$rateLimit['window']
);
if ($rateLimit['burst'] > 1.0) {
$limitInfo .= sprintf(' (%.0f%% burst allowed)', ($rateLimit['burst'] - 1) * 100);
}
$operation['description'] = isset($operation['description'])
? $operation['description']."\n\n".$limitInfo
: $limitInfo;
}
return $operation;
}
/**
* Check if route has rate limiting.
*/
protected function hasRateLimiting(Route $route): bool
{
$middleware = $route->middleware();
foreach ($middleware as $m) {
if (str_contains($m, 'throttle') ||
str_contains($m, 'rate') ||
str_contains($m, 'api.rate') ||
str_contains($m, 'RateLimit')) {
return true;
}
}
// Also check for RateLimit attribute on controller
$controller = $route->getController();
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if (! empty($reflection->getAttributes(RateLimit::class))) {
return true;
}
$action = $route->getActionMethod();
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
if (! empty($method->getAttributes(RateLimit::class))) {
return true;
}
}
}
return false;
}
/**
* Extract rate limit configuration from route.
*/
protected function extractRateLimit(Route $route): ?array
{
$controller = $route->getController();
if ($controller === null) {
return null;
}
$reflection = new ReflectionClass($controller);
$action = $route->getActionMethod();
// Check method first
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$attrs = $method->getAttributes(RateLimit::class);
if (! empty($attrs)) {
$rateLimit = $attrs[0]->newInstance();
return [
'limit' => $rateLimit->limit,
'window' => $rateLimit->window,
'burst' => $rateLimit->burst,
];
}
}
// Check class
$attrs = $reflection->getAttributes(RateLimit::class);
if (! empty($attrs)) {
$rateLimit = $attrs[0]->newInstance();
return [
'limit' => $rateLimit->limit,
'window' => $rateLimit->window,
'burst' => $rateLimit->burst,
];
}
return null;
}
}

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Extensions;
use Core\Mod\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* Workspace Header Extension.
*
* Adds documentation for the X-Workspace-ID header used in multi-tenant
* API operations.
*/
class WorkspaceHeaderExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
// Add workspace header parameter to components
$workspaceConfig = $config['workspace'] ?? [];
if (! empty($workspaceConfig)) {
$spec['components']['parameters']['workspaceId'] = [
'name' => $workspaceConfig['header_name'] ?? 'X-Workspace-ID',
'in' => 'header',
'required' => $workspaceConfig['required'] ?? false,
'description' => $workspaceConfig['description'] ?? 'Workspace identifier for multi-tenant operations',
'schema' => [
'type' => 'string',
'format' => 'uuid',
'example' => '550e8400-e29b-41d4-a716-446655440000',
],
];
}
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
// Check if route requires workspace context
if (! $this->requiresWorkspace($route)) {
return $operation;
}
$workspaceConfig = $config['workspace'] ?? [];
$headerName = $workspaceConfig['header_name'] ?? 'X-Workspace-ID';
// Add workspace header parameter reference
$operation['parameters'] = $operation['parameters'] ?? [];
// Check if already added
foreach ($operation['parameters'] as $param) {
if (isset($param['name']) && $param['name'] === $headerName) {
return $operation;
}
}
// Add as reference to component
$operation['parameters'][] = [
'$ref' => '#/components/parameters/workspaceId',
];
return $operation;
}
/**
* Check if route requires workspace context.
*/
protected function requiresWorkspace(Route $route): bool
{
$middleware = $route->middleware();
// Check for workspace-related middleware
foreach ($middleware as $m) {
if (str_contains($m, 'workspace') ||
str_contains($m, 'api.auth') ||
str_contains($m, 'auth.api')) {
return true;
}
}
// Check route name patterns that typically need workspace
$name = $route->getName() ?? '';
$workspaceRoutes = [
'api.key.',
'api.bio.',
'api.blocks.',
'api.shortlinks.',
'api.qr.',
'api.workspaces.',
'api.webhooks.',
'api.content.',
];
foreach ($workspaceRoutes as $pattern) {
if (str_starts_with($name, $pattern)) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Protect Documentation Middleware.
*
* Controls access to API documentation based on environment,
* authentication, and IP whitelist.
*/
class ProtectDocumentation
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// Check if documentation is enabled
if (! config('api-docs.enabled', true)) {
abort(404);
}
$config = config('api-docs.access', []);
// Check if public access is allowed in current environment
$publicEnvironments = $config['public_environments'] ?? ['local', 'testing', 'staging'];
if (in_array(app()->environment(), $publicEnvironments, true)) {
return $next($request);
}
// Check IP whitelist
$ipWhitelist = $config['ip_whitelist'] ?? [];
if (! empty($ipWhitelist)) {
$clientIp = $request->ip();
if (! in_array($clientIp, $ipWhitelist, true)) {
abort(403, 'Access denied.');
}
return $next($request);
}
// Check if authentication is required
if ($config['require_auth'] ?? false) {
if (! $request->user()) {
return redirect()->route('login');
}
// Check allowed roles
$allowedRoles = $config['allowed_roles'] ?? [];
if (! empty($allowedRoles)) {
$user = $request->user();
// Check if user has any of the allowed roles
$hasRole = false;
foreach ($allowedRoles as $role) {
if (method_exists($user, 'hasRole') && $user->hasRole($role)) {
$hasRole = true;
break;
}
}
if (! $hasRole) {
abort(403, 'Insufficient permissions to view documentation.');
}
}
}
return $next($request);
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation;
use Core\Mod\Api\Documentation\Attributes\ApiTag;
use Illuminate\Support\Facades\Route;
use ReflectionClass;
/**
* Module Discovery Service.
*
* Discovers API routes from modules and groups them by tag/module
* for organized documentation.
*/
class ModuleDiscovery
{
/**
* Discovered modules with their routes.
*
* @var array<string, array>
*/
protected array $modules = [];
/**
* Discover all API modules and their routes.
*
* @return array<string, array>
*/
public function discover(): array
{
$this->modules = [];
foreach (Route::getRoutes() as $route) {
if (! $this->isApiRoute($route)) {
continue;
}
$module = $this->identifyModule($route);
$this->addRouteToModule($module, $route);
}
ksort($this->modules);
return $this->modules;
}
/**
* Get modules grouped by tag.
*
* @return array<string, array>
*/
public function getModulesByTag(): array
{
$byTag = [];
foreach ($this->discover() as $module => $data) {
$tag = $data['tag'] ?? $module;
$byTag[$tag] = $byTag[$tag] ?? [
'name' => $tag,
'description' => $data['description'] ?? null,
'routes' => [],
];
$byTag[$tag]['routes'] = array_merge(
$byTag[$tag]['routes'],
$data['routes']
);
}
return $byTag;
}
/**
* Get a summary of discovered modules.
*/
public function getSummary(): array
{
$modules = $this->discover();
return array_map(function ($data) {
return [
'tag' => $data['tag'],
'description' => $data['description'],
'route_count' => count($data['routes']),
'endpoints' => array_map(function ($route) {
return [
'method' => $route['method'],
'uri' => $route['uri'],
'name' => $route['name'],
];
}, $data['routes']),
];
}, $modules);
}
/**
* Check if route is an API route.
*/
protected function isApiRoute($route): bool
{
$uri = $route->uri();
return str_starts_with($uri, 'api/') || $uri === 'api';
}
/**
* Identify which module a route belongs to.
*/
protected function identifyModule($route): string
{
$controller = $route->getController();
if ($controller !== null) {
// Check for ApiTag attribute
$reflection = new ReflectionClass($controller);
$tagAttrs = $reflection->getAttributes(ApiTag::class);
if (! empty($tagAttrs)) {
return $tagAttrs[0]->newInstance()->name;
}
// Infer from namespace
$namespace = $reflection->getNamespaceName();
// Extract module name from namespace patterns
if (preg_match('/(?:Mod|Module|Http\\\\Controllers)\\\\([^\\\\]+)/', $namespace, $matches)) {
return $matches[1];
}
}
// Infer from route URI
return $this->inferModuleFromUri($route->uri());
}
/**
* Infer module name from URI.
*/
protected function inferModuleFromUri(string $uri): string
{
// Remove api/ prefix
$path = preg_replace('#^api/#', '', $uri);
// Get first segment
$parts = explode('/', $path);
$segment = $parts[0] ?? 'general';
// Map common segments to module names
$mapping = [
'bio' => 'Bio',
'blocks' => 'Bio',
'shortlinks' => 'Bio',
'qr' => 'Bio',
'commerce' => 'Commerce',
'provisioning' => 'Commerce',
'workspaces' => 'Tenant',
'analytics' => 'Analytics',
'social' => 'Social',
'notify' => 'Notifications',
'support' => 'Support',
'pixel' => 'Pixel',
'seo' => 'SEO',
'mcp' => 'MCP',
'content' => 'Content',
'trust' => 'Trust',
'webhooks' => 'Webhooks',
'entitlements' => 'Entitlements',
];
return $mapping[$segment] ?? ucfirst($segment);
}
/**
* Add a route to a module.
*/
protected function addRouteToModule(string $module, $route): void
{
if (! isset($this->modules[$module])) {
$this->modules[$module] = [
'tag' => $module,
'description' => $this->getModuleDescription($module),
'routes' => [],
];
}
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
foreach ($methods as $method) {
$this->modules[$module]['routes'][] = [
'method' => strtoupper($method),
'uri' => '/'.$route->uri(),
'name' => $route->getName(),
'action' => $route->getActionMethod(),
'middleware' => $route->middleware(),
];
}
}
/**
* Get module description from config.
*/
protected function getModuleDescription(string $module): ?string
{
$tags = config('api-docs.tags', []);
return $tags[$module]['description'] ?? null;
}
}

View file

@ -0,0 +1,819 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation;
use Core\Mod\Api\Documentation\Attributes\ApiHidden;
use Core\Mod\Api\Documentation\Attributes\ApiParameter;
use Core\Mod\Api\Documentation\Attributes\ApiResponse;
use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
use Core\Mod\Api\Documentation\Attributes\ApiTag;
use Core\Mod\Api\Documentation\Extensions\ApiKeyAuthExtension;
use Core\Mod\Api\Documentation\Extensions\RateLimitExtension;
use Core\Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use ReflectionAttribute;
use ReflectionClass;
/**
* Enhanced OpenAPI Specification Builder.
*
* Builds comprehensive OpenAPI 3.1 specification from Laravel routes,
* with support for custom attributes, module discovery, and extensions.
*/
class OpenApiBuilder
{
/**
* Registered extensions.
*
* @var array<Extension>
*/
protected array $extensions = [];
/**
* Discovered tags from modules.
*
* @var array<string, array>
*/
protected array $discoveredTags = [];
/**
* Create a new builder instance.
*/
public function __construct()
{
$this->registerDefaultExtensions();
}
/**
* Register default extensions.
*/
protected function registerDefaultExtensions(): void
{
$this->extensions = [
new WorkspaceHeaderExtension,
new RateLimitExtension,
new ApiKeyAuthExtension,
];
}
/**
* Add a custom extension.
*/
public function addExtension(Extension $extension): static
{
$this->extensions[] = $extension;
return $this;
}
/**
* Generate the complete OpenAPI specification.
*/
public function build(): array
{
$config = config('api-docs', []);
if ($this->shouldCache($config)) {
$cacheKey = $config['cache']['key'] ?? 'api-docs:openapi';
$cacheTtl = $config['cache']['ttl'] ?? 3600;
return Cache::remember($cacheKey, $cacheTtl, fn () => $this->buildSpec($config));
}
return $this->buildSpec($config);
}
/**
* Clear the cached specification.
*/
public function clearCache(): void
{
$cacheKey = config('api-docs.cache.key', 'api-docs:openapi');
Cache::forget($cacheKey);
}
/**
* Check if caching should be enabled.
*/
protected function shouldCache(array $config): bool
{
if (! ($config['cache']['enabled'] ?? true)) {
return false;
}
$disabledEnvs = $config['cache']['disabled_environments'] ?? ['local', 'testing'];
return ! in_array(app()->environment(), $disabledEnvs, true);
}
/**
* Build the full OpenAPI specification.
*/
protected function buildSpec(array $config): array
{
$spec = [
'openapi' => '3.1.0',
'info' => $this->buildInfo($config),
'servers' => $this->buildServers($config),
'tags' => [],
'paths' => [],
'components' => $this->buildComponents($config),
];
// Build paths and collect tags
$spec['paths'] = $this->buildPaths($config);
$spec['tags'] = $this->buildTags($config);
// Apply extensions to spec
foreach ($this->extensions as $extension) {
$spec = $extension->extend($spec, $config);
}
return $spec;
}
/**
* Build API info section.
*/
protected function buildInfo(array $config): array
{
$info = $config['info'] ?? [];
$result = [
'title' => $info['title'] ?? config('app.name', 'API').' API',
'version' => $info['version'] ?? config('api.version', '1.0.0'),
];
if (! empty($info['description'])) {
$result['description'] = $info['description'];
}
if (! empty($info['contact'])) {
$contact = array_filter($info['contact']);
if (! empty($contact)) {
$result['contact'] = $contact;
}
}
if (! empty($info['license']['name'])) {
$result['license'] = array_filter($info['license']);
}
return $result;
}
/**
* Build servers section.
*/
protected function buildServers(array $config): array
{
$servers = $config['servers'] ?? [];
if (empty($servers)) {
return [
[
'url' => config('app.url', 'http://localhost'),
'description' => 'Current Environment',
],
];
}
return array_map(fn ($server) => array_filter($server), $servers);
}
/**
* Build tags section from discovered modules and config.
*/
protected function buildTags(array $config): array
{
$configTags = $config['tags'] ?? [];
$tags = [];
// Add discovered tags first
foreach ($this->discoveredTags as $name => $data) {
$tags[$name] = [
'name' => $name,
'description' => $data['description'] ?? null,
];
}
// Merge with configured tags (config takes precedence)
foreach ($configTags as $key => $tagConfig) {
$tagName = $tagConfig['name'] ?? $key;
$tags[$tagName] = [
'name' => $tagName,
'description' => $tagConfig['description'] ?? null,
];
}
// Clean up null descriptions and sort
$result = [];
foreach ($tags as $tag) {
$result[] = array_filter($tag);
}
usort($result, fn ($a, $b) => strcasecmp($a['name'], $b['name']));
return $result;
}
/**
* Build paths section from routes.
*/
protected function buildPaths(array $config): array
{
$paths = [];
$includePatterns = $config['routes']['include'] ?? ['api/*'];
$excludePatterns = $config['routes']['exclude'] ?? [];
foreach (RouteFacade::getRoutes() as $route) {
/** @var Route $route */
if (! $this->shouldIncludeRoute($route, $includePatterns, $excludePatterns)) {
continue;
}
$path = $this->normalizePath($route->uri());
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
foreach ($methods as $method) {
$method = strtolower($method);
$operation = $this->buildOperation($route, $method, $config);
if ($operation !== null) {
$paths[$path][$method] = $operation;
}
}
}
ksort($paths);
return $paths;
}
/**
* Check if a route should be included in documentation.
*/
protected function shouldIncludeRoute(Route $route, array $include, array $exclude): bool
{
$uri = $route->uri();
// Check exclusions first
foreach ($exclude as $pattern) {
if (fnmatch($pattern, $uri)) {
return false;
}
}
// Check inclusions
foreach ($include as $pattern) {
if (fnmatch($pattern, $uri)) {
return true;
}
}
return false;
}
/**
* Normalize route path to OpenAPI format.
*/
protected function normalizePath(string $uri): string
{
// Prepend slash if missing
$path = '/'.ltrim($uri, '/');
// Convert Laravel parameters to OpenAPI format: {param?} -> {param}
$path = preg_replace('/\{([^}?]+)\?\}/', '{$1}', $path);
return $path === '/' ? '/' : rtrim($path, '/');
}
/**
* Build operation for a specific route and method.
*/
protected function buildOperation(Route $route, string $method, array $config): ?array
{
$controller = $route->getController();
$action = $route->getActionMethod();
// Check for ApiHidden attribute
if ($this->isHidden($controller, $action)) {
return null;
}
$operation = [
'summary' => $this->buildSummary($route, $method),
'operationId' => $this->buildOperationId($route, $method),
'tags' => $this->buildOperationTags($route, $controller, $action),
'responses' => $this->buildResponses($controller, $action),
];
// Add description from PHPDoc if available
$description = $this->extractDescription($controller, $action);
if ($description) {
$operation['description'] = $description;
}
// Add parameters
$parameters = $this->buildParameters($route, $controller, $action, $config);
if (! empty($parameters)) {
$operation['parameters'] = $parameters;
}
// Add request body for POST/PUT/PATCH
if (in_array($method, ['post', 'put', 'patch'])) {
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
}
// Add security requirements
$security = $this->buildSecurity($route, $controller, $action);
if ($security !== null) {
$operation['security'] = $security;
}
// Apply extensions to operation
foreach ($this->extensions as $extension) {
$operation = $extension->extendOperation($operation, $route, $method, $config);
}
return $operation;
}
/**
* Check if controller/method is hidden from docs.
*/
protected function isHidden(?object $controller, string $action): bool
{
if ($controller === null) {
return false;
}
$reflection = new ReflectionClass($controller);
// Check class-level attribute
$classAttrs = $reflection->getAttributes(ApiHidden::class);
if (! empty($classAttrs)) {
return true;
}
// Check method-level attribute
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$methodAttrs = $method->getAttributes(ApiHidden::class);
if (! empty($methodAttrs)) {
return true;
}
}
return false;
}
/**
* Build operation summary.
*/
protected function buildSummary(Route $route, string $method): string
{
$name = $route->getName();
if ($name) {
// Convert route name to human-readable summary
$parts = explode('.', $name);
$action = array_pop($parts);
return Str::title(str_replace(['-', '_'], ' ', $action));
}
// Generate from URI and method
$uri = Str::afterLast($route->uri(), '/');
return Str::title($method.' '.str_replace(['-', '_'], ' ', $uri));
}
/**
* Build operation ID from route name.
*/
protected function buildOperationId(Route $route, string $method): string
{
$name = $route->getName();
if ($name) {
return Str::camel(str_replace(['.', '-'], '_', $name));
}
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
}
/**
* Build tags for an operation.
*/
protected function buildOperationTags(Route $route, ?object $controller, string $action): array
{
// Check for ApiTag attribute
if ($controller !== null) {
$tagAttr = $this->getAttribute($controller, $action, ApiTag::class);
if ($tagAttr !== null) {
$tag = $tagAttr->newInstance();
$this->discoveredTags[$tag->name] = ['description' => $tag->description];
return [$tag->name];
}
}
// Infer tag from route
return [$this->inferTag($route)];
}
/**
* Infer tag from route.
*/
protected function inferTag(Route $route): string
{
$uri = $route->uri();
$name = $route->getName() ?? '';
// Common tag mappings by route prefix
$tagMap = [
'api/bio' => 'Bio Links',
'api/blocks' => 'Bio Links',
'api/shortlinks' => 'Bio Links',
'api/qr' => 'Bio Links',
'api/commerce' => 'Commerce',
'api/provisioning' => 'Commerce',
'api/workspaces' => 'Workspaces',
'api/analytics' => 'Analytics',
'api/social' => 'Social',
'api/notify' => 'Notifications',
'api/support' => 'Support',
'api/pixel' => 'Pixel',
'api/seo' => 'SEO',
'api/mcp' => 'MCP',
'api/content' => 'Content',
'api/trust' => 'Trust',
'api/webhooks' => 'Webhooks',
'api/entitlements' => 'Entitlements',
];
foreach ($tagMap as $prefix => $tag) {
if (str_starts_with($uri, $prefix)) {
$this->discoveredTags[$tag] = $this->discoveredTags[$tag] ?? [];
return $tag;
}
}
$this->discoveredTags['General'] = $this->discoveredTags['General'] ?? [];
return 'General';
}
/**
* Extract description from PHPDoc.
*/
protected function extractDescription(?object $controller, string $action): ?string
{
if ($controller === null) {
return null;
}
$reflection = new ReflectionClass($controller);
if (! $reflection->hasMethod($action)) {
return null;
}
$method = $reflection->getMethod($action);
$doc = $method->getDocComment();
if (! $doc) {
return null;
}
// Extract description from PHPDoc (first paragraph before @tags)
preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n\s*\*\s*\n|\n\s*\*\s*@)/s', $doc, $matches);
if (! empty($matches[1])) {
$description = preg_replace('/\n\s*\*\s*/', ' ', $matches[1]);
return trim($description);
}
return null;
}
/**
* Build parameters for operation.
*/
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
{
$parameters = [];
// Add path parameters
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
foreach ($matches[1] as $param) {
$parameters[] = [
'name' => $param,
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
];
}
// Add parameters from ApiParameter attributes
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$paramAttrs = $method->getAttributes(ApiParameter::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($paramAttrs as $attr) {
$param = $attr->newInstance();
$parameters[] = $param->toOpenApi();
}
}
}
return $parameters;
}
/**
* Build responses section.
*/
protected function buildResponses(?object $controller, string $action): array
{
$responses = [];
// Get ApiResponse attributes
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$responseAttrs = $method->getAttributes(ApiResponse::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($responseAttrs as $attr) {
$response = $attr->newInstance();
$responses[(string) $response->status] = $this->buildResponseSchema($response);
}
}
}
// Default 200 response if none specified
if (empty($responses)) {
$responses['200'] = ['description' => 'Successful response'];
}
return $responses;
}
/**
* Build response schema from ApiResponse attribute.
*/
protected function buildResponseSchema(ApiResponse $response): array
{
$result = [
'description' => $response->getDescription(),
];
if ($response->resource !== null && class_exists($response->resource)) {
$schema = $this->extractResourceSchema($response->resource);
if ($response->paginated) {
$schema = $this->wrapPaginatedSchema($schema);
}
$result['content'] = [
'application/json' => [
'schema' => $schema,
],
];
}
if (! empty($response->headers)) {
$result['headers'] = [];
foreach ($response->headers as $header => $description) {
$result['headers'][$header] = [
'description' => $description,
'schema' => ['type' => 'string'],
];
}
}
return $result;
}
/**
* Extract schema from JsonResource class.
*/
protected function extractResourceSchema(string $resourceClass): array
{
if (! is_subclass_of($resourceClass, JsonResource::class)) {
return ['type' => 'object'];
}
// For now, return a generic object schema
// A more sophisticated implementation would analyze the resource's toArray method
return [
'type' => 'object',
'additionalProperties' => true,
];
}
/**
* Wrap schema in pagination structure.
*/
protected function wrapPaginatedSchema(array $itemSchema): array
{
return [
'type' => 'object',
'properties' => [
'data' => [
'type' => 'array',
'items' => $itemSchema,
],
'links' => [
'type' => 'object',
'properties' => [
'first' => ['type' => 'string', 'format' => 'uri'],
'last' => ['type' => 'string', 'format' => 'uri'],
'prev' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
'next' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
],
],
'meta' => [
'type' => 'object',
'properties' => [
'current_page' => ['type' => 'integer'],
'from' => ['type' => 'integer', 'nullable' => true],
'last_page' => ['type' => 'integer'],
'per_page' => ['type' => 'integer'],
'to' => ['type' => 'integer', 'nullable' => true],
'total' => ['type' => 'integer'],
],
],
],
];
}
/**
* Build request body schema.
*/
protected function buildRequestBody(?object $controller, string $action): array
{
return [
'required' => true,
'content' => [
'application/json' => [
'schema' => ['type' => 'object'],
],
],
];
}
/**
* Build security requirements.
*/
protected function buildSecurity(Route $route, ?object $controller, string $action): ?array
{
// Check for ApiSecurity attribute
if ($controller !== null) {
$securityAttr = $this->getAttribute($controller, $action, ApiSecurity::class);
if ($securityAttr !== null) {
$security = $securityAttr->newInstance();
if ($security->isPublic()) {
return []; // Empty array means no auth required
}
return [[$security->scheme => $security->scopes]];
}
}
// Infer from route middleware
$middleware = $route->middleware();
if (in_array('auth:sanctum', $middleware) || in_array('auth', $middleware)) {
return [['bearerAuth' => []]];
}
if (in_array('api.auth', $middleware) || in_array('auth.api', $middleware)) {
return [['apiKeyAuth' => []]];
}
foreach ($middleware as $m) {
if (str_contains($m, 'ApiKeyAuth') || str_contains($m, 'AuthenticateApiKey')) {
return [['apiKeyAuth' => []]];
}
}
return null;
}
/**
* Build components section.
*/
protected function buildComponents(array $config): array
{
$components = [
'securitySchemes' => [],
'schemas' => $this->buildCommonSchemas(),
];
// Add API Key security scheme
$apiKeyConfig = $config['auth']['api_key'] ?? [];
if ($apiKeyConfig['enabled'] ?? true) {
$components['securitySchemes']['apiKeyAuth'] = [
'type' => 'apiKey',
'in' => $apiKeyConfig['in'] ?? 'header',
'name' => $apiKeyConfig['name'] ?? 'X-API-Key',
'description' => $apiKeyConfig['description'] ?? 'API key for authentication',
];
}
// Add Bearer token security scheme
$bearerConfig = $config['auth']['bearer'] ?? [];
if ($bearerConfig['enabled'] ?? true) {
$components['securitySchemes']['bearerAuth'] = [
'type' => 'http',
'scheme' => $bearerConfig['scheme'] ?? 'bearer',
'bearerFormat' => $bearerConfig['format'] ?? 'JWT',
'description' => $bearerConfig['description'] ?? 'Bearer token authentication',
];
}
// Add OAuth2 security scheme
$oauth2Config = $config['auth']['oauth2'] ?? [];
if ($oauth2Config['enabled'] ?? false) {
$components['securitySchemes']['oauth2'] = [
'type' => 'oauth2',
'flows' => $oauth2Config['flows'] ?? [],
];
}
return $components;
}
/**
* Build common reusable schemas.
*/
protected function buildCommonSchemas(): array
{
return [
'Error' => [
'type' => 'object',
'required' => ['message'],
'properties' => [
'message' => ['type' => 'string', 'description' => 'Error message'],
'errors' => [
'type' => 'object',
'description' => 'Validation errors (field => messages)',
'additionalProperties' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
],
],
],
'Pagination' => [
'type' => 'object',
'properties' => [
'current_page' => ['type' => 'integer'],
'from' => ['type' => 'integer', 'nullable' => true],
'last_page' => ['type' => 'integer'],
'per_page' => ['type' => 'integer'],
'to' => ['type' => 'integer', 'nullable' => true],
'total' => ['type' => 'integer'],
],
],
];
}
/**
* Get attribute from controller class or method.
*
* @template T
*
* @param class-string<T> $attributeClass
* @return ReflectionAttribute<T>|null
*/
protected function getAttribute(object $controller, string $action, string $attributeClass): ?ReflectionAttribute
{
$reflection = new ReflectionClass($controller);
// Check method first (method takes precedence)
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$attrs = $method->getAttributes($attributeClass);
if (! empty($attrs)) {
return $attrs[0];
}
}
// Fall back to class
$attrs = $reflection->getAttributes($attributeClass);
return $attrs[0] ?? null;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Core\Mod\Api\Documentation\DocumentationController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Documentation Routes
|--------------------------------------------------------------------------
|
| These routes serve the OpenAPI documentation and interactive API explorers.
| Protected by the ProtectDocumentation middleware for production environments.
|
*/
// Documentation UI routes
Route::get('/', [DocumentationController::class, 'index'])->name('api.docs');
Route::get('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger');
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
// OpenAPI specification routes
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])
->name('api.docs.openapi.json')
->middleware('throttle:60,1');
Route::get('/openapi.yaml', [DocumentationController::class, 'openApiYaml'])
->name('api.docs.openapi.yaml')
->middleware('throttle:60,1');
// Cache management (admin only)
Route::post('/cache/clear', [DocumentationController::class, 'clearCache'])
->name('api.docs.cache.clear')
->middleware('auth');

View file

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="API Documentation - ReDoc">
<title>{{ config('api-docs.info.title', 'API Documentation') }} - ReDoc</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Custom ReDoc theme overrides */
.redoc-wrap {
--primary-color: #3b82f6;
--primary-color-dark: #2563eb;
--selection-color: rgba(59, 130, 246, 0.1);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
body {
background: #1a1a2e;
}
}
</style>
</head>
<body>
<redoc spec-url="{{ $specUrl }}"
expand-responses="200,201"
path-in-middle-panel
hide-hostname
hide-download-button
required-props-first
sort-props-alphabetically
no-auto-auth
theme='{
"colors": {
"primary": { "main": "#3b82f6" }
},
"typography": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif",
"headings": { "fontFamily": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif" },
"code": { "fontFamily": "\"JetBrains Mono\", \"Fira Code\", Consolas, monospace" }
},
"sidebar": {
"width": "280px"
},
"rightPanel": {
"backgroundColor": "#1e293b"
}
}'>
</redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
</body>
</html>

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="API Documentation - Scalar">
<title>{{ config('api-docs.info.title', 'API Documentation') }}</title>
<style>
body { margin: 0; }
</style>
</head>
<body>
<script
id="api-reference"
data-url="{{ $specUrl }}"
data-configuration='{
"theme": "{{ $config['theme'] ?? 'default' }}",
"showSidebar": {{ ($config['show_sidebar'] ?? true) ? 'true' : 'false' }},
"hideDownloadButton": {{ ($config['hide_download_button'] ?? false) ? 'true' : 'false' }},
"hideModels": {{ ($config['hide_models'] ?? false) ? 'true' : 'false' }},
"darkMode": false,
"layout": "modern",
"searchHotKey": "k"
}'
></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>

View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="API Documentation - Swagger UI">
<title>{{ config('api-docs.info.title', 'API Documentation') }} - Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
html { box-sizing: border-box; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; }
.swagger-ui .info { margin: 20px 0; }
.swagger-ui .info .title { font-size: 28px; }
.swagger-ui .scheme-container { background: transparent; box-shadow: none; padding: 0; }
.swagger-ui .opblock-tag { font-size: 18px; }
.swagger-ui .opblock .opblock-summary-operation-id { font-size: 13px; }
/* Dark mode support */
@media (prefers-color-scheme: dark) {
body { background: #1a1a2e; }
.swagger-ui { filter: invert(88%) hue-rotate(180deg); }
.swagger-ui .opblock-body pre { filter: invert(100%) hue-rotate(180deg); }
.swagger-ui img { filter: invert(100%) hue-rotate(180deg); }
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: @json($specUrl),
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "BaseLayout",
defaultModelsExpandDepth: -1,
docExpansion: @json($config['doc_expansion'] ?? 'none'),
filter: @json($config['filter'] ?? true),
showExtensions: @json($config['show_extensions'] ?? true),
showCommonExtensions: @json($config['show_common_extensions'] ?? true),
syntaxHighlight: {
activated: true,
theme: "monokai"
},
requestInterceptor: function(request) {
// Add any default headers here
return request;
}
});
};
</script>
</body>
</html>

View file

@ -0,0 +1,319 @@
<?php
/**
* API Documentation Configuration
*
* Configuration for OpenAPI/Swagger documentation powered by Scramble.
*/
return [
/*
|--------------------------------------------------------------------------
| Documentation Enabled
|--------------------------------------------------------------------------
|
| Enable or disable API documentation. When disabled, the /api/docs
| endpoint will return 404.
|
*/
'enabled' => env('API_DOCS_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Documentation Path
|--------------------------------------------------------------------------
|
| The URL path where API documentation is served.
|
*/
'path' => '/api/docs',
/*
|--------------------------------------------------------------------------
| API Information
|--------------------------------------------------------------------------
|
| Basic information about your API displayed in the documentation.
|
*/
'info' => [
'title' => env('API_DOCS_TITLE', 'API Documentation'),
'description' => env('API_DOCS_DESCRIPTION', 'REST API for programmatic access to services.'),
'version' => env('API_DOCS_VERSION', '1.0.0'),
'contact' => [
'name' => env('API_DOCS_CONTACT_NAME'),
'email' => env('API_DOCS_CONTACT_EMAIL'),
'url' => env('API_DOCS_CONTACT_URL'),
],
'license' => [
'name' => env('API_DOCS_LICENSE_NAME', 'Proprietary'),
'url' => env('API_DOCS_LICENSE_URL'),
],
],
/*
|--------------------------------------------------------------------------
| Servers
|--------------------------------------------------------------------------
|
| List of API servers displayed in the documentation.
|
*/
'servers' => [
[
'url' => env('APP_URL', 'http://localhost'),
'description' => 'Current Environment',
],
],
/*
|--------------------------------------------------------------------------
| Authentication Schemes
|--------------------------------------------------------------------------
|
| Configure how authentication is documented in OpenAPI.
|
*/
'auth' => [
// API Key authentication via header
'api_key' => [
'enabled' => true,
'name' => 'X-API-Key',
'in' => 'header',
'description' => 'API key for authentication. Create keys in your workspace settings.',
],
// Bearer token authentication
'bearer' => [
'enabled' => true,
'scheme' => 'bearer',
'format' => 'JWT',
'description' => 'Bearer token authentication for user sessions.',
],
// OAuth2 (if applicable)
'oauth2' => [
'enabled' => false,
'flows' => [
'authorizationCode' => [
'authorizationUrl' => '/oauth/authorize',
'tokenUrl' => '/oauth/token',
'refreshUrl' => '/oauth/token',
'scopes' => [
'read' => 'Read access to resources',
'write' => 'Write access to resources',
'delete' => 'Delete access to resources',
],
],
],
],
],
/*
|--------------------------------------------------------------------------
| Workspace Header
|--------------------------------------------------------------------------
|
| Configure the workspace header documentation.
|
*/
'workspace' => [
'header_name' => 'X-Workspace-ID',
'required' => false,
'description' => 'Optional workspace identifier for multi-tenant operations. If not provided, the default workspace associated with the API key will be used.',
],
/*
|--------------------------------------------------------------------------
| Rate Limiting Documentation
|--------------------------------------------------------------------------
|
| Configure how rate limits are documented in responses.
|
*/
'rate_limits' => [
'enabled' => true,
'headers' => [
'X-RateLimit-Limit' => 'Maximum number of requests allowed per window',
'X-RateLimit-Remaining' => 'Number of requests remaining in the current window',
'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets',
'Retry-After' => 'Seconds to wait before retrying (only on 429 responses)',
],
],
/*
|--------------------------------------------------------------------------
| Module Tags
|--------------------------------------------------------------------------
|
| Map module namespaces to documentation tags for grouping endpoints.
|
*/
'tags' => [
// Module namespace => Tag configuration
'Bio' => [
'name' => 'Bio Links',
'description' => 'Bio link pages, blocks, and customization',
],
'Commerce' => [
'name' => 'Commerce',
'description' => 'Billing, subscriptions, orders, and invoices',
],
'Analytics' => [
'name' => 'Analytics',
'description' => 'Website and link analytics tracking',
],
'Social' => [
'name' => 'Social',
'description' => 'Social media management and scheduling',
],
'Notify' => [
'name' => 'Notifications',
'description' => 'Push notifications and alerts',
],
'Support' => [
'name' => 'Support',
'description' => 'Helpdesk and customer support',
],
'Tenant' => [
'name' => 'Workspaces',
'description' => 'Workspace and team management',
],
'Pixel' => [
'name' => 'Pixel',
'description' => 'Unified tracking pixel endpoints',
],
'SEO' => [
'name' => 'SEO',
'description' => 'SEO analysis and reporting',
],
'MCP' => [
'name' => 'MCP',
'description' => 'Model Context Protocol HTTP bridge',
],
'Content' => [
'name' => 'Content',
'description' => 'AI content generation',
],
'Trust' => [
'name' => 'Trust',
'description' => 'Social proof and testimonials',
],
'Webhooks' => [
'name' => 'Webhooks',
'description' => 'Webhook endpoints and management',
],
],
/*
|--------------------------------------------------------------------------
| Route Filtering
|--------------------------------------------------------------------------
|
| Configure which routes are included in the documentation.
|
*/
'routes' => [
// Only include routes matching these patterns
'include' => [
'api/*',
],
// Exclude routes matching these patterns
'exclude' => [
'api/sanctum/*',
'api/telescope/*',
'api/horizon/*',
],
// Hide internal/admin routes from public docs
'hide_internal' => true,
],
/*
|--------------------------------------------------------------------------
| Documentation UI
|--------------------------------------------------------------------------
|
| Configure the documentation UI appearance.
|
*/
'ui' => [
// Default UI renderer: 'swagger', 'scalar', 'redoc', 'stoplight'
'default' => 'scalar',
// Swagger UI specific options
'swagger' => [
'doc_expansion' => 'none', // 'list', 'full', 'none'
'filter' => true,
'show_extensions' => true,
'show_common_extensions' => true,
],
// Scalar specific options
'scalar' => [
'theme' => 'default', // 'default', 'alternate', 'moon', 'purple', 'solarized'
'show_sidebar' => true,
'hide_download_button' => false,
'hide_models' => false,
],
],
/*
|--------------------------------------------------------------------------
| Access Control
|--------------------------------------------------------------------------
|
| Configure who can access the documentation.
|
*/
'access' => [
// Require authentication to view docs
'require_auth' => env('API_DOCS_REQUIRE_AUTH', false),
// Only allow these roles to view docs (empty = all authenticated users)
'allowed_roles' => [],
// Allow unauthenticated access in these environments
'public_environments' => ['local', 'testing', 'staging'],
// IP whitelist for production (empty = no restriction)
'ip_whitelist' => [],
],
/*
|--------------------------------------------------------------------------
| Caching
|--------------------------------------------------------------------------
|
| Configure documentation caching.
|
*/
'cache' => [
// Enable caching of generated OpenAPI spec
'enabled' => env('API_DOCS_CACHE_ENABLED', true),
// Cache key prefix
'key' => 'api-docs:openapi',
// Cache duration in seconds (1 hour default)
'ttl' => env('API_DOCS_CACHE_TTL', 3600),
// Disable cache in these environments
'disabled_environments' => ['local', 'testing'],
],
];

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Exceptions;
use Core\Mod\Api\RateLimit\RateLimitResult;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Exception thrown when API rate limit is exceeded.
*
* Renders as a proper JSON response with rate limit headers.
*/
class RateLimitExceededException extends HttpException
{
public function __construct(
protected RateLimitResult $rateLimitResult,
string $message = 'Too many requests. Please slow down.',
) {
parent::__construct(429, $message);
}
/**
* Get the rate limit result.
*/
public function getRateLimitResult(): RateLimitResult
{
return $this->rateLimitResult;
}
/**
* Render the exception as a JSON response.
*/
public function render(): JsonResponse
{
return response()->json([
'error' => 'rate_limit_exceeded',
'message' => $this->getMessage(),
'retry_after' => $this->rateLimitResult->retryAfter,
'limit' => $this->rateLimitResult->limit,
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
], 429, $this->rateLimitResult->headers());
}
/**
* Get headers for the response.
*
* @return array<string, string|int>
*/
public function getHeaders(): array
{
return array_map(fn ($value) => (string) $value, $this->rateLimitResult->headers());
}
}

View file

@ -2,8 +2,9 @@
declare(strict_types=1);
namespace Mod\Api\Jobs;
namespace Core\Mod\Api\Jobs;
use Core\Mod\Api\Models\WebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -11,7 +12,6 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Mod\Api\Models\WebhookDelivery;
/**
* Delivers webhook payloads to registered endpoints.

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Middleware;
use Closure;
use Core\Mod\Api\Models\ApiKey;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Automatically enforce API key scopes based on HTTP method.
*
* Scope mapping:
* - GET, HEAD, OPTIONS -> read
* - POST, PUT, PATCH -> write
* - DELETE -> delete
*
* Usage: Add to routes alongside api.auth middleware.
* Route::middleware(['api.auth', 'api.scope.enforce'])->group(...)
*
* For routes that need to override the auto-detection, use CheckApiScope:
* Route::middleware(['api.auth', 'api.scope:read'])->post('/readonly-action', ...)
*/
class EnforceApiScope
{
/**
* HTTP method to required scope mapping.
*/
protected const METHOD_SCOPES = [
'GET' => ApiKey::SCOPE_READ,
'HEAD' => ApiKey::SCOPE_READ,
'OPTIONS' => ApiKey::SCOPE_READ,
'POST' => ApiKey::SCOPE_WRITE,
'PUT' => ApiKey::SCOPE_WRITE,
'PATCH' => ApiKey::SCOPE_WRITE,
'DELETE' => ApiKey::SCOPE_DELETE,
];
public function handle(Request $request, Closure $next): Response
{
$apiKey = $request->attributes->get('api_key');
// If not authenticated via API key, allow through
// Session auth and Sanctum handle their own permissions
if (! $apiKey instanceof ApiKey) {
return $next($request);
}
$method = strtoupper($request->method());
$requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ;
if (! $apiKey->hasScope($requiredScope)) {
return response()->json([
'error' => 'forbidden',
'message' => "API key missing required scope: {$requiredScope}",
'detail' => "{$method} requests require '{$requiredScope}' scope",
'key_scopes' => $apiKey->scopes,
], 403);
}
return $next($request);
}
}

View file

@ -4,156 +4,349 @@ declare(strict_types=1);
namespace Core\Mod\Api\Middleware;
use Mod\Api\Models\ApiKey;
use Closure;
use Illuminate\Cache\RateLimiter;
use Core\Mod\Api\Exceptions\RateLimitExceededException;
use Core\Mod\Api\Models\ApiKey;
use Core\Mod\Api\RateLimit\RateLimit;
use Core\Mod\Api\RateLimit\RateLimitResult;
use Core\Mod\Api\RateLimit\RateLimitService;
use Illuminate\Http\Request;
use ReflectionClass;
use ReflectionMethod;
use Symfony\Component\HttpFoundation\Response;
/**
* Rate limit API requests based on API key or user.
* Rate limit API requests with granular control.
*
* Rate limits are configured in config/api.php and can be tier-based.
* Supports:
* - Per-endpoint rate limits via config or #[RateLimit] attribute
* - Per-workspace rate limits with workspace ID in key
* - Per-API key rate limits
* - Tier-based limits based on workspace subscription
* - Burst allowance configuration
* - Standard rate limit headers (X-RateLimit-*)
*
* Priority (highest to lowest):
* 1. Method-level #[RateLimit] attribute
* 2. Class-level #[RateLimit] attribute
* 3. Per-endpoint config (api.rate_limits.endpoints.{route_name})
* 4. Tier-based limits (api.rate_limits.tiers.{tier})
* 5. Default authenticated limits
* 6. Default unauthenticated limits
*
* Register in bootstrap/app.php:
* ->withMiddleware(function (Middleware $middleware) {
* $middleware->alias([
* 'api.ratelimit' => \App\Http\Middleware\Api\RateLimitApi::class,
* 'api.rate' => \Core\Mod\Api\Middleware\RateLimitApi::class,
* ]);
* })
*/
class RateLimitApi
{
public function __construct(
protected RateLimiter $limiter
protected RateLimitService $rateLimitService,
) {}
public function handle(Request $request, Closure $next): Response
{
$key = $this->resolveRateLimitKey($request);
$limits = $this->resolveRateLimits($request);
$maxAttempts = $limits['requests'];
$decayMinutes = $limits['per_minutes'];
if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
return $this->buildTooManyAttemptsResponse($key, $maxAttempts);
// Check if rate limiting is enabled
if (! config('api.rate_limits.enabled', true)) {
return $next($request);
}
$this->limiter->hit($key, $decayMinutes * 60);
$rateLimitConfig = $this->resolveRateLimitConfig($request);
$key = $this->resolveRateLimitKey($request, $rateLimitConfig);
// Perform rate limit check and hit
$result = $this->rateLimitService->hit(
key: $key,
limit: $rateLimitConfig['limit'],
window: $rateLimitConfig['window'],
burst: $rateLimitConfig['burst'],
);
if (! $result->allowed) {
throw new RateLimitExceededException($result);
}
$response = $next($request);
return $this->addRateLimitHeaders(
$response,
$maxAttempts,
$this->calculateRemainingAttempts($key, $maxAttempts)
);
return $this->addRateLimitHeaders($response, $result);
}
/**
* Resolve the rate limit configuration for the request.
*
* @return array{limit: int, window: int, burst: float, key: string|null}
*/
protected function resolveRateLimitConfig(Request $request): array
{
$defaults = config('api.rate_limits.default', [
'limit' => 60,
'window' => 60,
'burst' => 1.0,
]);
// 1. Check for #[RateLimit] attribute on controller/method
$attributeConfig = $this->getAttributeRateLimit($request);
if ($attributeConfig !== null) {
return array_merge($defaults, $attributeConfig);
}
// 2. Check for per-endpoint config
$endpointConfig = $this->getEndpointRateLimit($request);
if ($endpointConfig !== null) {
return array_merge($defaults, $endpointConfig);
}
// 3. Check for tier-based limits
$tierConfig = $this->getTierRateLimit($request);
if ($tierConfig !== null) {
return array_merge($defaults, $tierConfig);
}
// 4. Use authenticated limits if authenticated
if ($this->isAuthenticated($request)) {
$authenticated = config('api.rate_limits.authenticated', $defaults);
return [
'limit' => $authenticated['requests'] ?? $authenticated['limit'] ?? $defaults['limit'],
'window' => ($authenticated['per_minutes'] ?? 1) * 60,
'burst' => $authenticated['burst'] ?? $defaults['burst'] ?? 1.0,
'key' => null,
];
}
// 5. Use default limits
return [
'limit' => $defaults['requests'] ?? $defaults['limit'] ?? 60,
'window' => ($defaults['per_minutes'] ?? 1) * 60,
'burst' => $defaults['burst'] ?? 1.0,
'key' => null,
];
}
/**
* Get rate limit from #[RateLimit] attribute.
*
* @return array{limit: int, window: int, burst: float, key: string|null}|null
*/
protected function getAttributeRateLimit(Request $request): ?array
{
$route = $request->route();
if (! $route) {
return null;
}
$controller = $route->getController();
$method = $route->getActionMethod();
if (! $controller || ! $method) {
return null;
}
try {
// Check method-level attribute first
$reflection = new ReflectionMethod($controller, $method);
$attributes = $reflection->getAttributes(RateLimit::class);
if (! empty($attributes)) {
/** @var RateLimit $rateLimit */
$rateLimit = $attributes[0]->newInstance();
return [
'limit' => $rateLimit->limit,
'window' => $rateLimit->window,
'burst' => $rateLimit->burst,
'key' => $rateLimit->key,
];
}
// Check class-level attribute
$classReflection = new ReflectionClass($controller);
$classAttributes = $classReflection->getAttributes(RateLimit::class);
if (! empty($classAttributes)) {
/** @var RateLimit $rateLimit */
$rateLimit = $classAttributes[0]->newInstance();
return [
'limit' => $rateLimit->limit,
'window' => $rateLimit->window,
'burst' => $rateLimit->burst,
'key' => $rateLimit->key,
];
}
} catch (\ReflectionException) {
// Controller or method doesn't exist
}
return null;
}
/**
* Get rate limit from per-endpoint config.
*
* @return array{limit: int, window: int, burst: float, key: string|null}|null
*/
protected function getEndpointRateLimit(Request $request): ?array
{
$route = $request->route();
if (! $route) {
return null;
}
$routeName = $route->getName();
if (! $routeName) {
return null;
}
// Try exact match first (e.g., "api.users.index")
$config = config("api.rate_limits.endpoints.{$routeName}");
// Try with dots replaced (e.g., "users.index" for route "api.users.index")
if (! $config) {
$shortName = preg_replace('/^api\./', '', $routeName);
$config = config("api.rate_limits.endpoints.{$shortName}");
}
if (! $config) {
return null;
}
return [
'limit' => $config['limit'] ?? $config['requests'] ?? 60,
'window' => $config['window'] ?? (($config['per_minutes'] ?? 1) * 60),
'burst' => $config['burst'] ?? 1.0,
'key' => $config['key'] ?? null,
];
}
/**
* Get tier-based rate limit from workspace subscription.
*
* @return array{limit: int, window: int, burst: float, key: string|null}|null
*/
protected function getTierRateLimit(Request $request): ?array
{
$workspace = $request->attributes->get('workspace');
if (! $workspace) {
return null;
}
$tier = $this->getWorkspaceTier($workspace);
$tierConfig = config("api.rate_limits.tiers.{$tier}");
if (! $tierConfig) {
// Fall back to by_tier for backwards compatibility
$tierConfig = config("api.rate_limits.by_tier.{$tier}");
}
if (! $tierConfig) {
return null;
}
return [
'limit' => $tierConfig['limit'] ?? $tierConfig['requests'] ?? 60,
'window' => $tierConfig['window'] ?? (($tierConfig['per_minutes'] ?? 1) * 60),
'burst' => $tierConfig['burst'] ?? 1.0,
'key' => null,
];
}
/**
* Resolve the rate limit key for the request.
*
* @param array{limit: int, window: int, burst: float, key: string|null} $config
*/
protected function resolveRateLimitKey(Request $request): string
protected function resolveRateLimitKey(Request $request, array $config): string
{
$parts = [];
// Use custom key suffix if provided
$suffix = $config['key'];
// Add endpoint to key if per_workspace is enabled and we have a route
$perWorkspace = config('api.rate_limits.per_workspace', true);
$route = $request->route();
// Build identifier based on auth context
$apiKey = $request->attributes->get('api_key');
$workspace = $request->attributes->get('workspace');
if ($apiKey instanceof ApiKey) {
return 'api_key:'.$apiKey->id;
}
$parts[] = "api_key:{$apiKey->id}";
if ($request->user()) {
return 'user:'.$request->user()->id;
}
return 'ip:'.$request->ip();
}
/**
* Resolve rate limits based on authentication and tier.
*/
protected function resolveRateLimits(Request $request): array
{
$apiKey = $request->attributes->get('api_key');
// Default limits for unauthenticated requests
$default = config('api.rate_limits.default', [
'requests' => 60,
'per_minutes' => 1,
]);
if (! $apiKey && ! $request->user()) {
return $default;
}
// Authenticated limits
$authenticated = config('api.rate_limits.authenticated', [
'requests' => 1000,
'per_minutes' => 1,
]);
// Check for tier-based limits via workspace/entitlements
$workspace = $request->attributes->get('workspace');
if ($workspace) {
$tier = $this->getWorkspaceTier($workspace);
$tierLimits = config("api.rate_limits.by_tier.{$tier}");
if ($tierLimits) {
return $tierLimits;
// Include workspace if per_workspace is enabled
if ($perWorkspace && $workspace) {
$parts[] = "ws:{$workspace->id}";
}
} elseif ($request->user()) {
$parts[] = "user:{$request->user()->id}";
if ($perWorkspace && $workspace) {
$parts[] = "ws:{$workspace->id}";
}
} else {
$parts[] = "ip:{$request->ip()}";
}
return $authenticated;
// Add route name for per-endpoint isolation
if ($route && $route->getName()) {
$parts[] = "route:{$route->getName()}";
}
// Add custom suffix if provided
if ($suffix) {
$parts[] = $suffix;
}
return implode(':', $parts);
}
/**
* Get workspace tier for rate limiting.
*
* Integrates with EntitlementService for tier detection.
*/
protected function getWorkspaceTier($workspace): string
protected function getWorkspaceTier(mixed $workspace): string
{
// Check if workspace has an active package
$package = $workspace->activePackages()->first();
// Check if workspace has an active package/subscription
if (method_exists($workspace, 'activePackages')) {
$package = $workspace->activePackages()->first();
return $package?->slug ?? 'starter';
return $package?->slug ?? 'free';
}
// Check for a tier attribute
if (property_exists($workspace, 'tier')) {
return $workspace->tier ?? 'free';
}
// Check for a plan attribute
if (property_exists($workspace, 'plan')) {
return $workspace->plan ?? 'free';
}
return 'free';
}
/**
* Build response for too many attempts.
* Check if the request is authenticated.
*/
protected function buildTooManyAttemptsResponse(string $key, int $maxAttempts): Response
protected function isAuthenticated(Request $request): bool
{
$retryAfter = $this->limiter->availableIn($key);
return response()->json([
'error' => 'rate_limit_exceeded',
'message' => 'Too many requests. Please slow down.',
'retry_after' => $retryAfter,
], 429)->withHeaders([
'Retry-After' => $retryAfter,
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => now()->addSeconds($retryAfter)->timestamp,
]);
return $request->attributes->get('api_key') !== null
|| $request->user() !== null;
}
/**
* Add rate limit headers to response.
*/
protected function addRateLimitHeaders(Response $response, int $maxAttempts, int $remaining): Response
protected function addRateLimitHeaders(Response $response, RateLimitResult $result): Response
{
$response->headers->set('X-RateLimit-Limit', (string) $maxAttempts);
$response->headers->set('X-RateLimit-Remaining', (string) max(0, $remaining));
$response->headers->set('X-RateLimit-Reset', (string) now()->addMinute()->timestamp);
foreach ($result->headers() as $header => $value) {
$response->headers->set($header, (string) $value);
}
return $response;
}
/**
* Calculate remaining attempts.
*/
protected function calculateRemainingAttempts(string $key, int $maxAttempts): int
{
return $this->limiter->remaining($key, $maxAttempts);
}
}

View file

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Adds columns to support:
* - Secure hashing with bcrypt/Argon2 (hash_algorithm tracks which was used)
* - Key rotation with grace periods
* - Tracking which key was rotated from
*/
public function up(): void
{
Schema::table('api_keys', function (Blueprint $table) {
// Track hash algorithm for backward compatibility during migration
// 'sha256' = legacy unsalted hash, 'bcrypt' = secure hash
$table->string('hash_algorithm', 16)->default('sha256')->after('key');
// Grace period for key rotation - old key remains valid until this time
$table->timestamp('grace_period_ends_at')->nullable()->after('expires_at');
// Track key rotation lineage
$table->foreignId('rotated_from_id')
->nullable()
->after('grace_period_ends_at')
->constrained('api_keys')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('api_keys', function (Blueprint $table) {
$table->dropForeign(['rotated_from_id']);
$table->dropColumn(['hash_algorithm', 'grace_period_ends_at', 'rotated_from_id']);
});
}
};

View file

@ -10,19 +10,36 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* API Key - authenticates SDK and REST API requests.
*
* Keys are prefixed with 'hk_' for identification.
* The actual key is hashed and never stored in plain text.
* The actual key is hashed using bcrypt and never stored in plain text.
*
* Security: Keys created before the bcrypt migration use SHA-256 (without salt).
* The hash_algorithm column tracks which algorithm was used for each key.
* Legacy SHA-256 keys should be rotated to use the secure bcrypt algorithm.
*/
class ApiKey extends Model
{
use HasFactory;
use SoftDeletes;
/**
* Hash algorithm identifiers.
*/
public const HASH_SHA256 = 'sha256';
public const HASH_BCRYPT = 'bcrypt';
/**
* Default grace period for key rotation (in hours).
*/
public const DEFAULT_GRACE_PERIOD_HOURS = 24;
/**
* Scopes available for API keys.
*/
@ -43,11 +60,14 @@ class ApiKey extends Model
'user_id',
'name',
'key',
'hash_algorithm',
'prefix',
'scopes',
'server_scopes',
'last_used_at',
'expires_at',
'grace_period_ends_at',
'rotated_from_id',
];
protected $casts = [
@ -55,6 +75,7 @@ class ApiKey extends Model
'server_scopes' => 'array',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
'grace_period_ends_at' => 'datetime',
];
protected $hidden = [
@ -65,6 +86,7 @@ class ApiKey extends Model
* Generate a new API key for a workspace.
*
* Returns both the ApiKey model and the plain key (only available once).
* New keys use bcrypt for secure hashing with salt.
*
* @return array{api_key: ApiKey, plain_key: string}
*/
@ -82,7 +104,8 @@ class ApiKey extends Model
'workspace_id' => $workspaceId,
'user_id' => $userId,
'name' => $name,
'key' => hash('sha256', $plainKey),
'key' => Hash::make($plainKey),
'hash_algorithm' => self::HASH_BCRYPT,
'prefix' => $prefix,
'scopes' => $scopes,
'expires_at' => $expiresAt,
@ -97,6 +120,9 @@ class ApiKey extends Model
/**
* Find an API key by its plain text value.
*
* Supports both legacy SHA-256 keys and new bcrypt keys.
* For bcrypt keys, we must load all candidates by prefix and verify each.
*/
public static function findByPlainKey(string $plainKey): ?static
{
@ -113,14 +139,118 @@ class ApiKey extends Model
$prefix = $parts[0].'_'.$parts[1]; // hk_xxxxxxxx
$key = $parts[2];
return static::where('prefix', $prefix)
->where('key', hash('sha256', $key))
// Find potential matches by prefix
$candidates = static::where('prefix', $prefix)
->whereNull('deleted_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->first();
->where(function ($query) {
// Exclude keys past their grace period
$query->whereNull('grace_period_ends_at')
->orWhere('grace_period_ends_at', '>', now());
})
->get();
foreach ($candidates as $candidate) {
if ($candidate->verifyKey($key)) {
return $candidate;
}
}
return null;
}
/**
* Verify if the provided key matches this API key's stored hash.
*
* Handles both legacy SHA-256 and secure bcrypt algorithms.
*/
public function verifyKey(string $plainKey): bool
{
if ($this->hash_algorithm === self::HASH_BCRYPT) {
return Hash::check($plainKey, $this->key);
}
// Legacy SHA-256 verification (for backward compatibility)
return hash_equals($this->key, hash('sha256', $plainKey));
}
/**
* Check if this key uses legacy (insecure) SHA-256 hashing.
*
* Keys using SHA-256 should be rotated to use bcrypt.
*/
public function usesLegacyHash(): bool
{
return $this->hash_algorithm === self::HASH_SHA256
|| $this->hash_algorithm === null;
}
/**
* Rotate this API key, creating a new secure key.
*
* The old key remains valid during the grace period to allow
* seamless migration of integrations.
*
* @param int $gracePeriodHours Hours the old key remains valid
* @return array{api_key: ApiKey, plain_key: string, old_key: ApiKey}
*/
public function rotate(int $gracePeriodHours = self::DEFAULT_GRACE_PERIOD_HOURS): array
{
// Create new key with same settings
$result = static::generate(
$this->workspace_id,
$this->user_id,
$this->name,
$this->scopes ?? [self::SCOPE_READ, self::SCOPE_WRITE],
$this->expires_at
);
// Copy server scopes to new key
$result['api_key']->update([
'server_scopes' => $this->server_scopes,
'rotated_from_id' => $this->id,
]);
// Set grace period on old key
$this->update([
'grace_period_ends_at' => now()->addHours($gracePeriodHours),
]);
return [
'api_key' => $result['api_key'],
'plain_key' => $result['plain_key'],
'old_key' => $this,
];
}
/**
* Check if this key is currently in a rotation grace period.
*/
public function isInGracePeriod(): bool
{
return $this->grace_period_ends_at !== null
&& $this->grace_period_ends_at->isFuture();
}
/**
* Check if the grace period has expired (key should be revoked).
*/
public function isGracePeriodExpired(): bool
{
return $this->grace_period_ends_at !== null
&& $this->grace_period_ends_at->isPast();
}
/**
* End the grace period early and revoke this key.
*/
public function endGracePeriod(): void
{
$this->update(['grace_period_ends_at' => now()]);
$this->revoke();
}
/**
@ -210,7 +340,15 @@ class ApiKey extends Model
return $this->belongsTo(User::class);
}
// Scopes
/**
* Get the key this one was rotated from.
*/
public function rotatedFrom(): BelongsTo
{
return $this->belongsTo(static::class, 'rotated_from_id');
}
// Query Scopes
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
@ -222,6 +360,10 @@ class ApiKey extends Model
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($q) {
$q->whereNull('grace_period_ends_at')
->orWhere('grace_period_ends_at', '>', now());
});
}
@ -230,4 +372,41 @@ class ApiKey extends Model
return $query->whereNotNull('expires_at')
->where('expires_at', '<=', now());
}
/**
* Keys currently in a rotation grace period.
*/
public function scopeInGracePeriod($query)
{
return $query->whereNotNull('grace_period_ends_at')
->where('grace_period_ends_at', '>', now());
}
/**
* Keys with expired grace periods (should be cleaned up).
*/
public function scopeGracePeriodExpired($query)
{
return $query->whereNotNull('grace_period_ends_at')
->where('grace_period_ends_at', '<=', now());
}
/**
* Keys using legacy SHA-256 hashing (should be rotated).
*/
public function scopeLegacyHash($query)
{
return $query->where(function ($q) {
$q->where('hash_algorithm', self::HASH_SHA256)
->orWhereNull('hash_algorithm');
});
}
/**
* Keys using secure bcrypt hashing.
*/
public function scopeSecureHash($query)
{
return $query->where('hash_algorithm', self::HASH_BCRYPT);
}
}

View file

@ -20,12 +20,16 @@ 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_RETRIES = 5;
/**
@ -140,17 +144,35 @@ class WebhookDelivery extends Model
/**
* Get formatted payload with signature headers.
*
* Includes all required headers for webhook verification:
* - X-Webhook-Signature: HMAC-SHA256 signature of timestamp.payload
* - X-Webhook-Timestamp: Unix timestamp (for replay protection)
* - X-Webhook-Event: The event type (e.g., 'bio.created')
* - X-Webhook-Id: Unique delivery ID for idempotency
*
* ## Verification Instructions (for recipients)
*
* 1. Get the signature and timestamp from headers
* 2. Compute: HMAC-SHA256(timestamp + "." + rawBody, yourSecret)
* 3. Compare with X-Webhook-Signature using timing-safe comparison
* 4. Verify timestamp is within 5 minutes of current time
*
* @param int|null $timestamp Unix timestamp (defaults to current time)
* @return array{headers: array<string, string|int>, body: string}
*/
public function getDeliveryPayload(): array
public function getDeliveryPayload(?int $timestamp = null): array
{
$timestamp ??= time();
$jsonPayload = json_encode($this->payload);
return [
'headers' => [
'Content-Type' => 'application/json',
'X-HostHub-Event' => $this->event_type,
'X-HostHub-Delivery' => $this->event_id,
'X-HostHub-Signature' => $this->endpoint->generateSignature($jsonPayload),
'X-Webhook-Id' => $this->event_id,
'X-Webhook-Event' => $this->event_type,
'X-Webhook-Timestamp' => (string) $timestamp,
'X-Webhook-Signature' => $this->endpoint->generateSignature($jsonPayload, $timestamp),
],
'body' => $jsonPayload,
];

View file

@ -4,18 +4,30 @@ declare(strict_types=1);
namespace Core\Mod\Api\Models;
use Core\Mod\Api\Services\WebhookSignature;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
/**
* Webhook Endpoint - receives event notifications.
*
* Uses HMAC signatures for security and auto-disables after failures.
* Uses HMAC-SHA256 signatures with timestamps for security:
* - All outbound webhooks are signed with a per-endpoint secret
* - Timestamps prevent replay attacks (5-minute tolerance)
* - Auto-disables after 10 consecutive delivery failures
*
* ## Signature Verification (for webhook recipients)
*
* Recipients should verify webhooks using:
* 1. Compute: HMAC-SHA256(timestamp + "." + payload, secret)
* 2. Compare with X-Webhook-Signature header (timing-safe)
* 3. Verify X-Webhook-Timestamp is within 5 minutes of current time
*
* See WebhookSignature service for full documentation.
*/
class WebhookEndpoint extends Model
{
@ -93,10 +105,12 @@ class WebhookEndpoint extends Model
array $events,
?string $description = null
): static {
$signatureService = app(WebhookSignature::class);
return static::create([
'workspace_id' => $workspaceId,
'url' => $url,
'secret' => Str::random(64),
'secret' => $signatureService->generateSecret(),
'events' => $events,
'description' => $description,
'active' => true,
@ -104,11 +118,40 @@ class WebhookEndpoint extends Model
}
/**
* Generate signature for payload.
* Generate signature for payload with timestamp.
*
* The signature includes the timestamp to prevent replay attacks.
* Format: HMAC-SHA256(timestamp + "." + payload, secret)
*
* @param string $payload The JSON-encoded webhook payload
* @param int $timestamp Unix timestamp of the request
* @return string The hex-encoded HMAC-SHA256 signature
*/
public function generateSignature(string $payload): string
public function generateSignature(string $payload, int $timestamp): string
{
return hash_hmac('sha256', $payload, $this->secret);
$signatureService = app(WebhookSignature::class);
return $signatureService->sign($payload, $this->secret, $timestamp);
}
/**
* Verify a signature from an incoming request (for testing endpoints).
*
* @param string $payload The raw request body
* @param string $signature The signature from the header
* @param int $timestamp The timestamp from the header
* @param int $tolerance Maximum age in seconds (default: 300)
* @return bool True if the signature is valid
*/
public function verifySignature(
string $payload,
string $signature,
int $timestamp,
int $tolerance = WebhookSignature::DEFAULT_TOLERANCE
): bool {
$signatureService = app(WebhookSignature::class);
return $signatureService->verify($payload, $signature, $this->secret, $timestamp, $tolerance);
}
/**
@ -175,10 +218,16 @@ class WebhookEndpoint extends Model
/**
* Rotate the webhook secret.
*
* Generates a new cryptographically secure secret. The old secret
* immediately becomes invalid - recipients must update their configuration.
*
* @return string The new secret (only returned once, store securely)
*/
public function rotateSecret(): string
{
$newSecret = Str::random(64);
$signatureService = app(WebhookSignature::class);
$newSecret = $signatureService->generateSecret();
$this->update(['secret' => $newSecret]);
return $newSecret;

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Notifications;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Notification sent when API usage approaches rate limits.
*
* Levels:
* - warning: 80% of limit used
* - critical: 95% of limit used
*/
class HighApiUsageNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Workspace $workspace,
public string $level,
public int $currentUsage,
public int $limit,
public string $period,
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$percentage = round(($this->currentUsage / $this->limit) * 100, 1);
$subject = match ($this->level) {
'critical' => "API Usage Critical - {$percentage}% of limit reached",
default => "API Usage Warning - {$percentage}% of limit reached",
};
$message = (new MailMessage)
->subject($subject)
->greeting($this->getGreeting())
->line($this->getMainMessage())
->line("**Workspace:** {$this->workspace->name}")
->line("**Current usage:** {$this->currentUsage} requests")
->line("**Rate limit:** {$this->limit} requests per {$this->period}")
->line("**Usage:** {$percentage}%");
if ($this->level === 'critical') {
$message->line('If you exceed your rate limit, API requests will be temporarily blocked until the limit resets.');
}
$message->action('View API Usage', url('/developer/api'))
->line('Consider upgrading your plan if you regularly approach these limits.');
return $message;
}
/**
* Get the greeting based on level.
*/
protected function getGreeting(): string
{
return match ($this->level) {
'critical' => 'Warning: API Usage Critical',
default => 'Notice: API Usage High',
};
}
/**
* Get the main message based on level.
*/
protected function getMainMessage(): string
{
return match ($this->level) {
'critical' => 'Your API usage has reached a critical level and is approaching the rate limit.',
default => 'Your API usage is high and approaching the rate limit threshold.',
};
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'level' => $this->level,
'workspace_id' => $this->workspace->id,
'workspace_name' => $this->workspace->name,
'current_usage' => $this->currentUsage,
'limit' => $this->limit,
'period' => $this->period,
];
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\RateLimit;
use Attribute;
/**
* Rate limit attribute for controllers and methods.
*
* Apply to controller classes or individual methods to set custom rate limits.
* Method-level attributes take precedence over class-level attributes.
*
* Example usage:
*
* #[RateLimit(limit: 100, window: 60)]
* class UserController extends Controller
* {
* #[RateLimit(limit: 10, window: 60)] // Override for this method
* public function store() {}
*
* #[RateLimit(limit: 1000, window: 60, burst: 1.5)] // Allow 50% burst
* public function index() {}
* }
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class RateLimit
{
/**
* @param int $limit Maximum requests allowed in the window
* @param int $window Time window in seconds
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
* @param string|null $key Custom rate limit key suffix (null uses default)
*/
public function __construct(
public int $limit,
public int $window = 60,
public float $burst = 1.0,
public ?string $key = null,
) {}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\RateLimit;
use Carbon\Carbon;
/**
* Rate limit check result DTO.
*
* Contains information about the current rate limit status for a request.
*/
readonly class RateLimitResult
{
public function __construct(
public bool $allowed,
public int $limit,
public int $remaining,
public int $retryAfter,
public Carbon $resetsAt,
) {}
/**
* Create a successful result (request allowed).
*/
public static function allowed(int $limit, int $remaining, Carbon $resetsAt): self
{
return new self(
allowed: true,
limit: $limit,
remaining: $remaining,
retryAfter: 0,
resetsAt: $resetsAt,
);
}
/**
* Create a denied result (rate limit exceeded).
*/
public static function denied(int $limit, int $retryAfter, Carbon $resetsAt): self
{
return new self(
allowed: false,
limit: $limit,
remaining: 0,
retryAfter: $retryAfter,
resetsAt: $resetsAt,
);
}
/**
* Get headers for the response.
*
* @return array<string, string|int>
*/
public function headers(): array
{
$headers = [
'X-RateLimit-Limit' => $this->limit,
'X-RateLimit-Remaining' => $this->remaining,
'X-RateLimit-Reset' => $this->resetsAt->timestamp,
];
if (! $this->allowed) {
$headers['Retry-After'] = $this->retryAfter;
}
return $headers;
}
}

View file

@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\RateLimit;
use Carbon\Carbon;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Rate limiting service with sliding window algorithm.
*
* Provides granular rate limiting with support for:
* - Per-key rate limiting (API keys, users, IPs, etc.)
* - Sliding window algorithm for smoother rate limiting
* - Burst allowance configuration
* - Tier-based limits
*/
class RateLimitService
{
/**
* Cache prefix for rate limit keys.
*/
protected const CACHE_PREFIX = 'rate_limit:';
public function __construct(
protected CacheRepository $cache,
) {}
/**
* Check if a request would be allowed without incrementing the counter.
*
* @param string $key Unique identifier for the rate limit bucket
* @param int $limit Maximum requests allowed
* @param int $window Time window in seconds
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
*/
public function check(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = (int) floor($limit * $burst);
$now = Carbon::now();
$windowStart = $now->timestamp - $window;
// Get current window data
$hits = $this->getWindowHits($cacheKey, $windowStart);
$currentCount = count($hits);
$remaining = max(0, $effectiveLimit - $currentCount);
// Calculate reset time
$resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
if ($currentCount >= $effectiveLimit) {
// Find oldest hit to determine retry after
$oldestHit = min($hits);
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
}
return RateLimitResult::allowed($limit, $remaining, $resetsAt);
}
/**
* Record a hit and check if the request is allowed.
*
* @param string $key Unique identifier for the rate limit bucket
* @param int $limit Maximum requests allowed
* @param int $window Time window in seconds
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
*/
public function hit(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = (int) floor($limit * $burst);
$now = Carbon::now();
$windowStart = $now->timestamp - $window;
// Get current window data and clean up old entries
$hits = $this->getWindowHits($cacheKey, $windowStart);
$currentCount = count($hits);
// Calculate reset time
$resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
if ($currentCount >= $effectiveLimit) {
// Find oldest hit to determine retry after
$oldestHit = min($hits);
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
}
// Record the hit
$hits[] = $now->timestamp;
$this->storeWindowHits($cacheKey, $hits, $window);
$remaining = max(0, $effectiveLimit - count($hits));
return RateLimitResult::allowed($limit, $remaining, $resetsAt);
}
/**
* Get remaining attempts for a key.
*
* @param string $key Unique identifier for the rate limit bucket
* @param int $limit Maximum requests allowed (needed to calculate remaining)
* @param int $window Time window in seconds
* @param float $burst Burst multiplier
*/
public function remaining(string $key, int $limit, int $window, float $burst = 1.0): int
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = (int) floor($limit * $burst);
$windowStart = Carbon::now()->timestamp - $window;
$hits = $this->getWindowHits($cacheKey, $windowStart);
return max(0, $effectiveLimit - count($hits));
}
/**
* Reset (clear) a rate limit bucket.
*/
public function reset(string $key): void
{
$cacheKey = $this->getCacheKey($key);
$this->cache->forget($cacheKey);
}
/**
* Get the current hit count for a key.
*/
public function attempts(string $key, int $window): int
{
$cacheKey = $this->getCacheKey($key);
$windowStart = Carbon::now()->timestamp - $window;
return count($this->getWindowHits($cacheKey, $windowStart));
}
/**
* Build a rate limit key for an endpoint.
*/
public function buildEndpointKey(string $identifier, string $endpoint): string
{
return "endpoint:{$identifier}:{$endpoint}";
}
/**
* Build a rate limit key for a workspace.
*/
public function buildWorkspaceKey(int $workspaceId, ?string $suffix = null): string
{
$key = "workspace:{$workspaceId}";
if ($suffix !== null) {
$key .= ":{$suffix}";
}
return $key;
}
/**
* Build a rate limit key for an API key.
*/
public function buildApiKeyKey(int|string $apiKeyId, ?string $suffix = null): string
{
$key = "api_key:{$apiKeyId}";
if ($suffix !== null) {
$key .= ":{$suffix}";
}
return $key;
}
/**
* Build a rate limit key for an IP address.
*/
public function buildIpKey(string $ip, ?string $suffix = null): string
{
$key = "ip:{$ip}";
if ($suffix !== null) {
$key .= ":{$suffix}";
}
return $key;
}
/**
* Get hits within the sliding window.
*
* @return array<int> Array of timestamps
*/
protected function getWindowHits(string $cacheKey, int $windowStart): array
{
/** @var array<int> $hits */
$hits = $this->cache->get($cacheKey, []);
// Filter to only include hits within the window
return array_values(array_filter($hits, fn (int $timestamp) => $timestamp >= $windowStart));
}
/**
* Store hits in cache.
*
* @param array<int> $hits Array of timestamps
*/
protected function storeWindowHits(string $cacheKey, array $hits, int $window): void
{
// Add buffer to TTL to handle clock drift
$ttl = $window + 60;
$this->cache->put($cacheKey, $hits, $ttl);
}
/**
* Calculate when the rate limit resets.
*
* @param array<int> $hits Array of timestamps
*/
protected function calculateResetTime(array $hits, int $window, int $limit): Carbon
{
if (empty($hits)) {
return Carbon::now()->addSeconds($window);
}
// If under limit, reset is at the end of the window
if (count($hits) < $limit) {
return Carbon::now()->addSeconds($window);
}
// If at or over limit, reset when the oldest hit expires
$oldestHit = min($hits);
return Carbon::createFromTimestamp($oldestHit + $window);
}
/**
* Generate the cache key.
*/
protected function getCacheKey(string $key): string
{
return self::CACHE_PREFIX.$key;
}
}

View file

@ -71,11 +71,12 @@ Route::middleware('auth')->prefix('entitlements')->group(function () {
// MCP HTTP Bridge (API key auth)
// ─────────────────────────────────────────────────────────────────────────────
Route::middleware(['throttle:120,1', McpApiKeyAuth::class])
Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce'])
->prefix('mcp')
->name('api.mcp.')
->group(function () {
// Server discovery
// Scope enforcement: GET=read, POST=write
// Server discovery (read)
Route::get('/servers', [McpApiController::class, 'servers'])
->name('servers');
Route::get('/servers/{id}', [McpApiController::class, 'server'])
@ -83,11 +84,11 @@ Route::middleware(['throttle:120,1', McpApiKeyAuth::class])
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
->name('servers.tools');
// Tool execution
// Tool execution (write)
Route::post('/tools/call', [McpApiController::class, 'callTool'])
->name('tools.call');
// Resource access
// Resource access (read)
Route::get('/resources/{uri}', [McpApiController::class, 'resource'])
->where('uri', '.*')
->name('resources.show');

View file

@ -2,13 +2,13 @@
declare(strict_types=1);
namespace Mod\Api\Services;
namespace Core\Mod\Api\Services;
use Core\Mod\Api\Jobs\DeliverWebhookJob;
use Core\Mod\Api\Models\WebhookDelivery;
use Core\Mod\Api\Models\WebhookEndpoint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Mod\Api\Jobs\DeliverWebhookJob;
use Mod\Api\Models\WebhookDelivery;
use Mod\Api\Models\WebhookEndpoint;
/**
* Webhook Service - dispatches events to registered webhook endpoints.

View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Services;
use Illuminate\Support\Str;
/**
* Webhook Signature Service - handles HMAC signing and verification for outbound webhooks.
*
* This service provides cryptographic signing for webhook payloads to ensure:
* 1. **Authenticity**: Recipients can verify the request came from our platform
* 2. **Integrity**: Recipients can verify the payload wasn't tampered with
* 3. **Replay Protection**: Timestamps prevent replay attacks
*
* ## Signature Algorithm
*
* The signature is computed as:
* ```
* signature = HMAC-SHA256(timestamp + "." + payload, secret)
* ```
*
* Including the timestamp in the signed data prevents replay attacks where an
* attacker could capture a valid webhook and resend it later.
*
* ## Verification Example (for webhook recipients)
*
* ```php
* // Get headers and body from the request
* $signature = $request->header('X-Webhook-Signature');
* $timestamp = $request->header('X-Webhook-Timestamp');
* $payload = $request->getContent();
*
* // Compute expected signature
* $expectedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, $webhookSecret);
*
* // Verify signature using timing-safe comparison
* if (!hash_equals($expectedSignature, $signature)) {
* abort(401, 'Invalid webhook signature');
* }
*
* // Verify timestamp is within tolerance (e.g., 5 minutes)
* $tolerance = 300; // seconds
* if (abs(time() - (int)$timestamp) > $tolerance) {
* abort(401, 'Webhook timestamp too old');
* }
* ```
*/
class WebhookSignature
{
/**
* Default secret length in bytes (64 characters when hex-encoded).
*/
private const SECRET_LENGTH = 32;
/**
* Default tolerance for timestamp verification in seconds.
* 5 minutes allows for reasonable clock skew and network delays.
*/
public const DEFAULT_TOLERANCE = 300;
/**
* The hashing algorithm used for HMAC.
*/
private const ALGORITHM = 'sha256';
/**
* Generate a cryptographically secure webhook signing secret.
*
* The secret is a 64-character random string suitable for HMAC-SHA256 signing.
* This should be stored securely and shared with the webhook recipient out-of-band.
*
* @return string A 64-character random string
*/
public function generateSecret(): string
{
return Str::random(64);
}
/**
* Sign a webhook payload with the given secret and timestamp.
*
* The signature format is:
* HMAC-SHA256(timestamp + "." + payload, secret)
*
* This format ensures the timestamp cannot be changed without invalidating
* the signature, providing replay attack protection.
*
* @param string $payload The JSON-encoded webhook payload
* @param string $secret The endpoint's signing secret
* @param int $timestamp Unix timestamp of when the webhook was sent
* @return string The HMAC-SHA256 signature (hex-encoded, 64 characters)
*/
public function sign(string $payload, string $secret, int $timestamp): string
{
$signedPayload = $this->buildSignedPayload($timestamp, $payload);
return hash_hmac(self::ALGORITHM, $signedPayload, $secret);
}
/**
* Verify a webhook signature.
*
* Performs a timing-safe comparison to prevent timing attacks, and optionally
* validates that the timestamp is within the specified tolerance.
*
* @param string $payload The raw request body (JSON string)
* @param string $signature The signature from X-Webhook-Signature header
* @param string $secret The webhook endpoint's secret
* @param int $timestamp The timestamp from X-Webhook-Timestamp header
* @param int $tolerance Maximum age of the timestamp in seconds (default: 300)
* @return bool True if the signature is valid and timestamp is within tolerance
*/
public function verify(
string $payload,
string $signature,
string $secret,
int $timestamp,
int $tolerance = self::DEFAULT_TOLERANCE
): bool {
// Check timestamp is within tolerance
if (! $this->isTimestampValid($timestamp, $tolerance)) {
return false;
}
// Compute expected signature
$expectedSignature = $this->sign($payload, $secret, $timestamp);
// Use timing-safe comparison to prevent timing attacks
return hash_equals($expectedSignature, $signature);
}
/**
* Verify signature without timestamp validation.
*
* Use this method when you need to verify the signature but handle
* timestamp validation separately (e.g., for testing or special cases).
*
* @param string $payload The raw request body
* @param string $signature The signature from the header
* @param string $secret The webhook secret
* @param int $timestamp The timestamp from the header
* @return bool True if the signature is valid
*/
public function verifySignatureOnly(
string $payload,
string $signature,
string $secret,
int $timestamp
): bool {
$expectedSignature = $this->sign($payload, $secret, $timestamp);
return hash_equals($expectedSignature, $signature);
}
/**
* Check if a timestamp is within the allowed tolerance.
*
* @param int $timestamp The Unix timestamp to check
* @param int $tolerance Maximum age in seconds
* @return bool True if the timestamp is within tolerance
*/
public function isTimestampValid(int $timestamp, int $tolerance = self::DEFAULT_TOLERANCE): bool
{
$now = time();
return abs($now - $timestamp) <= $tolerance;
}
/**
* Build the signed payload string.
*
* Format: "{timestamp}.{payload}"
*
* @param int $timestamp Unix timestamp
* @param string $payload The JSON payload
* @return string The combined string to be signed
*/
private function buildSignedPayload(int $timestamp, string $payload): string
{
return $timestamp.'.'.$payload;
}
/**
* Get the headers to include with a webhook request.
*
* Returns an array of headers ready to be used with HTTP client:
* - X-Webhook-Signature: The HMAC signature
* - X-Webhook-Timestamp: Unix timestamp
*
* @param string $payload The JSON-encoded payload
* @param string $secret The signing secret
* @param int|null $timestamp Unix timestamp (defaults to current time)
* @return array<string, string|int> Headers array
*/
public function getHeaders(string $payload, string $secret, ?int $timestamp = null): array
{
$timestamp ??= time();
return [
'X-Webhook-Signature' => $this->sign($payload, $secret, $timestamp),
'X-Webhook-Timestamp' => $timestamp,
];
}
}

View file

@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Mod\Api\Database\Factories\ApiKeyFactory;
use Mod\Api\Models\ApiKey;
use Mod\Tenant\Models\User;
use Mod\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
});
// ─────────────────────────────────────────────────────────────────────────────
// Secure Hashing (bcrypt)
// ─────────────────────────────────────────────────────────────────────────────
describe('Secure Hashing', function () {
it('uses bcrypt for new API keys', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Secure Key'
);
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
expect($result['api_key']->key)->toStartWith('$2y$');
});
it('verifies bcrypt hashed keys correctly', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Verifiable Key'
);
$parts = explode('_', $result['plain_key'], 3);
$keyPart = $parts[2];
expect($result['api_key']->verifyKey($keyPart))->toBeTrue();
expect($result['api_key']->verifyKey('wrong-key'))->toBeFalse();
});
it('finds bcrypt keys by plain key', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Findable Bcrypt Key'
);
$found = ApiKey::findByPlainKey($result['plain_key']);
expect($found)->not->toBeNull();
expect($found->id)->toBe($result['api_key']->id);
});
it('bcrypt keys are not vulnerable to timing attacks', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Timing Safe Key'
);
$parts = explode('_', $result['plain_key'], 3);
$keyPart = $parts[2];
// bcrypt verification should take similar time for valid and invalid keys
// (this is a property test, not a precise timing test)
expect($result['api_key']->verifyKey($keyPart))->toBeTrue();
expect($result['api_key']->verifyKey('x'.$keyPart))->toBeFalse();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Legacy SHA-256 Backward Compatibility
// ─────────────────────────────────────────────────────────────────────────────
describe('Legacy SHA-256 Compatibility', function () {
it('identifies legacy hash keys', function () {
$result = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_SHA256);
expect($result['api_key']->usesLegacyHash())->toBeTrue();
});
it('verifies legacy SHA-256 keys correctly', function () {
$result = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
$parts = explode('_', $result['plain_key'], 3);
$keyPart = $parts[2];
expect($result['api_key']->verifyKey($keyPart))->toBeTrue();
expect($result['api_key']->verifyKey('wrong-key'))->toBeFalse();
});
it('finds legacy SHA-256 keys by plain key', function () {
$result = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
$found = ApiKey::findByPlainKey($result['plain_key']);
expect($found)->not->toBeNull();
expect($found->id)->toBe($result['api_key']->id);
});
it('treats null hash_algorithm as legacy', function () {
// Create a key without hash_algorithm (simulating pre-migration key)
$plainKey = Str::random(48);
$prefix = 'hk_'.Str::random(8);
$apiKey = ApiKey::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'name' => 'Pre-migration Key',
'key' => hash('sha256', $plainKey),
'hash_algorithm' => null, // Simulate pre-migration
'prefix' => $prefix,
'scopes' => [ApiKey::SCOPE_READ],
]);
expect($apiKey->usesLegacyHash())->toBeTrue();
// Should still be findable
$found = ApiKey::findByPlainKey("{$prefix}_{$plainKey}");
expect($found)->not->toBeNull();
expect($found->id)->toBe($apiKey->id);
});
it('can query for legacy hash keys', function () {
// Create a bcrypt key
ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Secure Key'
);
// Create a legacy key
ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
$legacyKeys = ApiKey::legacyHash()->get();
$secureKeys = ApiKey::secureHash()->get();
expect($legacyKeys)->toHaveCount(1);
expect($secureKeys)->toHaveCount(1);
expect($legacyKeys->first()->name)->toContain('API Key');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Key Rotation for Security Migration
// ─────────────────────────────────────────────────────────────────────────────
describe('Security Migration via Rotation', function () {
it('rotates legacy key to secure bcrypt key', function () {
$legacy = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
expect($legacy['api_key']->usesLegacyHash())->toBeTrue();
$rotated = $legacy['api_key']->rotate();
expect($rotated['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
expect($rotated['api_key']->usesLegacyHash())->toBeFalse();
expect($rotated['api_key']->key)->toStartWith('$2y$');
});
it('preserves settings when rotating legacy key', function () {
$legacy = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user,
[ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]
);
$legacy['api_key']->update(['server_scopes' => ['commerce', 'biohost']]);
$rotated = $legacy['api_key']->fresh()->rotate();
expect($rotated['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]);
expect($rotated['api_key']->server_scopes)->toBe(['commerce', 'biohost']);
expect($rotated['api_key']->workspace_id)->toBe($this->workspace->id);
});
it('legacy key remains valid during grace period after rotation', function () {
$legacy = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
$legacy['api_key']->rotate(24); // 24 hour grace period
// Old key should still work
$found = ApiKey::findByPlainKey($legacy['plain_key']);
expect($found)->not->toBeNull();
expect($found->isInGracePeriod())->toBeTrue();
});
it('tracks rotation lineage', function () {
$original = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
$rotated = $original['api_key']->rotate();
expect($rotated['api_key']->rotated_from_id)->toBe($original['api_key']->id);
expect($rotated['api_key']->rotatedFrom->id)->toBe($original['api_key']->id);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Grace Period Handling
// ─────────────────────────────────────────────────────────────────────────────
describe('Grace Period', function () {
it('sets grace period on rotation', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'To Be Rotated'
);
$result['api_key']->rotate(48);
$oldKey = $result['api_key']->fresh();
expect($oldKey->grace_period_ends_at)->not->toBeNull();
expect($oldKey->isInGracePeriod())->toBeTrue();
expect($oldKey->grace_period_ends_at->diffInHours(now()))->toBeLessThanOrEqual(48);
});
it('key becomes invalid after grace period expires', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Expiring Grace Key'
);
$result['api_key']->update([
'grace_period_ends_at' => now()->subHour(),
]);
$found = ApiKey::findByPlainKey($result['plain_key']);
expect($found)->toBeNull();
});
it('can end grace period early', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Early End Key'
);
$result['api_key']->rotate(24);
$oldKey = $result['api_key']->fresh();
expect($oldKey->isInGracePeriod())->toBeTrue();
$oldKey->endGracePeriod();
expect($oldKey->fresh()->trashed())->toBeTrue();
});
it('scopes keys in grace period correctly', function () {
// Key in grace period
$key1 = ApiKey::generate($this->workspace->id, $this->user->id, 'In Grace');
$key1['api_key']->update(['grace_period_ends_at' => now()->addHours(12)]);
// Key with expired grace period
$key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired Grace');
$key2['api_key']->update(['grace_period_ends_at' => now()->subHours(1)]);
// Normal key
ApiKey::generate($this->workspace->id, $this->user->id, 'Normal Key');
expect(ApiKey::inGracePeriod()->count())->toBe(1);
expect(ApiKey::gracePeriodExpired()->count())->toBe(1);
expect(ApiKey::active()->count())->toBe(2); // Normal + In Grace
});
it('detects grace period expired status', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Status Check Key'
);
// Not in grace period
expect($result['api_key']->isInGracePeriod())->toBeFalse();
expect($result['api_key']->isGracePeriodExpired())->toBeFalse();
// In grace period
$result['api_key']->update(['grace_period_ends_at' => now()->addHour()]);
expect($result['api_key']->fresh()->isInGracePeriod())->toBeTrue();
expect($result['api_key']->fresh()->isGracePeriodExpired())->toBeFalse();
// Grace period expired
$result['api_key']->update(['grace_period_ends_at' => now()->subHour()]);
expect($result['api_key']->fresh()->isInGracePeriod())->toBeFalse();
expect($result['api_key']->fresh()->isGracePeriodExpired())->toBeTrue();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Hash Algorithm Constants
// ─────────────────────────────────────────────────────────────────────────────
describe('Hash Algorithm Constants', function () {
it('defines correct hash algorithm constants', function () {
expect(ApiKey::HASH_SHA256)->toBe('sha256');
expect(ApiKey::HASH_BCRYPT)->toBe('bcrypt');
});
it('defines default grace period constant', function () {
expect(ApiKey::DEFAULT_GRACE_PERIOD_HOURS)->toBe(24);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Factory Legacy Support
// ─────────────────────────────────────────────────────────────────────────────
describe('Factory Legacy Support', function () {
it('creates legacy keys via static helper', function () {
$result = ApiKeyFactory::createLegacyKey(
$this->workspace,
$this->user
);
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_SHA256);
expect($result['api_key']->key)->not->toStartWith('$2y$');
// Should be a 64-char hex string (SHA-256)
expect(strlen($result['api_key']->key))->toBe(64);
});
it('creates keys in grace period via factory', function () {
$key = ApiKey::factory()
->for($this->workspace)
->for($this->user)
->inGracePeriod(6)
->create();
expect($key->isInGracePeriod())->toBeTrue();
expect($key->grace_period_ends_at->diffInHours(now()))->toBeLessThanOrEqual(6);
});
it('creates keys with expired grace period via factory', function () {
$key = ApiKey::factory()
->for($this->workspace)
->for($this->user)
->gracePeriodExpired()
->create();
expect($key->isGracePeriodExpired())->toBeTrue();
expect($key->isInGracePeriod())->toBeFalse();
});
});

View file

@ -81,7 +81,7 @@ describe('API Key Creation', function () {
expect($result['api_key']->expires_at->timestamp)->toBe($expiresAt->timestamp);
});
it('stores key as hashed value', function () {
it('stores key as bcrypt hashed value', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
@ -92,8 +92,23 @@ describe('API Key Creation', function () {
$parts = explode('_', $result['plain_key'], 3);
$keyPart = $parts[2];
// The stored key should be the SHA-256 hash
expect($result['api_key']->key)->toBe(hash('sha256', $keyPart));
// The stored key should be a bcrypt hash (starts with $2y$)
expect($result['api_key']->key)->toStartWith('$2y$');
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
// Verify the key matches using Hash::check
expect(\Illuminate\Support\Facades\Hash::check($keyPart, $result['api_key']->key))->toBeTrue();
});
it('sets hash_algorithm to bcrypt for new keys', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Bcrypt Key'
);
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
expect($result['api_key']->usesLegacyHash())->toBeFalse();
});
});

View file

@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
use Mod\Api\Models\ApiKey;
use Mod\Tenant\Models\User;
use Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Register test routes with scope enforcement
Route::middleware(['api', 'api.auth', 'api.scope.enforce'])
->prefix('test-scope')
->group(function () {
Route::get('/read', fn () => response()->json(['status' => 'ok']));
Route::post('/write', fn () => response()->json(['status' => 'ok']));
Route::put('/update', fn () => response()->json(['status' => 'ok']));
Route::patch('/patch', fn () => response()->json(['status' => 'ok']));
Route::delete('/delete', fn () => response()->json(['status' => 'ok']));
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Read Scope Enforcement
// ─────────────────────────────────────────────────────────────────────────────
describe('Read Scope Enforcement', function () {
it('allows GET request with read scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read Only Key',
[ApiKey::SCOPE_READ]
);
$response = $this->getJson('/api/test-scope/read', [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(200);
expect($response->json('status'))->toBe('ok');
});
it('denies POST request with read-only scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read Only Key',
[ApiKey::SCOPE_READ]
);
$response = $this->postJson('/api/test-scope/write', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(403);
expect($response->json('error'))->toBe('forbidden');
expect($response->json('message'))->toContain('write');
});
it('denies DELETE request with read-only scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read Only Key',
[ApiKey::SCOPE_READ]
);
$response = $this->deleteJson('/api/test-scope/delete', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(403);
expect($response->json('error'))->toBe('forbidden');
expect($response->json('message'))->toContain('delete');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Write Scope Enforcement
// ─────────────────────────────────────────────────────────────────────────────
describe('Write Scope Enforcement', function () {
it('allows POST request with write scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read/Write Key',
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
$response = $this->postJson('/api/test-scope/write', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(200);
});
it('allows PUT request with write scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read/Write Key',
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
$response = $this->putJson('/api/test-scope/update', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(200);
});
it('allows PATCH request with write scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read/Write Key',
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
$response = $this->patchJson('/api/test-scope/patch', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(200);
});
it('denies DELETE request without delete scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read/Write Key',
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
$response = $this->deleteJson('/api/test-scope/delete', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(403);
expect($response->json('message'))->toContain('delete');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete Scope Enforcement
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete Scope Enforcement', function () {
it('allows DELETE request with delete scope', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Full Access Key',
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE]
);
$response = $this->deleteJson('/api/test-scope/delete', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(200);
});
it('includes key scopes in error response', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Read Only Key',
[ApiKey::SCOPE_READ]
);
$response = $this->deleteJson('/api/test-scope/delete', [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
expect($response->status())->toBe(403);
expect($response->json('key_scopes'))->toBe([ApiKey::SCOPE_READ]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Full Access Keys
// ─────────────────────────────────────────────────────────────────────────────
describe('Full Access Keys', function () {
it('allows all operations with full access', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Full Access Key',
ApiKey::ALL_SCOPES
);
$headers = ['Authorization' => "Bearer {$result['plain_key']}"];
expect($this->getJson('/api/test-scope/read', $headers)->status())->toBe(200);
expect($this->postJson('/api/test-scope/write', [], $headers)->status())->toBe(200);
expect($this->putJson('/api/test-scope/update', [], $headers)->status())->toBe(200);
expect($this->patchJson('/api/test-scope/patch', [], $headers)->status())->toBe(200);
expect($this->deleteJson('/api/test-scope/delete', [], $headers)->status())->toBe(200);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Non-API Key Auth (Session)
// ─────────────────────────────────────────────────────────────────────────────
describe('Non-API Key Auth', function () {
it('passes through for session authenticated users', function () {
// For session auth, the middleware should allow through
// as scope enforcement only applies to API key auth
$this->actingAs($this->user);
// The api.auth middleware will require API key, so this tests
// that if somehow session auth is used, scope middleware allows it
// In practice, routes use either 'auth' OR 'api.auth', not both
});
});

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Tests\Feature;
use Core\Mod\Api\Documentation\Attributes\ApiHidden;
use Core\Mod\Api\Documentation\Attributes\ApiParameter;
use Core\Mod\Api\Documentation\Attributes\ApiResponse;
use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
use Core\Mod\Api\Documentation\Attributes\ApiTag;
use Core\Mod\Api\Documentation\Extension;
use Core\Mod\Api\Documentation\Extensions\ApiKeyAuthExtension;
use Core\Mod\Api\Documentation\Extensions\RateLimitExtension;
use Core\Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension;
use Core\Mod\Api\Documentation\OpenApiBuilder;
use Orchestra\Testbench\TestCase;
/**
* Test OpenAPI documentation generation.
*/
class OpenApiDocumentationTest extends TestCase
{
public function test_openapi_builder_can_be_instantiated(): void
{
$builder = new OpenApiBuilder;
$this->assertInstanceOf(OpenApiBuilder::class, $builder);
}
public function test_extensions_implement_interface(): void
{
$this->assertInstanceOf(Extension::class, new WorkspaceHeaderExtension);
$this->assertInstanceOf(Extension::class, new RateLimitExtension);
$this->assertInstanceOf(Extension::class, new ApiKeyAuthExtension);
}
public function test_api_tag_attribute(): void
{
$tag = new ApiTag('Users', 'User management');
$this->assertEquals('Users', $tag->name);
$this->assertEquals('User management', $tag->description);
}
public function test_api_response_attribute(): void
{
$response = new ApiResponse(200, null, 'Success');
$this->assertEquals(200, $response->status);
$this->assertEquals('Success', $response->getDescription());
$this->assertFalse($response->paginated);
}
public function test_api_response_generates_description_from_status(): void
{
$response = new ApiResponse(404);
$this->assertEquals('Not found', $response->getDescription());
}
public function test_api_security_attribute(): void
{
$security = new ApiSecurity('apiKey', ['read', 'write']);
$this->assertEquals('apiKey', $security->scheme);
$this->assertEquals(['read', 'write'], $security->scopes);
$this->assertFalse($security->isPublic());
}
public function test_api_security_public(): void
{
$security = new ApiSecurity(null);
$this->assertTrue($security->isPublic());
}
public function test_api_parameter_attribute(): void
{
$param = new ApiParameter(
name: 'page',
in: 'query',
type: 'integer',
description: 'Page number',
required: false,
example: 1
);
$this->assertEquals('page', $param->name);
$this->assertEquals('query', $param->in);
$this->assertEquals('integer', $param->type);
$this->assertEquals(1, $param->example);
}
public function test_api_parameter_to_openapi(): void
{
$param = new ApiParameter(
name: 'page',
in: 'query',
type: 'integer',
description: 'Page number',
required: false,
example: 1
);
$openApi = $param->toOpenApi();
$this->assertEquals('page', $openApi['name']);
$this->assertEquals('query', $openApi['in']);
$this->assertFalse($openApi['required']);
$this->assertEquals('integer', $openApi['schema']['type']);
}
public function test_api_hidden_attribute(): void
{
$hidden = new ApiHidden('Internal only');
$this->assertEquals('Internal only', $hidden->reason);
}
}

View file

@ -0,0 +1,532 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Api\Tests\Feature;
use Carbon\Carbon;
use Core\LifecycleEventProvider;
use Core\Mod\Api\Exceptions\RateLimitExceededException;
use Core\Mod\Api\RateLimit\RateLimit;
use Core\Mod\Api\RateLimit\RateLimitResult;
use Core\Mod\Api\RateLimit\RateLimitService;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Orchestra\Testbench\TestCase;
/**
* Rate Limiting Tests
*
* Tests for the rate limiting service, result DTO, attribute, exception,
* and configuration.
*/
class RateLimitTest extends TestCase
{
use RefreshDatabase;
protected RateLimitService $rateLimitService;
protected function getPackageProviders($app): array
{
return [
LifecycleEventProvider::class,
];
}
protected function setUp(): void
{
parent::setUp();
Cache::flush();
Carbon::setTestNow(Carbon::now());
$this->rateLimitService = new RateLimitService($this->app->make(CacheRepository::class));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
// ─────────────────────────────────────────────────────────────────────────
// RateLimitResult DTO Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_rate_limit_result_creates_allowed_result(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::allowed(100, 99, $resetsAt);
$this->assertTrue($result->allowed);
$this->assertSame(100, $result->limit);
$this->assertSame(99, $result->remaining);
$this->assertSame(0, $result->retryAfter);
$this->assertSame($resetsAt->timestamp, $result->resetsAt->timestamp);
}
public function test_rate_limit_result_creates_denied_result(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::denied(100, 30, $resetsAt);
$this->assertFalse($result->allowed);
$this->assertSame(100, $result->limit);
$this->assertSame(0, $result->remaining);
$this->assertSame(30, $result->retryAfter);
$this->assertSame($resetsAt->timestamp, $result->resetsAt->timestamp);
}
public function test_rate_limit_result_generates_correct_headers_for_allowed(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::allowed(100, 99, $resetsAt);
$headers = $result->headers();
$this->assertArrayHasKey('X-RateLimit-Limit', $headers);
$this->assertArrayHasKey('X-RateLimit-Remaining', $headers);
$this->assertArrayHasKey('X-RateLimit-Reset', $headers);
$this->assertSame(100, $headers['X-RateLimit-Limit']);
$this->assertSame(99, $headers['X-RateLimit-Remaining']);
$this->assertSame($resetsAt->timestamp, $headers['X-RateLimit-Reset']);
$this->assertArrayNotHasKey('Retry-After', $headers);
}
public function test_rate_limit_result_generates_correct_headers_for_denied(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::denied(100, 30, $resetsAt);
$headers = $result->headers();
$this->assertArrayHasKey('X-RateLimit-Limit', $headers);
$this->assertArrayHasKey('X-RateLimit-Remaining', $headers);
$this->assertArrayHasKey('X-RateLimit-Reset', $headers);
$this->assertArrayHasKey('Retry-After', $headers);
$this->assertSame(100, $headers['X-RateLimit-Limit']);
$this->assertSame(0, $headers['X-RateLimit-Remaining']);
$this->assertSame(30, $headers['Retry-After']);
}
// ─────────────────────────────────────────────────────────────────────────
// RateLimitService - Basic Rate Limiting Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_service_allows_requests_under_the_limit(): void
{
$result = $this->rateLimitService->hit('test-key', 10, 60);
$this->assertTrue($result->allowed);
$this->assertSame(9, $result->remaining);
$this->assertSame(10, $result->limit);
}
public function test_service_tracks_requests_correctly(): void
{
// Make 5 requests
for ($i = 0; $i < 5; $i++) {
$result = $this->rateLimitService->hit('test-key', 10, 60);
}
$this->assertTrue($result->allowed);
$this->assertSame(5, $result->remaining);
}
public function test_service_blocks_requests_when_limit_exceeded(): void
{
// Make 10 requests (at limit)
for ($i = 0; $i < 10; $i++) {
$this->rateLimitService->hit('test-key', 10, 60);
}
// 11th request should be blocked
$result = $this->rateLimitService->hit('test-key', 10, 60);
$this->assertFalse($result->allowed);
$this->assertSame(0, $result->remaining);
$this->assertGreaterThan(0, $result->retryAfter);
}
public function test_check_method_does_not_increment_counter(): void
{
// Hit once
$this->rateLimitService->hit('test-key', 10, 60);
// Check multiple times (should not count)
$this->rateLimitService->check('test-key', 10, 60);
$this->rateLimitService->check('test-key', 10, 60);
$this->rateLimitService->check('test-key', 10, 60);
// Verify only 1 hit was recorded
$this->assertSame(9, $this->rateLimitService->remaining('test-key', 10, 60));
}
public function test_service_resets_correctly(): void
{
// Make some requests
for ($i = 0; $i < 5; $i++) {
$this->rateLimitService->hit('test-key', 10, 60);
}
$this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60));
// Reset
$this->rateLimitService->reset('test-key');
$this->assertSame(10, $this->rateLimitService->remaining('test-key', 10, 60));
}
public function test_service_returns_correct_attempts_count(): void
{
$this->assertSame(0, $this->rateLimitService->attempts('test-key', 60));
$this->rateLimitService->hit('test-key', 10, 60);
$this->rateLimitService->hit('test-key', 10, 60);
$this->rateLimitService->hit('test-key', 10, 60);
$this->assertSame(3, $this->rateLimitService->attempts('test-key', 60));
}
// ─────────────────────────────────────────────────────────────────────────
// RateLimitService - Sliding Window Algorithm Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_sliding_window_expires_old_requests(): void
{
// Make 5 requests now
for ($i = 0; $i < 5; $i++) {
$this->rateLimitService->hit('test-key', 10, 60);
}
$this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60));
// Move time forward 61 seconds (past the window)
Carbon::setTestNow(Carbon::now()->addSeconds(61));
// Old requests should have expired
$this->assertSame(10, $this->rateLimitService->remaining('test-key', 10, 60));
}
public function test_sliding_window_maintains_requests_within_window(): void
{
// Make 5 requests now
for ($i = 0; $i < 5; $i++) {
$this->rateLimitService->hit('test-key', 10, 60);
}
// Move time forward 30 seconds (still within window)
Carbon::setTestNow(Carbon::now()->addSeconds(30));
// Requests should still count
$this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60));
// Make 3 more requests
for ($i = 0; $i < 3; $i++) {
$this->rateLimitService->hit('test-key', 10, 60);
}
$this->assertSame(2, $this->rateLimitService->remaining('test-key', 10, 60));
}
// ─────────────────────────────────────────────────────────────────────────
// RateLimitService - Burst Allowance Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_burst_allows_when_configured(): void
{
// With 20% burst, limit of 10 becomes effective limit of 12
for ($i = 0; $i < 12; $i++) {
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.2);
$this->assertTrue($result->allowed);
}
// 13th request should be blocked
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.2);
$this->assertFalse($result->allowed);
}
public function test_burst_reports_base_limit_not_burst_limit(): void
{
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.5);
// Limit shown should be the base limit (10), not the burst limit (15)
$this->assertSame(10, $result->limit);
}
public function test_burst_calculates_remaining_based_on_burst_limit(): void
{
// With 50% burst, limit of 10 becomes effective limit of 15
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.5);
// After 1 hit, remaining should be 14 (15 - 1)
$this->assertSame(14, $result->remaining);
}
public function test_burst_works_without_burst(): void
{
for ($i = 0; $i < 10; $i++) {
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.0);
$this->assertTrue($result->allowed);
}
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.0);
$this->assertFalse($result->allowed);
}
// ─────────────────────────────────────────────────────────────────────────
// RateLimitService - Key Builders Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_builds_endpoint_keys_correctly(): void
{
$key = $this->rateLimitService->buildEndpointKey('api_key:123', 'users.index');
$this->assertSame('endpoint:api_key:123:users.index', $key);
}
public function test_builds_workspace_keys_correctly(): void
{
$key = $this->rateLimitService->buildWorkspaceKey(456);
$this->assertSame('workspace:456', $key);
$keyWithSuffix = $this->rateLimitService->buildWorkspaceKey(456, 'users.index');
$this->assertSame('workspace:456:users.index', $keyWithSuffix);
}
public function test_builds_api_key_keys_correctly(): void
{
$key = $this->rateLimitService->buildApiKeyKey(789);
$this->assertSame('api_key:789', $key);
$keyWithSuffix = $this->rateLimitService->buildApiKeyKey(789, 'users.index');
$this->assertSame('api_key:789:users.index', $keyWithSuffix);
}
public function test_builds_ip_keys_correctly(): void
{
$key = $this->rateLimitService->buildIpKey('192.168.1.1');
$this->assertSame('ip:192.168.1.1', $key);
$keyWithSuffix = $this->rateLimitService->buildIpKey('192.168.1.1', 'users.index');
$this->assertSame('ip:192.168.1.1:users.index', $keyWithSuffix);
}
// ─────────────────────────────────────────────────────────────────────────
// RateLimit Attribute Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_attribute_instantiates_with_required_parameters(): void
{
$attribute = new RateLimit(limit: 100);
$this->assertSame(100, $attribute->limit);
$this->assertSame(60, $attribute->window); // default
$this->assertSame(1.0, $attribute->burst); // default
$this->assertNull($attribute->key); // default
}
public function test_attribute_instantiates_with_all_parameters(): void
{
$attribute = new RateLimit(
limit: 200,
window: 120,
burst: 1.5,
key: 'custom-key'
);
$this->assertSame(200, $attribute->limit);
$this->assertSame(120, $attribute->window);
$this->assertSame(1.5, $attribute->burst);
$this->assertSame('custom-key', $attribute->key);
}
// ─────────────────────────────────────────────────────────────────────────
// RateLimitExceededException Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_exception_creates_with_rate_limit_result(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::denied(100, 30, $resetsAt);
$exception = new RateLimitExceededException($result);
$this->assertSame(429, $exception->getStatusCode());
$this->assertSame($result, $exception->getRateLimitResult());
}
public function test_exception_renders_as_json_response(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::denied(100, 30, $resetsAt);
$exception = new RateLimitExceededException($result);
$response = $exception->render();
$this->assertSame(429, $response->getStatusCode());
$content = json_decode($response->getContent(), true);
$this->assertSame('rate_limit_exceeded', $content['error']);
$this->assertSame(30, $content['retry_after']);
$this->assertSame(100, $content['limit']);
}
public function test_exception_includes_rate_limit_headers_in_response(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::denied(100, 30, $resetsAt);
$exception = new RateLimitExceededException($result);
$response = $exception->render();
$this->assertSame('100', $response->headers->get('X-RateLimit-Limit'));
$this->assertSame('0', $response->headers->get('X-RateLimit-Remaining'));
$this->assertSame('30', $response->headers->get('Retry-After'));
}
public function test_exception_allows_custom_message(): void
{
$resetsAt = Carbon::now()->addMinute();
$result = RateLimitResult::denied(100, 30, $resetsAt);
$exception = new RateLimitExceededException($result, 'Custom rate limit message');
$response = $exception->render();
$content = json_decode($response->getContent(), true);
$this->assertSame('Custom rate limit message', $content['message']);
}
// ─────────────────────────────────────────────────────────────────────────
// Per-Workspace Rate Limiting Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_isolates_rate_limits_by_workspace(): void
{
// Create two different workspace keys
$key1 = $this->rateLimitService->buildWorkspaceKey(1, 'endpoint');
$key2 = $this->rateLimitService->buildWorkspaceKey(2, 'endpoint');
// Hit rate limit for workspace 1
for ($i = 0; $i < 10; $i++) {
$this->rateLimitService->hit($key1, 10, 60);
}
// Workspace 1 should be blocked
$result1 = $this->rateLimitService->hit($key1, 10, 60);
$this->assertFalse($result1->allowed);
// Workspace 2 should still be allowed
$result2 = $this->rateLimitService->hit($key2, 10, 60);
$this->assertTrue($result2->allowed);
}
// ─────────────────────────────────────────────────────────────────────────
// Rate Limit Configuration Tests
// ─────────────────────────────────────────────────────────────────────────
public function test_config_has_enabled_flag(): void
{
Config::set('api.rate_limits.enabled', true);
$this->assertTrue(config('api.rate_limits.enabled'));
}
public function test_config_has_default_limits(): void
{
Config::set('api.rate_limits.default', [
'limit' => 60,
'window' => 60,
'burst' => 1.0,
]);
$default = config('api.rate_limits.default');
$this->assertArrayHasKey('limit', $default);
$this->assertArrayHasKey('window', $default);
$this->assertArrayHasKey('burst', $default);
}
public function test_config_has_authenticated_limits(): void
{
Config::set('api.rate_limits.authenticated', [
'limit' => 1000,
'window' => 60,
'burst' => 1.2,
]);
$authenticated = config('api.rate_limits.authenticated');
$this->assertArrayHasKey('limit', $authenticated);
$this->assertSame(1000, $authenticated['limit']);
}
public function test_config_has_per_workspace_flag(): void
{
Config::set('api.rate_limits.per_workspace', true);
$this->assertTrue(config('api.rate_limits.per_workspace'));
}
public function test_config_has_endpoints_configuration(): void
{
Config::set('api.rate_limits.endpoints', []);
$this->assertIsArray(config('api.rate_limits.endpoints'));
}
public function test_config_has_tier_based_limits(): void
{
Config::set('api.rate_limits.tiers', [
'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0],
'starter' => ['limit' => 1000, 'window' => 60, 'burst' => 1.2],
'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3],
'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5],
'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0],
]);
$tiers = config('api.rate_limits.tiers');
$this->assertArrayHasKey('free', $tiers);
$this->assertArrayHasKey('starter', $tiers);
$this->assertArrayHasKey('pro', $tiers);
$this->assertArrayHasKey('agency', $tiers);
$this->assertArrayHasKey('enterprise', $tiers);
foreach ($tiers as $tier => $tierConfig) {
$this->assertArrayHasKey('limit', $tierConfig);
$this->assertArrayHasKey('window', $tierConfig);
$this->assertArrayHasKey('burst', $tierConfig);
}
}
public function test_tier_limits_increase_with_tier_level(): void
{
Config::set('api.rate_limits.tiers', [
'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0],
'starter' => ['limit' => 1000, 'window' => 60, 'burst' => 1.2],
'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3],
'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5],
'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0],
]);
$tiers = config('api.rate_limits.tiers');
$this->assertGreaterThan($tiers['free']['limit'], $tiers['starter']['limit']);
$this->assertGreaterThan($tiers['starter']['limit'], $tiers['pro']['limit']);
$this->assertGreaterThan($tiers['pro']['limit'], $tiers['agency']['limit']);
$this->assertGreaterThan($tiers['agency']['limit'], $tiers['enterprise']['limit']);
}
public function test_higher_tiers_have_higher_burst_allowance(): void
{
Config::set('api.rate_limits.tiers', [
'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0],
'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3],
'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5],
'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0],
]);
$tiers = config('api.rate_limits.tiers');
$this->assertGreaterThanOrEqual($tiers['pro']['burst'], $tiers['agency']['burst']);
$this->assertGreaterThanOrEqual($tiers['agency']['burst'], $tiers['enterprise']['burst']);
}
}

View file

@ -2,12 +2,13 @@
declare(strict_types=1);
use Core\Mod\Api\Jobs\DeliverWebhookJob;
use Core\Mod\Api\Models\WebhookDelivery;
use Core\Mod\Api\Models\WebhookEndpoint;
use Core\Mod\Api\Services\WebhookService;
use Core\Mod\Api\Services\WebhookSignature;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Mod\Api\Jobs\DeliverWebhookJob;
use Mod\Api\Models\WebhookDelivery;
use Mod\Api\Models\WebhookEndpoint;
use Mod\Api\Services\WebhookService;
use Mod\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -16,11 +17,323 @@ beforeEach(function () {
$this->workspace = Workspace::factory()->create();
$this->service = app(WebhookService::class);
$this->signatureService = app(WebhookSignature::class);
});
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Webhook Signature Service
// -----------------------------------------------------------------------------
describe('Webhook Signature Service', function () {
it('generates a 64-character secret', function () {
$secret = $this->signatureService->generateSecret();
expect($secret)->toBeString();
expect(strlen($secret))->toBe(64);
});
it('generates unique secrets', function () {
$secrets = [];
for ($i = 0; $i < 100; $i++) {
$secrets[] = $this->signatureService->generateSecret();
}
expect(array_unique($secrets))->toHaveCount(100);
});
it('signs payload with timestamp', function () {
$payload = '{"event":"test"}';
$secret = 'test_secret_key';
$timestamp = 1704067200; // Fixed timestamp for testing
$signature = $this->signatureService->sign($payload, $secret, $timestamp);
// Verify it's a 64-character hex string (SHA256)
expect($signature)->toBeString();
expect(strlen($signature))->toBe(64);
expect(ctype_xdigit($signature))->toBeTrue();
// Verify signature is deterministic
$signature2 = $this->signatureService->sign($payload, $secret, $timestamp);
expect($signature)->toBe($signature2);
});
it('produces different signatures for different payloads', function () {
$secret = 'test_secret_key';
$timestamp = 1704067200;
$sig1 = $this->signatureService->sign('{"a":1}', $secret, $timestamp);
$sig2 = $this->signatureService->sign('{"a":2}', $secret, $timestamp);
expect($sig1)->not->toBe($sig2);
});
it('produces different signatures for different timestamps', function () {
$payload = '{"event":"test"}';
$secret = 'test_secret_key';
$sig1 = $this->signatureService->sign($payload, $secret, 1704067200);
$sig2 = $this->signatureService->sign($payload, $secret, 1704067201);
expect($sig1)->not->toBe($sig2);
});
it('produces different signatures for different secrets', function () {
$payload = '{"event":"test"}';
$timestamp = 1704067200;
$sig1 = $this->signatureService->sign($payload, 'secret1', $timestamp);
$sig2 = $this->signatureService->sign($payload, 'secret2', $timestamp);
expect($sig1)->not->toBe($sig2);
});
it('verifies valid signature', function () {
$payload = '{"event":"test","data":{"id":123}}';
$secret = 'webhook_secret_abc123';
$timestamp = time();
$signature = $this->signatureService->sign($payload, $secret, $timestamp);
$isValid = $this->signatureService->verify(
$payload,
$signature,
$secret,
$timestamp
);
expect($isValid)->toBeTrue();
});
it('rejects invalid signature', function () {
$payload = '{"event":"test"}';
$secret = 'webhook_secret_abc123';
$timestamp = time();
$isValid = $this->signatureService->verify(
$payload,
'invalid_signature_abc123',
$secret,
$timestamp
);
expect($isValid)->toBeFalse();
});
it('rejects tampered payload', function () {
$secret = 'webhook_secret_abc123';
$timestamp = time();
// Sign original payload
$signature = $this->signatureService->sign('{"event":"test"}', $secret, $timestamp);
// Verify with tampered payload
$isValid = $this->signatureService->verify(
'{"event":"test","hacked":true}',
$signature,
$secret,
$timestamp
);
expect($isValid)->toBeFalse();
});
it('rejects tampered timestamp', function () {
$payload = '{"event":"test"}';
$secret = 'webhook_secret_abc123';
$originalTimestamp = time();
// Sign with original timestamp
$signature = $this->signatureService->sign($payload, $secret, $originalTimestamp);
// Verify with different timestamp (simulating replay attack)
$isValid = $this->signatureService->verifySignatureOnly(
$payload,
$signature,
$secret,
$originalTimestamp + 1
);
expect($isValid)->toBeFalse();
});
it('rejects expired timestamp', function () {
$payload = '{"event":"test"}';
$secret = 'webhook_secret_abc123';
$oldTimestamp = time() - 600; // 10 minutes ago
$signature = $this->signatureService->sign($payload, $secret, $oldTimestamp);
// Default tolerance is 5 minutes
$isValid = $this->signatureService->verify(
$payload,
$signature,
$secret,
$oldTimestamp
);
expect($isValid)->toBeFalse();
});
it('accepts timestamp within tolerance', function () {
$payload = '{"event":"test"}';
$secret = 'webhook_secret_abc123';
$recentTimestamp = time() - 60; // 1 minute ago
$signature = $this->signatureService->sign($payload, $secret, $recentTimestamp);
$isValid = $this->signatureService->verify(
$payload,
$signature,
$secret,
$recentTimestamp
);
expect($isValid)->toBeTrue();
});
it('allows custom tolerance', function () {
$payload = '{"event":"test"}';
$secret = 'webhook_secret_abc123';
$oldTimestamp = time() - 600; // 10 minutes ago
$signature = $this->signatureService->sign($payload, $secret, $oldTimestamp);
// Verify with 15-minute tolerance
$isValid = $this->signatureService->verify(
$payload,
$signature,
$secret,
$oldTimestamp,
tolerance: 900
);
expect($isValid)->toBeTrue();
});
it('checks timestamp validity correctly', function () {
$now = time();
// Within tolerance
expect($this->signatureService->isTimestampValid($now))->toBeTrue();
expect($this->signatureService->isTimestampValid($now - 60))->toBeTrue();
expect($this->signatureService->isTimestampValid($now - 299))->toBeTrue();
// Outside tolerance
expect($this->signatureService->isTimestampValid($now - 301))->toBeFalse();
expect($this->signatureService->isTimestampValid($now - 600))->toBeFalse();
// Future timestamp within tolerance
expect($this->signatureService->isTimestampValid($now + 60))->toBeTrue();
// Future timestamp outside tolerance
expect($this->signatureService->isTimestampValid($now + 400))->toBeFalse();
});
it('returns correct headers', function () {
$payload = '{"event":"test"}';
$secret = 'webhook_secret_abc123';
$timestamp = 1704067200;
$headers = $this->signatureService->getHeaders($payload, $secret, $timestamp);
expect($headers)->toHaveKey('X-Webhook-Signature');
expect($headers)->toHaveKey('X-Webhook-Timestamp');
expect($headers['X-Webhook-Timestamp'])->toBe($timestamp);
expect($headers['X-Webhook-Signature'])->toBe(
$this->signatureService->sign($payload, $secret, $timestamp)
);
});
});
// -----------------------------------------------------------------------------
// Webhook Endpoint Signing
// -----------------------------------------------------------------------------
describe('Webhook Endpoint Signing', function () {
it('generates signature for payload with timestamp', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$payload = '{"event":"test"}';
$timestamp = time();
$signature = $endpoint->generateSignature($payload, $timestamp);
expect($signature)->toBeString();
expect(strlen($signature))->toBe(64);
});
it('verifies valid signature', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$payload = '{"event":"test","data":{"id":123}}';
$timestamp = time();
$signature = $endpoint->generateSignature($payload, $timestamp);
$isValid = $endpoint->verifySignature($payload, $signature, $timestamp);
expect($isValid)->toBeTrue();
});
it('rejects invalid signature', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$isValid = $endpoint->verifySignature(
'{"event":"test"}',
'invalid_signature',
time()
);
expect($isValid)->toBeFalse();
});
it('rotates secret and invalidates old signatures', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$payload = '{"event":"test"}';
$timestamp = time();
// Sign with original secret
$originalSignature = $endpoint->generateSignature($payload, $timestamp);
// Rotate secret
$newSecret = $endpoint->rotateSecret();
$endpoint->refresh();
// Old signature should be invalid
$isValid = $endpoint->verifySignature($payload, $originalSignature, $timestamp);
expect($isValid)->toBeFalse();
// New signature should be valid
$newSignature = $endpoint->generateSignature($payload, $timestamp);
$isValid = $endpoint->verifySignature($payload, $newSignature, $timestamp);
expect($isValid)->toBeTrue();
// New secret should be 64 characters
expect(strlen($newSecret))->toBe(64);
});
});
// -----------------------------------------------------------------------------
// Webhook Service
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
describe('Webhook Service', function () {
it('dispatches event to subscribed endpoints', function () {
@ -131,9 +444,9 @@ describe('Webhook Service', function () {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Webhook Delivery Job
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
describe('Webhook Delivery Job', function () {
it('marks delivery as success on 2xx response', function () {
@ -214,12 +527,23 @@ describe('Webhook Delivery Job', function () {
expect($delivery->status)->toBe(WebhookDelivery::STATUS_FAILED);
});
it('includes correct signature header', function () {
it('includes correct signature and timestamp headers', function () {
Http::fake(function ($request) {
// Verify signature header exists
expect($request->hasHeader('X-HostHub-Signature'))->toBeTrue();
expect($request->hasHeader('X-HostHub-Event'))->toBeTrue();
expect($request->hasHeader('X-HostHub-Delivery'))->toBeTrue();
// Verify all required headers exist
expect($request->hasHeader('X-Webhook-Signature'))->toBeTrue();
expect($request->hasHeader('X-Webhook-Timestamp'))->toBeTrue();
expect($request->hasHeader('X-Webhook-Event'))->toBeTrue();
expect($request->hasHeader('X-Webhook-Id'))->toBeTrue();
// Verify timestamp is a valid Unix timestamp
$timestamp = $request->header('X-Webhook-Timestamp')[0];
expect(is_numeric($timestamp))->toBeTrue();
expect((int) $timestamp)->toBeGreaterThan(0);
// Verify signature is a 64-character hex string
$signature = $request->header('X-Webhook-Signature')[0];
expect(strlen($signature))->toBe(64);
expect(ctype_xdigit($signature))->toBeTrue();
return Http::response(['ok' => true], 200);
});
@ -244,6 +568,39 @@ describe('Webhook Delivery Job', function () {
});
});
it('sends verifiable signature', function () {
$capturedRequest = null;
Http::fake(function ($request) use (&$capturedRequest) {
$capturedRequest = $request;
return Http::response(['ok' => true], 200);
});
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$delivery = WebhookDelivery::createForEvent(
$endpoint,
'bio.created',
['bio_id' => 123]
);
$job = new DeliverWebhookJob($delivery);
$job->handle();
// Verify the signature can be verified by a recipient
$body = $capturedRequest->body();
$signature = $capturedRequest->header('X-Webhook-Signature')[0];
$timestamp = (int) $capturedRequest->header('X-Webhook-Timestamp')[0];
$isValid = $endpoint->verifySignature($body, $signature, $timestamp);
expect($isValid)->toBeTrue();
});
it('skips delivery if endpoint becomes inactive', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
@ -272,9 +629,9 @@ describe('Webhook Delivery Job', function () {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Webhook Endpoint Auto-Disable
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
describe('Webhook Endpoint Auto-Disable', function () {
it('disables endpoint after consecutive failures', function () {
@ -338,3 +695,76 @@ describe('Webhook Endpoint Auto-Disable', function () {
expect($endpoint->failure_count)->toBe(0);
});
});
// -----------------------------------------------------------------------------
// Delivery Payload Headers
// -----------------------------------------------------------------------------
describe('Delivery Payload Headers', function () {
it('includes all required headers', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$delivery = WebhookDelivery::createForEvent(
$endpoint,
'bio.created',
['bio_id' => 123]
);
$payload = $delivery->getDeliveryPayload();
expect($payload)->toHaveKey('headers');
expect($payload)->toHaveKey('body');
expect($payload['headers'])->toHaveKey('Content-Type');
expect($payload['headers'])->toHaveKey('X-Webhook-Id');
expect($payload['headers'])->toHaveKey('X-Webhook-Event');
expect($payload['headers'])->toHaveKey('X-Webhook-Timestamp');
expect($payload['headers'])->toHaveKey('X-Webhook-Signature');
});
it('uses provided timestamp', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$delivery = WebhookDelivery::createForEvent(
$endpoint,
'bio.created',
['bio_id' => 123]
);
$fixedTimestamp = 1704067200;
$payload = $delivery->getDeliveryPayload($fixedTimestamp);
expect($payload['headers']['X-Webhook-Timestamp'])->toBe((string) $fixedTimestamp);
});
it('generates valid signature in payload', function () {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhook',
['bio.created']
);
$delivery = WebhookDelivery::createForEvent(
$endpoint,
'bio.created',
['bio_id' => 123]
);
$payload = $delivery->getDeliveryPayload();
$timestamp = (int) $payload['headers']['X-Webhook-Timestamp'];
$signature = $payload['headers']['X-Webhook-Signature'];
$body = $payload['body'];
// Verify the signature is valid
$isValid = $endpoint->verifySignature($body, $signature, $timestamp);
expect($isValid)->toBeTrue();
});
});

View file

@ -26,24 +26,93 @@ return [
|--------------------------------------------------------------------------
|
| Configure rate limits for API requests.
| Limits can be tier-based when integrated with entitlements.
|
| Features:
| - Per-endpoint limits via 'endpoints' config or #[RateLimit] attribute
| - Per-workspace limits (when 'per_workspace' is true)
| - Tier-based limits based on workspace subscription
| - Burst allowance for temporary traffic spikes
| - Sliding window algorithm for smoother rate limiting
|
| Priority (highest to lowest):
| 1. Method-level #[RateLimit] attribute
| 2. Class-level #[RateLimit] attribute
| 3. Per-endpoint config (endpoints.{route_name})
| 4. Tier-based limits (tiers.{tier})
| 5. Authenticated limits
| 6. Default limits
|
*/
'rate_limits' => [
// Enable/disable rate limiting globally
'enabled' => env('API_RATE_LIMITING_ENABLED', true),
// Unauthenticated requests (by IP)
'default' => [
'limit' => 60,
'window' => 60, // seconds
'burst' => 1.0, // no burst allowance for unauthenticated
// Legacy support
'requests' => 60,
'per_minutes' => 1,
],
// Authenticated requests (by user/key)
'authenticated' => [
'limit' => 1000,
'window' => 60, // seconds
'burst' => 1.2, // 20% burst allowance
// Legacy support
'requests' => 1000,
'per_minutes' => 1,
],
// Tier-based limits (integrate with EntitlementService)
// Enable per-workspace rate limiting (isolates limits by workspace)
'per_workspace' => true,
// Per-endpoint rate limits (route names)
// Example: 'users.index' => ['limit' => 100, 'window' => 60]
'endpoints' => [
// High-volume endpoints may need higher limits
// 'links.index' => ['limit' => 500, 'window' => 60],
// 'qrcodes.index' => ['limit' => 500, 'window' => 60],
// Sensitive endpoints may need lower limits
// 'auth.login' => ['limit' => 10, 'window' => 60],
// 'keys.create' => ['limit' => 20, 'window' => 60],
],
// Tier-based limits (based on workspace subscription/plan)
'tiers' => [
'free' => [
'limit' => 60,
'window' => 60, // seconds
'burst' => 1.0,
],
'starter' => [
'limit' => 1000,
'window' => 60,
'burst' => 1.2,
],
'pro' => [
'limit' => 5000,
'window' => 60,
'burst' => 1.3,
],
'agency' => [
'limit' => 20000,
'window' => 60,
'burst' => 1.5,
],
'enterprise' => [
'limit' => 100000,
'window' => 60,
'burst' => 2.0,
],
],
// Legacy: Tier-based limits (deprecated, use 'tiers' instead)
'by_tier' => [
'starter' => [
'requests' => 1000,
@ -62,6 +131,41 @@ return [
'per_minutes' => 1,
],
],
// Route-specific rate limiters (for named routes)
'routes' => [
'mcp' => 'authenticated',
'pixel' => 'default',
],
],
/*
|--------------------------------------------------------------------------
| Usage Alerts
|--------------------------------------------------------------------------
|
| Configure notifications when API usage approaches limits.
|
| Thresholds define percentages of rate limit that trigger alerts:
| - warning: First alert level (default: 80%)
| - critical: Urgent alert level (default: 95%)
|
| Cooldown prevents duplicate notifications for the same level.
|
*/
'alerts' => [
// Enable/disable usage alerting
'enabled' => env('API_USAGE_ALERTS_ENABLED', true),
// Alert thresholds (percentage of rate limit)
'thresholds' => [
'warning' => 80,
'critical' => 95,
],
// Hours between notifications of the same level
'cooldown_hours' => 6,
],
/*

View file

@ -30,11 +30,6 @@ class DocsController
return view('api::guides.authentication');
}
public function biolinks(): View
{
return view('api::guides.biolinks');
}
public function qrcodes(): View
{
return view('api::guides.qrcodes');

View file

@ -12,7 +12,6 @@ Route::get('/', [DocsController::class, 'index'])->name('api.docs');
Route::get('/guides', [DocsController::class, 'guides'])->name('api.guides');
Route::get('/guides/quickstart', [DocsController::class, 'quickstart'])->name('api.guides.quickstart');
Route::get('/guides/authentication', [DocsController::class, 'authentication'])->name('api.guides.authentication');
Route::get('/guides/biolinks', [DocsController::class, 'biolinks'])->name('api.guides.biolinks');
Route::get('/guides/qrcodes', [DocsController::class, 'qrcodes'])->name('api.guides.qrcodes');
Route::get('/guides/webhooks', [DocsController::class, 'webhooks'])->name('api.guides.webhooks');
Route::get('/guides/errors', [DocsController::class, 'errors'])->name('api.guides.errors');

View file

@ -62,7 +62,7 @@
<section id="overview" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
The Host UK API uses API keys for authentication. Each API key is scoped to a specific workspace and has configurable permissions.
The API uses API keys for authentication. Each API key is scoped to a specific workspace and has configurable permissions.
</p>
<p class="text-slate-600 dark:text-slate-400">
API keys are prefixed with <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-sm">hk_</code> to make them easily identifiable.
@ -76,7 +76,7 @@
To create an API key:
</p>
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-6">
<li>Log in to your Host UK account</li>
<li>Log in to your account</li>
<li>Navigate to <strong>Settings API Keys</strong></li>
<li>Click <strong>Create API Key</strong></li>
<li>Enter a descriptive name (e.g., "Production", "Development")</li>

View file

@ -1,197 +0,0 @@
@extends('api::layouts.docs')
@section('title', 'Managing Biolinks')
@section('content')
<div class="flex">
{{-- Sidebar --}}
<aside class="hidden lg:block fixed left-0 top-16 md:top-20 bottom-0 w-64 border-r border-slate-200 dark:border-slate-800">
<div class="h-full px-4 py-8 overflow-y-auto no-scrollbar">
<nav>
<ul class="space-y-2">
<li>
<a href="#overview" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Overview
</a>
</li>
<li>
<a href="#create-biolink" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Create a Biolink
</a>
</li>
<li>
<a href="#add-blocks" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Add Blocks
</a>
</li>
<li>
<a href="#block-types" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Block Types
</a>
</li>
<li>
<a href="#update-biolink" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Update Settings
</a>
</li>
</ul>
</nav>
</div>
</aside>
{{-- Main content --}}
<div class="lg:pl-64 w-full">
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-12">
{{-- Breadcrumb --}}
<nav class="mb-8">
<ol class="flex items-center gap-2 text-sm">
<li><a href="{{ route('api.guides') }}" class="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">Guides</a></li>
<li class="text-slate-400">/</li>
<li class="text-slate-800 dark:text-slate-200">Managing Biolinks</li>
</ol>
</nav>
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">Managing Biolinks</h1>
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
Create, update, and manage biolink pages with blocks and themes.
</p>
{{-- Overview --}}
<section id="overview" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Biolinks are customisable landing pages hosted at <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-sm">lt.hn/yourpage</code>. Each biolink can contain multiple blocks of content, including links, text, images, and more.
</p>
</section>
{{-- Create Biolink --}}
<section id="create-biolink" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Create a Biolink</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Create a new biolink page with a POST request:
</p>
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">cURL</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> POST \
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio'</span> \
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span> \
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Content-Type: application/json'</span> \
<span class="text-slate-500">--data</span> <span class="text-amber-400">'{
"url": "mypage",
"type": "biolink"
}'</span></code></pre>
</div>
<p class="text-slate-600 dark:text-slate-400">
The <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-sm">url</code> field determines your biolink's address. It must be unique within your workspace.
</p>
</section>
{{-- Add Blocks --}}
<section id="add-blocks" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Add Blocks</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Add content blocks to your biolink:
</p>
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">cURL</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> POST \
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio/1/blocks'</span> \
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span> \
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Content-Type: application/json'</span> \
<span class="text-slate-500">--data</span> <span class="text-amber-400">'{
"type": "link",
"data": {
"title": "Visit My Website",
"url": "https://example.com"
}
}'</span></code></pre>
</div>
</section>
{{-- Block Types --}}
<section id="block-types" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Block Types</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Available block types:
</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Type</th>
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">link</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Clickable link button</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">text</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Text paragraph or heading</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">image</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Image with optional link</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">socials</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Social media icon links</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">divider</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Visual separator</td>
</tr>
</tbody>
</table>
</div>
</section>
{{-- Update Biolink --}}
<section id="update-biolink" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Update Settings</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Update biolink settings like URL, theme, or metadata:
</p>
<div class="bg-slate-800 rounded-sm overflow-hidden">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">cURL</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> PUT \
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio/1'</span> \
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span> \
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Content-Type: application/json'</span> \
<span class="text-slate-500">--data</span> <span class="text-amber-400">'{
"url": "newpage",
"title": "My Updated Page"
}'</span></code></pre>
</div>
</section>
{{-- Next steps --}}
<div class="flex items-center justify-between pt-8 border-t border-slate-200 dark:border-slate-700">
<a href="{{ route('api.guides.authentication') }}" class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200">
&larr; Authentication
</a>
<a href="{{ route('api.guides.qrcodes') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 font-medium">
QR Code Generation &rarr;
</a>
</div>
</div>
</div>
</div>
@endsection

View file

@ -62,7 +62,7 @@
<section id="overview" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
The Host UK API uses conventional HTTP response codes to indicate success or failure. Codes in the 2xx range indicate success, 4xx indicate client errors, and 5xx indicate server errors.
The API uses conventional HTTP response codes to indicate success or failure. Codes in the 2xx range indicate success, 4xx indicate client errors, and 5xx indicate server errors.
</p>
</section>

View file

@ -7,7 +7,7 @@
<div class="max-w-3xl">
<h1 class="h2 mb-4 text-slate-800 dark:text-slate-100">Guides</h1>
<p class="text-lg text-slate-600 dark:text-slate-400 mb-12">
Step-by-step tutorials and best practices for integrating with the Host UK API.
Step-by-step tutorials and best practices for integrating with the API.
</p>
</div>
@ -24,7 +24,7 @@
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Getting Started</span>
</div>
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-500">Quick Start</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">Get up and running with the Host UK API in under 5 minutes.</p>
<p class="text-sm text-slate-600 dark:text-slate-400">Get up and running with the API in under 5 minutes.</p>
</a>
{{-- Authentication --}}
@ -41,20 +41,6 @@
<p class="text-sm text-slate-600 dark:text-slate-400">Learn how to authenticate your API requests using API keys.</p>
</a>
{{-- Biolinks --}}
<a href="{{ route('api.guides.biolinks') }}" class="group block p-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
<div class="flex items-center gap-3 mb-3">
<div class="w-8 h-8 flex items-center justify-center bg-purple-100 dark:bg-purple-900/30 rounded-sm">
<svg class="w-4 h-4 fill-purple-600" viewBox="0 0 16 16">
<path d="M10.586 3.586a2 2 0 1 1 2.828 2.828l-2.5 2.5a2 2 0 0 1-2.828 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 1 0-4.95-4.95l-1.25 1.25a.75.75 0 1 0 1.06 1.06l1.25-1.25z"/>
</svg>
</div>
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Core</span>
</div>
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-500">Managing Biolinks</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">Create, update, and manage biolink pages with blocks and themes.</p>
</a>
{{-- QR Codes --}}
<a href="{{ route('api.guides.qrcodes') }}" class="group block p-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
<div class="flex items-center gap-3 mb-3">

View file

@ -62,7 +62,7 @@
<section id="overview" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
The Host UK API provides two ways to generate QR codes:
The API provides two ways to generate QR codes:
</p>
<ul class="list-disc list-inside space-y-2 text-slate-600 dark:text-slate-400">
<li><strong>Biolink QR codes</strong> - Generate QR codes for your existing biolinks</li>
@ -92,7 +92,7 @@
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">{
<span class="text-blue-400">"data"</span>: {
<span class="text-blue-400">"svg"</span>: <span class="text-green-400">"&lt;svg&gt;...&lt;/svg&gt;"</span>,
<span class="text-blue-400">"url"</span>: <span class="text-green-400">"https://lt.hn/mypage"</span>
<span class="text-blue-400">"url"</span>: <span class="text-green-400">"https://example.com/mypage"</span>
}
}</code></pre>
</div>

View file

@ -55,7 +55,7 @@
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">Quick Start</h1>
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
Get up and running with the Host UK API in under 5 minutes.
Get up and running with the API in under 5 minutes.
</p>
{{-- Prerequisites --}}
@ -65,7 +65,7 @@
Before you begin, you'll need:
</p>
<ul class="list-disc list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-4">
<li>A Host UK account (<a href="https://hub.host.uk.com/register" class="text-blue-600 hover:underline">sign up free</a>)</li>
<li>An account with API access</li>
<li>A workspace (created automatically on signup)</li>
<li>cURL or any HTTP client</li>
</ul>
@ -155,7 +155,7 @@
</div>
<p class="text-slate-600 dark:text-slate-400">
This creates a new biolink page at <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-sm">lt.hn/mypage</code>.
This creates a new biolink page at your configured short URL.
</p>
</section>

View file

@ -30,9 +30,24 @@
Payload Format
</a>
</li>
<li>
<a href="#headers" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Request Headers
</a>
</li>
<li>
<a href="#verification" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Verification
Signature Verification
</a>
</li>
<li>
<a href="#retry-policy" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Retry Policy
</a>
</li>
<li>
<a href="#best-practices" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
Best Practices
</a>
</li>
</ul>
@ -55,22 +70,25 @@
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">Webhooks</h1>
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
Receive real-time notifications for events in your workspace.
Receive real-time notifications for events in your workspace with cryptographically signed payloads.
</p>
{{-- Overview --}}
<section id="overview" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Webhooks allow your application to receive real-time HTTP callbacks when events occur in your Host UK workspace. Instead of polling the API, webhooks push data to your server as events happen.
Webhooks allow your application to receive real-time HTTP callbacks when events occur in your workspace. Instead of polling the API, webhooks push data to your server as events happen.
</p>
<div class="text-sm p-4 bg-blue-50 border border-blue-200 rounded-sm dark:bg-blue-900/20 dark:border-blue-800">
<p class="text-slate-600 dark:text-slate-400 mb-4">
All webhook requests are cryptographically signed using HMAC-SHA256, allowing you to verify that requests genuinely came from our platform and haven't been tampered with.
</p>
<div class="text-sm p-4 bg-amber-50 border border-amber-200 rounded-sm dark:bg-amber-900/20 dark:border-amber-800">
<div class="flex items-start">
<svg class="fill-blue-500 shrink-0 mr-3 mt-0.5" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm1 12H7V7h2v5zm0-6H7V4h2v2z"/>
<svg class="fill-amber-500 shrink-0 mr-3 mt-0.5" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm0 12a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm1-3H7V4h2v5z"/>
</svg>
<p class="text-blue-800 dark:text-blue-200">
<strong>Coming soon:</strong> Webhook functionality is currently in development.
<p class="text-amber-800 dark:text-amber-200">
<strong>Security:</strong> Always verify webhook signatures before processing. Never trust unverified webhook requests.
</p>
</div>
</div>
@ -82,13 +100,23 @@
<p class="text-slate-600 dark:text-slate-400 mb-4">
To configure webhooks:
</p>
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400">
<li>Go to <strong>Settings Webhooks</strong> in your workspace</li>
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-4">
<li>Go to <strong>Settings &rarr; Webhooks</strong> in your workspace</li>
<li>Click <strong>Add Webhook</strong></li>
<li>Enter your endpoint URL (must be HTTPS)</li>
<li>Enter your endpoint URL (must be HTTPS in production)</li>
<li>Select the events you want to receive</li>
<li>Save and note your webhook secret</li>
<li>Save and securely store your webhook secret</li>
</ol>
<div class="text-sm p-4 bg-blue-50 border border-blue-200 rounded-sm dark:bg-blue-900/20 dark:border-blue-800">
<div class="flex items-start">
<svg class="fill-blue-500 shrink-0 mr-3 mt-0.5" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm1 12H7V7h2v5zm0-6H7V4h2v2z"/>
</svg>
<p class="text-blue-800 dark:text-blue-200">
Your webhook secret is only shown once when you create the endpoint. Store it securely - you'll need it to verify incoming webhooks.
</p>
</div>
</div>
</section>
{{-- Events --}}
@ -108,20 +136,36 @@
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">biolink.created</code></td>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">bio.created</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A new biolink was created</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">biolink.updated</code></td>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">bio.updated</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A biolink was updated</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">biolink.deleted</code></td>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">bio.deleted</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A biolink was deleted</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">click.tracked</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A link click was recorded</td>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">link.created</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A new link was created</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">link.clicked</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A link was clicked (high volume)</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">qrcode.created</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A QR code was generated</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">qrcode.scanned</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A QR code was scanned (high volume)</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">*</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Subscribe to all events (wildcard)</td>
</tr>
</tbody>
</table>
@ -132,13 +176,13 @@
<section id="payload" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Payload Format</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Webhook payloads are sent as JSON:
Webhook payloads are sent as JSON with a consistent structure:
</p>
<div class="bg-slate-800 rounded-sm overflow-hidden">
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">{
<span class="text-blue-400">"id"</span>: <span class="text-green-400">"evt_abc123"</span>,
<span class="text-blue-400">"type"</span>: <span class="text-green-400">"biolink.created"</span>,
<span class="text-blue-400">"id"</span>: <span class="text-green-400">"evt_abc123xyz456"</span>,
<span class="text-blue-400">"type"</span>: <span class="text-green-400">"bio.created"</span>,
<span class="text-blue-400">"created_at"</span>: <span class="text-green-400">"2024-01-15T10:30:00Z"</span>,
<span class="text-blue-400">"workspace_id"</span>: <span class="text-amber-400">1</span>,
<span class="text-blue-400">"data"</span>: {
@ -150,30 +194,381 @@
</div>
</section>
{{-- Headers --}}
<section id="headers" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Request Headers</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Every webhook request includes the following headers:
</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Header</th>
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Signature</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">HMAC-SHA256 signature for verification</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Timestamp</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Unix timestamp when the webhook was sent</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Event</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">The event type (e.g., <code>bio.created</code>)</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Id</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Unique delivery ID for idempotency</td>
</tr>
<tr>
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">Content-Type</code></td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Always <code>application/json</code></td>
</tr>
</tbody>
</table>
</div>
</section>
{{-- Verification --}}
<section id="verification" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Verification</h2>
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Signature Verification</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
All webhook requests include a signature header for verification:
To verify a webhook signature, compute the HMAC-SHA256 of the timestamp concatenated with the raw request body using your webhook secret. The signature includes the timestamp to prevent replay attacks.
</p>
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">X-Host-Signature: sha256=abc123...</code></pre>
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Verification Algorithm</h3>
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-6">
<li>Extract <code>X-Webhook-Signature</code> and <code>X-Webhook-Timestamp</code> headers</li>
<li>Concatenate: <code>timestamp + "." + raw_request_body</code></li>
<li>Compute: <code>HMAC-SHA256(concatenated_string, your_webhook_secret)</code></li>
<li>Compare using timing-safe comparison (prevents timing attacks)</li>
<li>Verify timestamp is within 5 minutes of current time (prevents replay attacks)</li>
</ol>
{{-- PHP Example --}}
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">PHP</h3>
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">webhook-handler.php</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-purple-400">&lt;?php</span>
<span class="text-slate-500">// Get request data</span>
<span class="text-purple-400">$payload</span> = <span class="text-teal-400">file_get_contents</span>(<span class="text-green-400">'php://input'</span>);
<span class="text-purple-400">$signature</span> = <span class="text-purple-400">$_SERVER</span>[<span class="text-green-400">'HTTP_X_WEBHOOK_SIGNATURE'</span>] ?? <span class="text-green-400">''</span>;
<span class="text-purple-400">$timestamp</span> = <span class="text-purple-400">$_SERVER</span>[<span class="text-green-400">'HTTP_X_WEBHOOK_TIMESTAMP'</span>] ?? <span class="text-green-400">''</span>;
<span class="text-purple-400">$secret</span> = <span class="text-teal-400">getenv</span>(<span class="text-green-400">'WEBHOOK_SECRET'</span>);
<span class="text-slate-500">// Verify timestamp (5 minute tolerance)</span>
<span class="text-purple-400">$tolerance</span> = <span class="text-amber-400">300</span>;
<span class="text-pink-400">if</span> (<span class="text-teal-400">abs</span>(<span class="text-teal-400">time</span>() - (<span class="text-pink-400">int</span>)<span class="text-purple-400">$timestamp</span>) > <span class="text-purple-400">$tolerance</span>) {
<span class="text-teal-400">http_response_code</span>(<span class="text-amber-400">401</span>);
<span class="text-pink-400">die</span>(<span class="text-green-400">'Webhook timestamp expired'</span>);
}
<span class="text-slate-500">// Compute expected signature</span>
<span class="text-purple-400">$signedPayload</span> = <span class="text-purple-400">$timestamp</span> . <span class="text-green-400">'.'</span> . <span class="text-purple-400">$payload</span>;
<span class="text-purple-400">$expectedSignature</span> = <span class="text-teal-400">hash_hmac</span>(<span class="text-green-400">'sha256'</span>, <span class="text-purple-400">$signedPayload</span>, <span class="text-purple-400">$secret</span>);
<span class="text-slate-500">// Verify signature (timing-safe comparison)</span>
<span class="text-pink-400">if</span> (!<span class="text-teal-400">hash_equals</span>(<span class="text-purple-400">$expectedSignature</span>, <span class="text-purple-400">$signature</span>)) {
<span class="text-teal-400">http_response_code</span>(<span class="text-amber-400">401</span>);
<span class="text-pink-400">die</span>(<span class="text-green-400">'Invalid webhook signature'</span>);
}
<span class="text-slate-500">// Signature valid - process the webhook</span>
<span class="text-purple-400">$event</span> = <span class="text-teal-400">json_decode</span>(<span class="text-purple-400">$payload</span>, <span class="text-pink-400">true</span>);
<span class="text-teal-400">processWebhook</span>(<span class="text-purple-400">$event</span>);</code></pre>
</div>
<p class="text-slate-600 dark:text-slate-400 mb-4">
Verify the signature by computing HMAC-SHA256 of the request body using your webhook secret:
</p>
{{-- Node.js Example --}}
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Node.js</h3>
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">webhook-handler.js</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">const</span> crypto = <span class="text-teal-400">require</span>(<span class="text-green-400">'crypto'</span>);
<span class="text-pink-400">const</span> express = <span class="text-teal-400">require</span>(<span class="text-green-400">'express'</span>);
<span class="text-pink-400">const</span> app = <span class="text-teal-400">express</span>();
app.<span class="text-teal-400">use</span>(express.<span class="text-teal-400">raw</span>({ type: <span class="text-green-400">'application/json'</span> }));
<span class="text-pink-400">const</span> WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
<span class="text-pink-400">const</span> TOLERANCE = <span class="text-amber-400">300</span>; <span class="text-slate-500">// 5 minutes</span>
app.<span class="text-teal-400">post</span>(<span class="text-green-400">'/webhook'</span>, (req, res) => {
<span class="text-pink-400">const</span> signature = req.headers[<span class="text-green-400">'x-webhook-signature'</span>];
<span class="text-pink-400">const</span> timestamp = req.headers[<span class="text-green-400">'x-webhook-timestamp'</span>];
<span class="text-pink-400">const</span> payload = req.body;
<span class="text-slate-500">// Verify timestamp</span>
<span class="text-pink-400">const</span> now = Math.<span class="text-teal-400">floor</span>(Date.<span class="text-teal-400">now</span>() / <span class="text-amber-400">1000</span>);
<span class="text-pink-400">if</span> (Math.<span class="text-teal-400">abs</span>(now - <span class="text-teal-400">parseInt</span>(timestamp)) > TOLERANCE) {
<span class="text-pink-400">return</span> res.<span class="text-teal-400">status</span>(<span class="text-amber-400">401</span>).<span class="text-teal-400">send</span>(<span class="text-green-400">'Webhook timestamp expired'</span>);
}
<span class="text-slate-500">// Compute expected signature</span>
<span class="text-pink-400">const</span> signedPayload = <span class="text-green-400">`${</span>timestamp<span class="text-green-400">}.${</span>payload<span class="text-green-400">}`</span>;
<span class="text-pink-400">const</span> expectedSignature = crypto
.<span class="text-teal-400">createHmac</span>(<span class="text-green-400">'sha256'</span>, WEBHOOK_SECRET)
.<span class="text-teal-400">update</span>(signedPayload)
.<span class="text-teal-400">digest</span>(<span class="text-green-400">'hex'</span>);
<span class="text-slate-500">// Verify signature (timing-safe comparison)</span>
<span class="text-pink-400">if</span> (!crypto.<span class="text-teal-400">timingSafeEqual</span>(
Buffer.<span class="text-teal-400">from</span>(expectedSignature),
Buffer.<span class="text-teal-400">from</span>(signature)
)) {
<span class="text-pink-400">return</span> res.<span class="text-teal-400">status</span>(<span class="text-amber-400">401</span>).<span class="text-teal-400">send</span>(<span class="text-green-400">'Invalid webhook signature'</span>);
}
<span class="text-slate-500">// Signature valid - process the webhook</span>
<span class="text-pink-400">const</span> event = JSON.<span class="text-teal-400">parse</span>(payload);
<span class="text-teal-400">processWebhook</span>(event);
res.<span class="text-teal-400">status</span>(<span class="text-amber-400">200</span>).<span class="text-teal-400">send</span>(<span class="text-green-400">'OK'</span>);
});</code></pre>
</div>
{{-- Python Example --}}
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Python</h3>
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">webhook_handler.py</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">import</span> hmac
<span class="text-pink-400">import</span> hashlib
<span class="text-pink-400">import</span> time
<span class="text-pink-400">import</span> os
<span class="text-pink-400">from</span> flask <span class="text-pink-400">import</span> Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ[<span class="text-green-400">'WEBHOOK_SECRET'</span>]
TOLERANCE = <span class="text-amber-400">300</span> <span class="text-slate-500"># 5 minutes</span>
<span class="text-pink-400">@</span>app.route(<span class="text-green-400">'/webhook'</span>, methods=[<span class="text-green-400">'POST'</span>])
<span class="text-pink-400">def</span> <span class="text-teal-400">webhook</span>():
signature = request.headers.get(<span class="text-green-400">'X-Webhook-Signature'</span>, <span class="text-green-400">''</span>)
timestamp = request.headers.get(<span class="text-green-400">'X-Webhook-Timestamp'</span>, <span class="text-green-400">''</span>)
payload = request.get_data(as_text=<span class="text-pink-400">True</span>)
<span class="text-slate-500"># Verify timestamp</span>
<span class="text-pink-400">if</span> abs(time.time() - int(timestamp)) > TOLERANCE:
abort(<span class="text-amber-400">401</span>, <span class="text-green-400">'Webhook timestamp expired'</span>)
<span class="text-slate-500"># Compute expected signature</span>
signed_payload = <span class="text-green-400">f'{timestamp}.{payload}'</span>
expected_signature = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
<span class="text-slate-500"># Verify signature (timing-safe comparison)</span>
<span class="text-pink-400">if not</span> hmac.compare_digest(expected_signature, signature):
abort(<span class="text-amber-400">401</span>, <span class="text-green-400">'Invalid webhook signature'</span>)
<span class="text-slate-500"># Signature valid - process the webhook</span>
event = request.get_json()
process_webhook(event)
<span class="text-pink-400">return</span> <span class="text-green-400">'OK'</span>, <span class="text-amber-400">200</span></code></pre>
</div>
{{-- Ruby Example --}}
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Ruby</h3>
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">webhook_handler.rb</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">require</span> <span class="text-green-400">'sinatra'</span>
<span class="text-pink-400">require</span> <span class="text-green-400">'openssl'</span>
<span class="text-pink-400">require</span> <span class="text-green-400">'json'</span>
WEBHOOK_SECRET = ENV[<span class="text-green-400">'WEBHOOK_SECRET'</span>]
TOLERANCE = <span class="text-amber-400">300</span> <span class="text-slate-500"># 5 minutes</span>
post <span class="text-green-400">'/webhook'</span> <span class="text-pink-400">do</span>
signature = request.env[<span class="text-green-400">'HTTP_X_WEBHOOK_SIGNATURE'</span>] || <span class="text-green-400">''</span>
timestamp = request.env[<span class="text-green-400">'HTTP_X_WEBHOOK_TIMESTAMP'</span>] || <span class="text-green-400">''</span>
payload = request.body.read
<span class="text-slate-500"># Verify timestamp</span>
<span class="text-pink-400">if</span> (Time.now.to_i - timestamp.to_i).<span class="text-teal-400">abs</span> > TOLERANCE
halt <span class="text-amber-400">401</span>, <span class="text-green-400">'Webhook timestamp expired'</span>
<span class="text-pink-400">end</span>
<span class="text-slate-500"># Compute expected signature</span>
signed_payload = <span class="text-green-400">"#{timestamp}.#{payload}"</span>
expected_signature = OpenSSL::HMAC.hexdigest(
<span class="text-green-400">'sha256'</span>,
WEBHOOK_SECRET,
signed_payload
)
<span class="text-slate-500"># Verify signature (timing-safe comparison)</span>
<span class="text-pink-400">unless</span> Rack::Utils.secure_compare(expected_signature, signature)
halt <span class="text-amber-400">401</span>, <span class="text-green-400">'Invalid webhook signature'</span>
<span class="text-pink-400">end</span>
<span class="text-slate-500"># Signature valid - process the webhook</span>
event = JSON.parse(payload)
process_webhook(event)
<span class="text-amber-400">200</span>
<span class="text-pink-400">end</span></code></pre>
</div>
{{-- Go Example --}}
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Go</h3>
<div class="bg-slate-800 rounded-sm overflow-hidden">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span class="text-sm text-slate-400">PHP</span>
<span class="text-sm text-slate-400">webhook_handler.go</span>
</div>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-purple-400">$signature</span> = <span class="text-teal-400">hash_hmac</span>(<span class="text-green-400">'sha256'</span>, <span class="text-purple-400">$requestBody</span>, <span class="text-purple-400">$webhookSecret</span>);
<span class="text-purple-400">$valid</span> = <span class="text-teal-400">hash_equals</span>(<span class="text-green-400">'sha256='</span> . <span class="text-purple-400">$signature</span>, <span class="text-purple-400">$headerSignature</span>);</code></pre>
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">package</span> main
<span class="text-pink-400">import</span> (
<span class="text-green-400">"crypto/hmac"</span>
<span class="text-green-400">"crypto/sha256"</span>
<span class="text-green-400">"crypto/subtle"</span>
<span class="text-green-400">"encoding/hex"</span>
<span class="text-green-400">"io"</span>
<span class="text-green-400">"math"</span>
<span class="text-green-400">"net/http"</span>
<span class="text-green-400">"os"</span>
<span class="text-green-400">"strconv"</span>
<span class="text-green-400">"time"</span>
)
<span class="text-pink-400">const</span> tolerance = <span class="text-amber-400">300</span> <span class="text-slate-500">// 5 minutes</span>
<span class="text-pink-400">func</span> <span class="text-teal-400">webhookHandler</span>(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get(<span class="text-green-400">"X-Webhook-Signature"</span>)
timestamp := r.Header.Get(<span class="text-green-400">"X-Webhook-Timestamp"</span>)
secret := os.Getenv(<span class="text-green-400">"WEBHOOK_SECRET"</span>)
payload, _ := io.ReadAll(r.Body)
<span class="text-slate-500">// Verify timestamp</span>
ts, _ := strconv.ParseInt(timestamp, <span class="text-amber-400">10</span>, <span class="text-amber-400">64</span>)
<span class="text-pink-400">if</span> math.Abs(<span class="text-teal-400">float64</span>(time.Now().Unix()-ts)) > tolerance {
http.Error(w, <span class="text-green-400">"Webhook timestamp expired"</span>, <span class="text-amber-400">401</span>)
<span class="text-pink-400">return</span>
}
<span class="text-slate-500">// Compute expected signature</span>
signedPayload := timestamp + <span class="text-green-400">"."</span> + <span class="text-teal-400">string</span>(payload)
mac := hmac.New(sha256.New, []<span class="text-teal-400">byte</span>(secret))
mac.Write([]<span class="text-teal-400">byte</span>(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(<span class="text-pink-400">nil</span>))
<span class="text-slate-500">// Verify signature (timing-safe comparison)</span>
<span class="text-pink-400">if</span> subtle.ConstantTimeCompare(
[]<span class="text-teal-400">byte</span>(expectedSignature),
[]<span class="text-teal-400">byte</span>(signature),
) != <span class="text-amber-400">1</span> {
http.Error(w, <span class="text-green-400">"Invalid webhook signature"</span>, <span class="text-amber-400">401</span>)
<span class="text-pink-400">return</span>
}
<span class="text-slate-500">// Signature valid - process the webhook</span>
processWebhook(payload)
w.WriteHeader(http.StatusOK)
}</code></pre>
</div>
</section>
{{-- Retry Policy --}}
<section id="retry-policy" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Retry Policy</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">
If your endpoint returns a non-2xx status code or times out, we'll retry with exponential backoff:
</p>
<div class="overflow-x-auto mb-4">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Attempt</th>
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Delay</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">1 (initial)</td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Immediate</td>
</tr>
<tr>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">2</td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">1 minute</td>
</tr>
<tr>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">3</td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">5 minutes</td>
</tr>
<tr>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">4</td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">30 minutes</td>
</tr>
<tr>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">5 (final)</td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">2 hours</td>
</tr>
</tbody>
</table>
</div>
<p class="text-slate-600 dark:text-slate-400">
After 5 failed attempts, the delivery is marked as failed. If your endpoint fails 10 consecutive deliveries, it will be automatically disabled. You can re-enable it from your webhook settings.
</p>
</section>
{{-- Best Practices --}}
<section id="best-practices" data-scrollspy-target class="mb-12">
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Best Practices</h2>
<ul class="space-y-3 text-slate-600 dark:text-slate-400">
<li class="flex items-start">
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
</svg>
<span><strong>Always verify signatures</strong> - Never process webhooks without verification</span>
</li>
<li class="flex items-start">
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
</svg>
<span><strong>Respond quickly</strong> - Return 200 within 30 seconds to avoid timeouts</span>
</li>
<li class="flex items-start">
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
</svg>
<span><strong>Process asynchronously</strong> - Queue webhook processing for long-running tasks</span>
</li>
<li class="flex items-start">
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
</svg>
<span><strong>Handle duplicates</strong> - Use <code>X-Webhook-Id</code> for idempotency</span>
</li>
<li class="flex items-start">
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
</svg>
<span><strong>Use HTTPS</strong> - Always use HTTPS endpoints in production</span>
</li>
<li class="flex items-start">
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
</svg>
<span><strong>Rotate secrets regularly</strong> - Rotate your webhook secret periodically</span>
</li>
</ul>
</section>
{{-- Next steps --}}
<div class="flex items-center justify-between pt-8 border-t border-slate-200 dark:border-slate-700">
<a href="{{ route('api.guides.qrcodes') }}" class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200">