From e498a1701e642eb344d73bac44b763f2582bd105 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 14:24:42 +0000 Subject: [PATCH] refactor: update namespaces and remove deprecated biolinks route; enhance API documentation attributes --- packages/core-api/TODO.md | 86 +- .../core-api/changelog/2026/jan/features.md | 122 +++ packages/core-api/composer.json | 3 +- packages/core-api/src/Mod/Api/Boot.php | 17 + .../Console/Commands/CheckApiUsageAlerts.php | 291 +++++++ .../Api/Database/Factories/ApiKeyFactory.php | 86 +- .../Documentation/Attributes/ApiHidden.php | 41 + .../Documentation/Attributes/ApiParameter.php | 101 +++ .../Documentation/Attributes/ApiResponse.php | 80 ++ .../Documentation/Attributes/ApiSecurity.php | 51 ++ .../Api/Documentation/Attributes/ApiTag.php | 38 + .../Documentation/DocumentationController.php | 128 +++ .../DocumentationServiceProvider.php | 87 ++ .../Documentation/Examples/CommonExamples.php | 278 ++++++ .../src/Mod/Api/Documentation/Extension.php | 40 + .../Extensions/ApiKeyAuthExtension.php | 234 +++++ .../Extensions/RateLimitExtension.php | 228 +++++ .../Extensions/WorkspaceHeaderExtension.php | 111 +++ .../Middleware/ProtectDocumentation.php | 76 ++ .../Mod/Api/Documentation/ModuleDiscovery.php | 209 +++++ .../Mod/Api/Documentation/OpenApiBuilder.php | 819 ++++++++++++++++++ .../src/Mod/Api/Documentation/Routes/docs.php | 36 + .../Api/Documentation/Views/redoc.blade.php | 60 ++ .../Api/Documentation/Views/scalar.blade.php | 28 + .../Api/Documentation/Views/swagger.blade.php | 65 ++ .../src/Mod/Api/Documentation/config.php | 319 +++++++ .../Exceptions/RateLimitExceededException.php | 56 ++ .../src/Mod/Api/Jobs/DeliverWebhookJob.php | 4 +- .../Mod/Api/Middleware/EnforceApiScope.php | 65 ++ .../src/Mod/Api/Middleware/RateLimitApi.php | 379 ++++++-- ...0_add_secure_hashing_to_api_keys_table.php | 46 + .../core-api/src/Mod/Api/Models/ApiKey.php | 191 +++- .../src/Mod/Api/Models/WebhookDelivery.php | 30 +- .../src/Mod/Api/Models/WebhookEndpoint.php | 63 +- .../HighApiUsageNotification.php | 111 +++ .../src/Mod/Api/RateLimit/RateLimit.php | 42 + .../src/Mod/Api/RateLimit/RateLimitResult.php | 71 ++ .../Mod/Api/RateLimit/RateLimitService.php | 247 ++++++ packages/core-api/src/Mod/Api/Routes/api.php | 9 +- .../src/Mod/Api/Services/WebhookService.php | 8 +- .../src/Mod/Api/Services/WebhookSignature.php | 206 +++++ .../Api/Tests/Feature/ApiKeySecurityTest.php | 381 ++++++++ .../src/Mod/Api/Tests/Feature/ApiKeyTest.php | 21 +- .../Tests/Feature/ApiScopeEnforcementTest.php | 232 +++++ .../Feature/OpenApiDocumentationTest.php | 120 +++ .../Mod/Api/Tests/Feature/RateLimitTest.php | 532 ++++++++++++ .../Api/Tests/Feature/WebhookDeliveryTest.php | 462 +++++++++- packages/core-api/src/Mod/Api/config.php | 108 ++- .../Api/Controllers/DocsController.php | 5 - .../core-api/src/Website/Api/Routes/web.php | 1 - .../Blade/guides/authentication.blade.php | 4 +- .../Api/View/Blade/guides/biolinks.blade.php | 197 ----- .../Api/View/Blade/guides/errors.blade.php | 2 +- .../Api/View/Blade/guides/index.blade.php | 18 +- .../Api/View/Blade/guides/qrcodes.blade.php | 4 +- .../View/Blade/guides/quickstart.blade.php | 6 +- .../Api/View/Blade/guides/webhooks.blade.php | 455 +++++++++- 57 files changed, 7226 insertions(+), 484 deletions(-) create mode 100644 packages/core-api/changelog/2026/jan/features.md create mode 100644 packages/core-api/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Attributes/ApiHidden.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Attributes/ApiParameter.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Attributes/ApiResponse.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Attributes/ApiSecurity.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Attributes/ApiTag.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/DocumentationController.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/DocumentationServiceProvider.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Examples/CommonExamples.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Extension.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Extensions/ApiKeyAuthExtension.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Extensions/RateLimitExtension.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/ModuleDiscovery.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/OpenApiBuilder.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Routes/docs.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Views/redoc.blade.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Views/scalar.blade.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/Views/swagger.blade.php create mode 100644 packages/core-api/src/Mod/Api/Documentation/config.php create mode 100644 packages/core-api/src/Mod/Api/Exceptions/RateLimitExceededException.php create mode 100644 packages/core-api/src/Mod/Api/Middleware/EnforceApiScope.php create mode 100644 packages/core-api/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php create mode 100644 packages/core-api/src/Mod/Api/Notifications/HighApiUsageNotification.php create mode 100644 packages/core-api/src/Mod/Api/RateLimit/RateLimit.php create mode 100644 packages/core-api/src/Mod/Api/RateLimit/RateLimitResult.php create mode 100644 packages/core-api/src/Mod/Api/RateLimit/RateLimitService.php create mode 100644 packages/core-api/src/Mod/Api/Services/WebhookSignature.php create mode 100644 packages/core-api/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php create mode 100644 packages/core-api/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php create mode 100644 packages/core-api/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php create mode 100644 packages/core-api/src/Mod/Api/Tests/Feature/RateLimitTest.php delete mode 100644 packages/core-api/src/Website/Api/View/Blade/guides/biolinks.blade.php diff --git a/packages/core-api/TODO.md b/packages/core-api/TODO.md index 5c6e934..858ce74 100644 --- a/packages/core-api/TODO.md +++ b/packages/core-api/TODO.md @@ -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.* diff --git a/packages/core-api/changelog/2026/jan/features.md b/packages/core-api/changelog/2026/jan/features.md new file mode 100644 index 0000000..dca84c4 --- /dev/null +++ b/packages/core-api/changelog/2026/jan/features.md @@ -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. diff --git a/packages/core-api/composer.json b/packages/core-api/composer.json index 6d05297..ca9ab32 100644 --- a/packages/core-api/composer.json +++ b/packages/core-api/composer.json @@ -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": { diff --git a/packages/core-api/src/Mod/Api/Boot.php b/packages/core-api/src/Mod/Api/Boot.php index 357812c..e02e0b6 100644 --- a/packages/core-api/src/Mod/Api/Boot.php +++ b/packages/core-api/src/Mod/Api/Boot.php @@ -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); } } diff --git a/packages/core-api/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php b/packages/core-api/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php new file mode 100644 index 0000000..6163605 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php @@ -0,0 +1,291 @@ +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}"; + } +} diff --git a/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php b/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php index 6b44cdf..36b6898 100644 --- a/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php +++ b/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php @@ -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 */ 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 */ 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), + ]); + } } diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiHidden.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiHidden.php new file mode 100644 index 0000000..4ae0858 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiHidden.php @@ -0,0 +1,41 @@ + $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; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiResponse.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiResponse.php new file mode 100644 index 0000000..2b5092a --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiResponse.php @@ -0,0 +1,80 @@ + $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', + }; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiSecurity.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiSecurity.php new file mode 100644 index 0000000..97fcf01 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiSecurity.php @@ -0,0 +1,51 @@ + $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; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiTag.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiTag.php new file mode 100644 index 0000000..239d3c5 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiTag.php @@ -0,0 +1,38 @@ + $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}"; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/DocumentationServiceProvider.php b/packages/core-api/src/Mod/Api/Documentation/DocumentationServiceProvider.php new file mode 100644 index 0000000..12b8f2b --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/DocumentationServiceProvider.php @@ -0,0 +1,87 @@ +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'); + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Examples/CommonExamples.php b/packages/core-api/src/Mod/Api/Documentation/Examples/CommonExamples.php new file mode 100644 index 0000000..f53ab02 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Examples/CommonExamples.php @@ -0,0 +1,278 @@ + [ + '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 = "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; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Extension.php b/packages/core-api/src/Mod/Api/Documentation/Extension.php new file mode 100644 index 0000000..31e7360 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Extension.php @@ -0,0 +1,40 @@ +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 << '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; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php b/packages/core-api/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php new file mode 100644 index 0000000..0679048 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php @@ -0,0 +1,111 @@ + $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; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php b/packages/core-api/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php new file mode 100644 index 0000000..4752c81 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php @@ -0,0 +1,76 @@ +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); + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/ModuleDiscovery.php b/packages/core-api/src/Mod/Api/Documentation/ModuleDiscovery.php new file mode 100644 index 0000000..9bb3681 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/ModuleDiscovery.php @@ -0,0 +1,209 @@ + + */ + protected array $modules = []; + + /** + * Discover all API modules and their routes. + * + * @return 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 + */ + 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; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/OpenApiBuilder.php b/packages/core-api/src/Mod/Api/Documentation/OpenApiBuilder.php new file mode 100644 index 0000000..e02764c --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/OpenApiBuilder.php @@ -0,0 +1,819 @@ + + */ + protected array $extensions = []; + + /** + * Discovered tags from modules. + * + * @var 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 $attributeClass + * @return ReflectionAttribute|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; + } +} diff --git a/packages/core-api/src/Mod/Api/Documentation/Routes/docs.php b/packages/core-api/src/Mod/Api/Documentation/Routes/docs.php new file mode 100644 index 0000000..03ae6ad --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Routes/docs.php @@ -0,0 +1,36 @@ +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'); diff --git a/packages/core-api/src/Mod/Api/Documentation/Views/redoc.blade.php b/packages/core-api/src/Mod/Api/Documentation/Views/redoc.blade.php new file mode 100644 index 0000000..d1fd68e --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Views/redoc.blade.php @@ -0,0 +1,60 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} - ReDoc + + + + + + + + + + diff --git a/packages/core-api/src/Mod/Api/Documentation/Views/scalar.blade.php b/packages/core-api/src/Mod/Api/Documentation/Views/scalar.blade.php new file mode 100644 index 0000000..85ac8c8 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Views/scalar.blade.php @@ -0,0 +1,28 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} + + + + + + + diff --git a/packages/core-api/src/Mod/Api/Documentation/Views/swagger.blade.php b/packages/core-api/src/Mod/Api/Documentation/Views/swagger.blade.php new file mode 100644 index 0000000..2515ddd --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/Views/swagger.blade.php @@ -0,0 +1,65 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} - Swagger UI + + + + +
+ + + + + + diff --git a/packages/core-api/src/Mod/Api/Documentation/config.php b/packages/core-api/src/Mod/Api/Documentation/config.php new file mode 100644 index 0000000..0c43186 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Documentation/config.php @@ -0,0 +1,319 @@ + 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'], + ], + +]; diff --git a/packages/core-api/src/Mod/Api/Exceptions/RateLimitExceededException.php b/packages/core-api/src/Mod/Api/Exceptions/RateLimitExceededException.php new file mode 100644 index 0000000..62436b1 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Exceptions/RateLimitExceededException.php @@ -0,0 +1,56 @@ +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 + */ + public function getHeaders(): array + { + return array_map(fn ($value) => (string) $value, $this->rateLimitResult->headers()); + } +} diff --git a/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php b/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php index fa7cdc7..ba7612d 100644 --- a/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php +++ b/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php @@ -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. diff --git a/packages/core-api/src/Mod/Api/Middleware/EnforceApiScope.php b/packages/core-api/src/Mod/Api/Middleware/EnforceApiScope.php new file mode 100644 index 0000000..2a91f42 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Middleware/EnforceApiScope.php @@ -0,0 +1,65 @@ + 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); + } +} diff --git a/packages/core-api/src/Mod/Api/Middleware/RateLimitApi.php b/packages/core-api/src/Mod/Api/Middleware/RateLimitApi.php index e137b2f..772bb5d 100644 --- a/packages/core-api/src/Mod/Api/Middleware/RateLimitApi.php +++ b/packages/core-api/src/Mod/Api/Middleware/RateLimitApi.php @@ -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); - } } diff --git a/packages/core-api/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php b/packages/core-api/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php new file mode 100644 index 0000000..4883ffc --- /dev/null +++ b/packages/core-api/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php @@ -0,0 +1,46 @@ +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']); + }); + } +}; diff --git a/packages/core-api/src/Mod/Api/Models/ApiKey.php b/packages/core-api/src/Mod/Api/Models/ApiKey.php index ee3e06b..61587a7 100644 --- a/packages/core-api/src/Mod/Api/Models/ApiKey.php +++ b/packages/core-api/src/Mod/Api/Models/ApiKey.php @@ -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); + } } diff --git a/packages/core-api/src/Mod/Api/Models/WebhookDelivery.php b/packages/core-api/src/Mod/Api/Models/WebhookDelivery.php index 7dedcde..637b6c2 100644 --- a/packages/core-api/src/Mod/Api/Models/WebhookDelivery.php +++ b/packages/core-api/src/Mod/Api/Models/WebhookDelivery.php @@ -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, 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, ]; diff --git a/packages/core-api/src/Mod/Api/Models/WebhookEndpoint.php b/packages/core-api/src/Mod/Api/Models/WebhookEndpoint.php index cd544f1..6c4ebad 100644 --- a/packages/core-api/src/Mod/Api/Models/WebhookEndpoint.php +++ b/packages/core-api/src/Mod/Api/Models/WebhookEndpoint.php @@ -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; diff --git a/packages/core-api/src/Mod/Api/Notifications/HighApiUsageNotification.php b/packages/core-api/src/Mod/Api/Notifications/HighApiUsageNotification.php new file mode 100644 index 0000000..ae8c44a --- /dev/null +++ b/packages/core-api/src/Mod/Api/Notifications/HighApiUsageNotification.php @@ -0,0 +1,111 @@ + + */ + 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 + */ + 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, + ]; + } +} diff --git a/packages/core-api/src/Mod/Api/RateLimit/RateLimit.php b/packages/core-api/src/Mod/Api/RateLimit/RateLimit.php new file mode 100644 index 0000000..b49e099 --- /dev/null +++ b/packages/core-api/src/Mod/Api/RateLimit/RateLimit.php @@ -0,0 +1,42 @@ + + */ + 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; + } +} diff --git a/packages/core-api/src/Mod/Api/RateLimit/RateLimitService.php b/packages/core-api/src/Mod/Api/RateLimit/RateLimitService.php new file mode 100644 index 0000000..c85aebd --- /dev/null +++ b/packages/core-api/src/Mod/Api/RateLimit/RateLimitService.php @@ -0,0 +1,247 @@ +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 Array of timestamps + */ + protected function getWindowHits(string $cacheKey, int $windowStart): array + { + /** @var array $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 $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 $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; + } +} diff --git a/packages/core-api/src/Mod/Api/Routes/api.php b/packages/core-api/src/Mod/Api/Routes/api.php index 89e9cfe..29898fb 100644 --- a/packages/core-api/src/Mod/Api/Routes/api.php +++ b/packages/core-api/src/Mod/Api/Routes/api.php @@ -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'); diff --git a/packages/core-api/src/Mod/Api/Services/WebhookService.php b/packages/core-api/src/Mod/Api/Services/WebhookService.php index 1300213..4a77d5c 100644 --- a/packages/core-api/src/Mod/Api/Services/WebhookService.php +++ b/packages/core-api/src/Mod/Api/Services/WebhookService.php @@ -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. diff --git a/packages/core-api/src/Mod/Api/Services/WebhookSignature.php b/packages/core-api/src/Mod/Api/Services/WebhookSignature.php new file mode 100644 index 0000000..400f032 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Services/WebhookSignature.php @@ -0,0 +1,206 @@ +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 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, + ]; + } +} diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php new file mode 100644 index 0000000..d9f0545 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php @@ -0,0 +1,381 @@ +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(); + }); +}); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php index 1fbd478..109811c 100644 --- a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php +++ b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php @@ -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(); }); }); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php new file mode 100644 index 0000000..ec6f630 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php @@ -0,0 +1,232 @@ +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 + }); +}); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php new file mode 100644 index 0000000..b8f31d9 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php @@ -0,0 +1,120 @@ +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); + } +} diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/RateLimitTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/RateLimitTest.php new file mode 100644 index 0000000..b6a3300 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Tests/Feature/RateLimitTest.php @@ -0,0 +1,532 @@ +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']); + } +} diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php index 88be540..3ee6c02 100644 --- a/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php +++ b/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php @@ -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(); + }); +}); diff --git a/packages/core-api/src/Mod/Api/config.php b/packages/core-api/src/Mod/Api/config.php index f676b65..701ee76 100644 --- a/packages/core-api/src/Mod/Api/config.php +++ b/packages/core-api/src/Mod/Api/config.php @@ -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, ], /* diff --git a/packages/core-api/src/Website/Api/Controllers/DocsController.php b/packages/core-api/src/Website/Api/Controllers/DocsController.php index f12e91a..05de0f4 100644 --- a/packages/core-api/src/Website/Api/Controllers/DocsController.php +++ b/packages/core-api/src/Website/Api/Controllers/DocsController.php @@ -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'); diff --git a/packages/core-api/src/Website/Api/Routes/web.php b/packages/core-api/src/Website/Api/Routes/web.php index 5305c5d..b90954b 100644 --- a/packages/core-api/src/Website/Api/Routes/web.php +++ b/packages/core-api/src/Website/Api/Routes/web.php @@ -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'); diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/authentication.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/authentication.blade.php index 147a2c3..5c27993 100644 --- a/packages/core-api/src/Website/Api/View/Blade/guides/authentication.blade.php +++ b/packages/core-api/src/Website/Api/View/Blade/guides/authentication.blade.php @@ -62,7 +62,7 @@

Overview

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

API keys are prefixed with hk_ to make them easily identifiable. @@ -76,7 +76,7 @@ To create an API key:

    -
  1. Log in to your Host UK account
  2. +
  3. Log in to your account
  4. Navigate to Settings → API Keys
  5. Click Create API Key
  6. Enter a descriptive name (e.g., "Production", "Development")
  7. diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/biolinks.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/biolinks.blade.php deleted file mode 100644 index 15caa2a..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/guides/biolinks.blade.php +++ /dev/null @@ -1,197 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'Managing Biolinks') - -@section('content') -
    - - {{-- Sidebar --}} - - - {{-- Main content --}} -
    -
    - - {{-- Breadcrumb --}} - - -

    Managing Biolinks

    -

    - Create, update, and manage biolink pages with blocks and themes. -

    - - {{-- Overview --}} -
    -

    Overview

    -

    - Biolinks are customisable landing pages hosted at lt.hn/yourpage. Each biolink can contain multiple blocks of content, including links, text, images, and more. -

    -
    - - {{-- Create Biolink --}} - - - {{-- Add Blocks --}} -
    -

    Add Blocks

    -

    - Add content blocks to your biolink: -

    - -
    -
    - cURL -
    -
    curl --request POST \
    -  --url 'https://api.host.uk.com/api/v1/bio/1/blocks' \
    -  --header 'Authorization: Bearer YOUR_API_KEY' \
    -  --header 'Content-Type: application/json' \
    -  --data '{
    -    "type": "link",
    -    "data": {
    -      "title": "Visit My Website",
    -      "url": "https://example.com"
    -    }
    -  }'
    -
    -
    - - {{-- Block Types --}} -
    -

    Block Types

    -

    - Available block types: -

    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    TypeDescription
    linkClickable link button
    textText paragraph or heading
    imageImage with optional link
    socialsSocial media icon links
    dividerVisual separator
    -
    -
    - - {{-- Update Biolink --}} - - - {{-- Next steps --}} - - -
    -
    - -
    -@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/errors.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/errors.blade.php index f5afa28..2bb9770 100644 --- a/packages/core-api/src/Website/Api/View/Blade/guides/errors.blade.php +++ b/packages/core-api/src/Website/Api/View/Blade/guides/errors.blade.php @@ -62,7 +62,7 @@

    Overview

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

    diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/index.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/index.blade.php index 476efbb..ef77a68 100644 --- a/packages/core-api/src/Website/Api/View/Blade/guides/index.blade.php +++ b/packages/core-api/src/Website/Api/View/Blade/guides/index.blade.php @@ -7,7 +7,7 @@

    Guides

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

    @@ -24,7 +24,7 @@ Getting Started

    Quick Start

    -

    Get up and running with the Host UK API in under 5 minutes.

    +

    Get up and running with the API in under 5 minutes.

    {{-- Authentication --}} @@ -41,20 +41,6 @@

    Learn how to authenticate your API requests using API keys.

    - {{-- Biolinks --}} - -
    -
    - - - -
    - Core -
    -

    Managing Biolinks

    -

    Create, update, and manage biolink pages with blocks and themes.

    -
    - {{-- QR Codes --}}
    diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/qrcodes.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/qrcodes.blade.php index 216750a..6d08861 100644 --- a/packages/core-api/src/Website/Api/View/Blade/guides/qrcodes.blade.php +++ b/packages/core-api/src/Website/Api/View/Blade/guides/qrcodes.blade.php @@ -62,7 +62,7 @@

    Overview

    - The Host UK API provides two ways to generate QR codes: + The API provides two ways to generate QR codes:

    • Biolink QR codes - Generate QR codes for your existing biolinks
    • @@ -92,7 +92,7 @@
      {
         "data": {
           "svg": "<svg>...</svg>",
      -    "url": "https://lt.hn/mypage"
      +    "url": "https://example.com/mypage"
         }
       }
    diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/quickstart.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/quickstart.blade.php index d14871c..c71acdf 100644 --- a/packages/core-api/src/Website/Api/View/Blade/guides/quickstart.blade.php +++ b/packages/core-api/src/Website/Api/View/Blade/guides/quickstart.blade.php @@ -55,7 +55,7 @@

    Quick Start

    - Get up and running with the Host UK API in under 5 minutes. + Get up and running with the API in under 5 minutes.

    {{-- Prerequisites --}} @@ -65,7 +65,7 @@ Before you begin, you'll need:

    @@ -155,7 +155,7 @@

    - This creates a new biolink page at lt.hn/mypage. + This creates a new biolink page at your configured short URL.

diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/webhooks.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/webhooks.blade.php index 7a7b942..31323fa 100644 --- a/packages/core-api/src/Website/Api/View/Blade/guides/webhooks.blade.php +++ b/packages/core-api/src/Website/Api/View/Blade/guides/webhooks.blade.php @@ -30,9 +30,24 @@ Payload Format +
  • + + Request Headers + +
  • - Verification + Signature Verification + +
  • +
  • + + Retry Policy + +
  • +
  • + + Best Practices
  • @@ -55,22 +70,25 @@

    Webhooks

    - Receive real-time notifications for events in your workspace. + Receive real-time notifications for events in your workspace with cryptographically signed payloads.

    {{-- Overview --}}

    Overview

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

    -
    +

    + 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. +

    +
    - - + + -

    - Coming soon: Webhook functionality is currently in development. +

    + Security: Always verify webhook signatures before processing. Never trust unverified webhook requests.

    @@ -82,13 +100,23 @@

    To configure webhooks:

    -
      -
    1. Go to Settings → Webhooks in your workspace
    2. +
        +
      1. Go to Settings → Webhooks in your workspace
      2. Click Add Webhook
      3. -
      4. Enter your endpoint URL (must be HTTPS)
      5. +
      6. Enter your endpoint URL (must be HTTPS in production)
      7. Select the events you want to receive
      8. -
      9. Save and note your webhook secret
      10. +
      11. Save and securely store your webhook secret
      +
      +
      + + + +

      + Your webhook secret is only shown once when you create the endpoint. Store it securely - you'll need it to verify incoming webhooks. +

      +
      +
    {{-- Events --}} @@ -108,20 +136,36 @@ - biolink.created + bio.created A new biolink was created - biolink.updated + bio.updated A biolink was updated - biolink.deleted + bio.deleted A biolink was deleted - click.tracked - A link click was recorded + link.created + A new link was created + + + link.clicked + A link was clicked (high volume) + + + qrcode.created + A QR code was generated + + + qrcode.scanned + A QR code was scanned (high volume) + + + * + Subscribe to all events (wildcard) @@ -132,13 +176,13 @@

    Payload Format

    - Webhook payloads are sent as JSON: + Webhook payloads are sent as JSON with a consistent structure:

    {
    -  "id": "evt_abc123",
    -  "type": "biolink.created",
    +  "id": "evt_abc123xyz456",
    +  "type": "bio.created",
       "created_at": "2024-01-15T10:30:00Z",
       "workspace_id": 1,
       "data": {
    @@ -150,30 +194,381 @@
                     
    + {{-- Headers --}} +
    +

    Request Headers

    +

    + Every webhook request includes the following headers: +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    HeaderDescription
    X-Webhook-SignatureHMAC-SHA256 signature for verification
    X-Webhook-TimestampUnix timestamp when the webhook was sent
    X-Webhook-EventThe event type (e.g., bio.created)
    X-Webhook-IdUnique delivery ID for idempotency
    Content-TypeAlways application/json
    +
    +
    + {{-- Verification --}}
    -

    Verification

    +

    Signature Verification

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

    -
    -
    X-Host-Signature: sha256=abc123...
    +

    Verification Algorithm

    +
      +
    1. Extract X-Webhook-Signature and X-Webhook-Timestamp headers
    2. +
    3. Concatenate: timestamp + "." + raw_request_body
    4. +
    5. Compute: HMAC-SHA256(concatenated_string, your_webhook_secret)
    6. +
    7. Compare using timing-safe comparison (prevents timing attacks)
    8. +
    9. Verify timestamp is within 5 minutes of current time (prevents replay attacks)
    10. +
    + + {{-- PHP Example --}} +

    PHP

    +
    +
    + webhook-handler.php +
    +
    <?php
    +
    +// Get request data
    +$payload = file_get_contents('php://input');
    +$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
    +$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
    +$secret = getenv('WEBHOOK_SECRET');
    +
    +// Verify timestamp (5 minute tolerance)
    +$tolerance = 300;
    +if (abs(time() - (int)$timestamp) > $tolerance) {
    +    http_response_code(401);
    +    die('Webhook timestamp expired');
    +}
    +
    +// Compute expected signature
    +$signedPayload = $timestamp . '.' . $payload;
    +$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
    +
    +// Verify signature (timing-safe comparison)
    +if (!hash_equals($expectedSignature, $signature)) {
    +    http_response_code(401);
    +    die('Invalid webhook signature');
    +}
    +
    +// Signature valid - process the webhook
    +$event = json_decode($payload, true);
    +processWebhook($event);
    -

    - Verify the signature by computing HMAC-SHA256 of the request body using your webhook secret: -

    + {{-- Node.js Example --}} +

    Node.js

    +
    +
    + webhook-handler.js +
    +
    const crypto = require('crypto');
    +const express = require('express');
     
    +const app = express();
    +app.use(express.raw({ type: 'application/json' }));
    +
    +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
    +const TOLERANCE = 300; // 5 minutes
    +
    +app.post('/webhook', (req, res) => {
    +    const signature = req.headers['x-webhook-signature'];
    +    const timestamp = req.headers['x-webhook-timestamp'];
    +    const payload = req.body;
    +
    +    // Verify timestamp
    +    const now = Math.floor(Date.now() / 1000);
    +    if (Math.abs(now - parseInt(timestamp)) > TOLERANCE) {
    +        return res.status(401).send('Webhook timestamp expired');
    +    }
    +
    +    // Compute expected signature
    +    const signedPayload = `${timestamp}.${payload}`;
    +    const expectedSignature = crypto
    +        .createHmac('sha256', WEBHOOK_SECRET)
    +        .update(signedPayload)
    +        .digest('hex');
    +
    +    // Verify signature (timing-safe comparison)
    +    if (!crypto.timingSafeEqual(
    +        Buffer.from(expectedSignature),
    +        Buffer.from(signature)
    +    )) {
    +        return res.status(401).send('Invalid webhook signature');
    +    }
    +
    +    // Signature valid - process the webhook
    +    const event = JSON.parse(payload);
    +    processWebhook(event);
    +    res.status(200).send('OK');
    +});
    +
    + + {{-- Python Example --}} +

    Python

    +
    +
    + webhook_handler.py +
    +
    import hmac
    +import hashlib
    +import time
    +import os
    +from flask import Flask, request, abort
    +
    +app = Flask(__name__)
    +WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
    +TOLERANCE = 300  # 5 minutes
    +
    +@app.route('/webhook', methods=['POST'])
    +def webhook():
    +    signature = request.headers.get('X-Webhook-Signature', '')
    +    timestamp = request.headers.get('X-Webhook-Timestamp', '')
    +    payload = request.get_data(as_text=True)
    +
    +    # Verify timestamp
    +    if abs(time.time() - int(timestamp)) > TOLERANCE:
    +        abort(401, 'Webhook timestamp expired')
    +
    +    # Compute expected signature
    +    signed_payload = f'{timestamp}.{payload}'
    +    expected_signature = hmac.new(
    +        WEBHOOK_SECRET.encode(),
    +        signed_payload.encode(),
    +        hashlib.sha256
    +    ).hexdigest()
    +
    +    # Verify signature (timing-safe comparison)
    +    if not hmac.compare_digest(expected_signature, signature):
    +        abort(401, 'Invalid webhook signature')
    +
    +    # Signature valid - process the webhook
    +    event = request.get_json()
    +    process_webhook(event)
    +    return 'OK', 200
    +
    + + {{-- Ruby Example --}} +

    Ruby

    +
    +
    + webhook_handler.rb +
    +
    require 'sinatra'
    +require 'openssl'
    +require 'json'
    +
    +WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']
    +TOLERANCE = 300  # 5 minutes
    +
    +post '/webhook' do
    +  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || ''
    +  timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP'] || ''
    +  payload = request.body.read
    +
    +  # Verify timestamp
    +  if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE
    +    halt 401, 'Webhook timestamp expired'
    +  end
    +
    +  # Compute expected signature
    +  signed_payload = "#{timestamp}.#{payload}"
    +  expected_signature = OpenSSL::HMAC.hexdigest(
    +    'sha256',
    +    WEBHOOK_SECRET,
    +    signed_payload
    +  )
    +
    +  # Verify signature (timing-safe comparison)
    +  unless Rack::Utils.secure_compare(expected_signature, signature)
    +    halt 401, 'Invalid webhook signature'
    +  end
    +
    +  # Signature valid - process the webhook
    +  event = JSON.parse(payload)
    +  process_webhook(event)
    +  200
    +end
    +
    + + {{-- Go Example --}} +

    Go

    - PHP + webhook_handler.go
    -
    $signature = hash_hmac('sha256', $requestBody, $webhookSecret);
    -$valid = hash_equals('sha256=' . $signature, $headerSignature);
    +
    package main
    +
    +import (
    +    "crypto/hmac"
    +    "crypto/sha256"
    +    "crypto/subtle"
    +    "encoding/hex"
    +    "io"
    +    "math"
    +    "net/http"
    +    "os"
    +    "strconv"
    +    "time"
    +)
    +
    +const tolerance = 300 // 5 minutes
    +
    +func webhookHandler(w http.ResponseWriter, r *http.Request) {
    +    signature := r.Header.Get("X-Webhook-Signature")
    +    timestamp := r.Header.Get("X-Webhook-Timestamp")
    +    secret := os.Getenv("WEBHOOK_SECRET")
    +
    +    payload, _ := io.ReadAll(r.Body)
    +
    +    // Verify timestamp
    +    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    +    if math.Abs(float64(time.Now().Unix()-ts)) > tolerance {
    +        http.Error(w, "Webhook timestamp expired", 401)
    +        return
    +    }
    +
    +    // Compute expected signature
    +    signedPayload := timestamp + "." + string(payload)
    +    mac := hmac.New(sha256.New, []byte(secret))
    +    mac.Write([]byte(signedPayload))
    +    expectedSignature := hex.EncodeToString(mac.Sum(nil))
    +
    +    // Verify signature (timing-safe comparison)
    +    if subtle.ConstantTimeCompare(
    +        []byte(expectedSignature),
    +        []byte(signature),
    +    ) != 1 {
    +        http.Error(w, "Invalid webhook signature", 401)
    +        return
    +    }
    +
    +    // Signature valid - process the webhook
    +    processWebhook(payload)
    +    w.WriteHeader(http.StatusOK)
    +}
    + {{-- Retry Policy --}} +
    +

    Retry Policy

    +

    + If your endpoint returns a non-2xx status code or times out, we'll retry with exponential backoff: +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AttemptDelay
    1 (initial)Immediate
    21 minute
    35 minutes
    430 minutes
    5 (final)2 hours
    +
    + +

    + 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. +

    +
    + + {{-- Best Practices --}} +
    +

    Best Practices

    +
      +
    • + + + + Always verify signatures - Never process webhooks without verification +
    • +
    • + + + + Respond quickly - Return 200 within 30 seconds to avoid timeouts +
    • +
    • + + + + Process asynchronously - Queue webhook processing for long-running tasks +
    • +
    • + + + + Handle duplicates - Use X-Webhook-Id for idempotency +
    • +
    • + + + + Use HTTPS - Always use HTTPS endpoints in production +
    • +
    • + + + + Rotate secrets regularly - Rotate your webhook secret periodically +
    • +
    +
    + {{-- Next steps --}}